OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / integration / public-hive-gateway-ops.test.js

397 lines · javascript ref:
import test from 'brittle'
import { execFile, spawnSync } from 'node:child_process'
import { createHash, X509Certificate } from 'node:crypto'
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import {
  encodeHiveAppKey
} from '../../packages/core/gateway/hive-host.js'
import {
  PUBLIC_HIVE_GATEWAY_DNS_SNAPSHOT_SCHEMA,
  PUBLIC_HIVE_GATEWAY_FINITE_POLICY,
  PUBLIC_HIVE_GATEWAY_OPS_CONTRACT_SCHEMA,
  PUBLIC_HIVE_GATEWAY_TLS_SNAPSHOT_SCHEMA,
  inspectPublicHiveGatewayCertificate,
  normalizePublicHiveGatewayOpsContract
} from '../../scripts/lib/public-hive-gateway-ops.mjs'
import {
  PUBLIC_HIVE_GATEWAY_EVIDENCE_SCHEMA,
  PUBLIC_HIVE_GATEWAY_PROBE_CHECKS,
  PUBLIC_HIVE_GATEWAY_PROBE_SCHEMA
} from '../../scripts/lib/public-hive-gateway-evidence.mjs'

const execFileAsync = promisify(execFile)
const APP_KEY = 'a'.repeat(64)
const CONTENT_SHA = 'c'.repeat(64)
const NGINX_SHA = 'd'.repeat(64)
const RELEASE_SHA = 'e'.repeat(40)
const IPV4 = '8.8.8.8'
const IPV6 = '2606:4700:4700::1111'
const SUFFIX = 'hive-canary.operator.example'

test('public gateway ops certificate - wildcard SAN, key, chain, lifetime, and identity are exact', async (t) => {
  const root = await mkdtemp(join(tmpdir(), 'hiverelay-ops-cert-'))
  t.teardown(() => rm(root, { recursive: true, force: true }))
  const fixture = await createCertificateChain(root, SUFFIX)
  const contract = normalizePublicHiveGatewayOpsContract(contractFixture(fixture))
  const now = Date.now()
  const valid = inspectPublicHiveGatewayCertificate(fixture.fullchain, fixture.privateKey, contract, { now })
  t.ok(valid.ok, valid.errors.join('\n'))
  t.is(valid.identity.fingerprint256, fixture.fingerprint256)
  t.is(valid.identity.spkiSha256, fixture.spkiSha256)
  t.alike(valid.identity.dnsNames, [`*.${SUFFIX}`])
  t.is(valid.identity.chainLength, 2)

  const wrongKey = inspectPublicHiveGatewayCertificate(fixture.fullchain, fixture.caPrivateKey, contract, { now })
  t.absent(wrongKey.ok)
  t.ok(wrongKey.errors.some(value => value.includes('do not match')))

  const noChain = inspectPublicHiveGatewayCertificate(fixture.leafCertificate, fixture.privateKey, contract, { now })
  t.absent(noChain.ok)
  t.ok(noChain.errors.some(value => value.includes('fullchain')))

  const nearExpiry = inspectPublicHiveGatewayCertificate(fixture.fullchain, fixture.privateKey, contract, {
    now: Date.parse(fixture.validTo) - 6 * 24 * 60 * 60 * 1000
  })
  t.absent(nearExpiry.ok)
  t.ok(nearExpiry.errors.some(value => value.includes('at least 7 days')))

  const wrongSuffix = normalizePublicHiveGatewayOpsContract(contractFixture(fixture, {
    suffix: 'hive.other.example',
    registrableDomain: 'other.example',
    apiHostname: 'relay-api.other.example'
  }))
  const wrongSan = inspectPublicHiveGatewayCertificate(fixture.fullchain, fixture.privateKey, wrongSuffix, { now })
  t.absent(wrongSan.ok)
  t.ok(wrongSan.errors.some(value => value.includes('exactly the DNS SAN')))
})

test('public gateway ops CLI - certbot symlinks and offline DNS/TLS/socket fixtures produce bounded rehearsal evidence', async (t) => {
  const root = await mkdtemp(join(tmpdir(), 'hiverelay-ops-cli-'))
  t.teardown(() => rm(root, { recursive: true, force: true }))
  const certificateRoot = join(root, 'letsencrypt')
  const archive = join(certificateRoot, 'archive', 'hiverelay-public-apps')
  const live = join(certificateRoot, 'live', 'hiverelay-public-apps')
  await mkdir(archive, { recursive: true })
  await mkdir(live, { recursive: true })
  const fixture = await createCertificateChain(root, SUFFIX)
  const archiveCertificate = join(archive, 'fullchain1.pem')
  const archiveKey = join(archive, 'privkey1.pem')
  await writeFile(archiveCertificate, fixture.fullchain)
  await writeFile(archiveKey, fixture.privateKey, { mode: 0o600 })
  await symlink('../../archive/hiverelay-public-apps/fullchain1.pem', join(live, 'fullchain.pem'))
  await symlink('../../archive/hiverelay-public-apps/privkey1.pem', join(live, 'privkey.pem'))

  const now = Date.now()
  const contract = normalizePublicHiveGatewayOpsContract(contractFixture(fixture))
  const appHostname = contract.appHostname
  const witnessHostname = `${encodeHiveAppKey(Buffer.alloc(32))}.${SUFFIX}`
  const config = {
    productProfile: 'relay-core',
    requirePhysicalEnforcement: true,
    apiHost: '127.0.0.1',
    apiPort: 9100,
    gatewayHost: '127.0.0.1',
    gatewayPort: 9200,
    gatewayTrustProxy: true,
    gatewayRequireForwardedSNI: true,
    gatewayMaxResponseBytes: 67108864,
    gatewayMaxTransformBytes: 4194304,
    gatewayEgressBytesPerWindow: 268435456,
    gatewayEgressWindowMs: 60000,
    gatewayMaxResponseLifetimeMs: 900000,
    custody: { enabled: false },
    hiveAppHostSuffix: SUFFIX,
    hiveAppPublicKeys: [APP_KEY],
    hiveAppPublicVersions: { [APP_KEY]: 7 }
  }
  const gatewayEvidence = baseEvidenceFixture(contract, now, fixture.validTo)
  const answer = {
    a: [{ address: IPV4, ttl: 300 }],
    aaaa: [{ address: IPV6, ttl: 300 }],
    cname: []
  }
  const dns = {
    schema: PUBLIC_HIVE_GATEWAY_DNS_SNAPSHOT_SCHEMA,
    observedAt: new Date(now).toISOString(),
    hostname: appHostname,
    witnessHostname,
    app: structuredClone(answer),
    witness: structuredClone(answer),
    routing: {
      app: { https: [], svcb: [] },
      witness: { https: [], svcb: [] }
    }
  }
  const tls = {
    schema: PUBLIC_HIVE_GATEWAY_TLS_SNAPSHOT_SCHEMA,
    observedAt: new Date(now).toISOString(),
    hostname: appHostname,
    port: 443,
    endpoints: [IPV4, IPV6].map(address => ({
      address,
      authorized: true,
      protocol: 'TLSv1.3',
      fingerprint256: fixture.fingerprint256,
      spkiSha256: fixture.spkiSha256,
      validTo: fixture.validTo,
      probe: { ...gatewayEvidence.probe, connectAddress: address }
    }))
  }
  const sockets = [
    'LISTEN 0 511 0.0.0.0:443 0.0.0.0:*',
    'LISTEN 0 511 [::]:443 [::]:*',
    'LISTEN 0 511 127.0.0.1:9100 0.0.0.0:*',
    'LISTEN 0 511 127.0.0.1:9200 0.0.0.0:*',
    ''
  ].join('\n')

  const contractPath = join(root, 'operator-contract.json')
  const configPath = join(root, 'config.json')
  const gatewayEvidencePath = join(root, 'gateway-evidence.json')
  const dnsPath = join(root, 'dns.json')
  const tlsPath = join(root, 'tls.json')
  const socketsPath = join(root, 'sockets.txt')
  const outputPath = join(root, 'ops-evidence.json')
  await Promise.all([
    writeFile(contractPath, JSON.stringify(contractFixture(fixture))),
    writeFile(configPath, JSON.stringify(config)),
    writeFile(gatewayEvidencePath, JSON.stringify(gatewayEvidence)),
    writeFile(dnsPath, JSON.stringify(dns)),
    writeFile(tlsPath, JSON.stringify(tls)),
    writeFile(socketsPath, sockets)
  ])

  const result = runOps([
    '--mode', 'rehearsal',
    '--contract', contractPath,
    '--config', configPath,
    '--gateway-evidence', gatewayEvidencePath,
    '--release-sha', RELEASE_SHA,
    '--certificate', join(live, 'fullchain.pem'),
    '--certificate-key', join(live, 'privkey.pem'),
    '--certificate-root', certificateRoot,
    '--dns-snapshot', dnsPath,
    '--tls-snapshot', tlsPath,
    '--socket-snapshot', socketsPath,
    '--evidence', outputPath
  ])
  t.is(result.status, 0, result.stderr)
  const evidence = JSON.parse(result.stdout)
  t.is(evidence.status, 'pass')
  t.alike(evidence.errors, [])
  t.is(evidence.certificate.fingerprint256, fixture.fingerprint256)
  t.is(evidence.certificate.spkiSha256, fixture.spkiSha256)
  t.alike(evidence.checks, {
    contract: true,
    config: true,
    certificate: true,
    dns: true,
    tls: true,
    sockets: true,
    gateway: true
  })
  t.absent(evidence.claims.provesCurrentDnsAnswers, 'fixture does not become live DNS proof')
  t.absent(evidence.claims.provesBlindG2)
  t.absent(evidence.claims.provesBlindG3)
  t.ok(evidence.claims.attestsFiniteProductionPolicyValues)
  t.absent(evidence.claims.provesFiniteProductionPolicyBehavior)
  t.is(evidence.finiteProductionPolicy.maxResponseBytes, 67108864)
  t.is(evidence.finiteProductionPolicy.egressWindowMs, 60000)
  t.absent(evidence.finiteProductionPolicy.signedReleaseBound)
  t.alike(JSON.parse(await readFile(outputPath, 'utf8')), evidence)

  await chmod(archiveKey, 0o644)
  const permissiveKey = runOps([
    '--mode', 'rehearsal',
    '--contract', contractPath,
    '--config', configPath,
    '--gateway-evidence', gatewayEvidencePath,
    '--release-sha', RELEASE_SHA,
    '--certificate', join(live, 'fullchain.pem'),
    '--certificate-key', join(live, 'privkey.pem'),
    '--certificate-root', certificateRoot,
    '--dns-snapshot', dnsPath,
    '--tls-snapshot', tlsPath,
    '--socket-snapshot', socketsPath
  ])
  t.not(permissiveKey.status, 0)
  t.ok(permissiveKey.stderr.includes('must not be group/world accessible'))
})

test('public gateway ops verifier CLI - readiness evidence cannot be supplied through a symlink', async (t) => {
  const root = await mkdtemp(join(tmpdir(), 'hiverelay-ops-verifier-'))
  t.teardown(() => rm(root, { recursive: true, force: true }))
  const target = join(root, 'target.json')
  const evidence = join(root, 'evidence.json')
  const contract = join(root, 'contract.json')
  const manifest = join(root, 'manifest.json')
  await writeFile(target, '{}\n')
  await symlink(target, evidence)
  await writeFile(contract, '{}\n')
  await writeFile(manifest, '{}\n')
  const result = spawnSync(process.execPath, [
    'scripts/verify-public-hive-gateway-ops-evidence.mjs',
    '--evidence', evidence,
    '--contract', contract,
    '--release-manifest', manifest,
    '--release-sha', RELEASE_SHA,
    '--relay', 'utah',
    '--expected-contract-sha256', 'f'.repeat(64)
  ], {
    encoding: 'utf8',
    maxBuffer: 1024 * 1024,
    timeout: 30_000
  })
  t.not(result.status, 0)
  t.ok(result.stderr.includes('operator readiness evidence must be a readable non-symlink file'))
})

async function createCertificateChain (directory, suffix) {
  const rootConfig = join(directory, `root-${Date.now()}.cnf`)
  const leafConfig = join(directory, `leaf-${Date.now()}.cnf`)
  const caKeyPath = join(directory, `root-${Date.now()}.key`)
  const caCertPath = join(directory, `root-${Date.now()}.crt`)
  const keyPath = join(directory, `leaf-${Date.now()}.key`)
  const requestPath = join(directory, `leaf-${Date.now()}.csr`)
  const certificatePath = join(directory, `leaf-${Date.now()}.crt`)
  await writeFile(rootConfig, [
    '[req]',
    'prompt = no',
    'distinguished_name = dn',
    'x509_extensions = extensions',
    '[dn]',
    'CN = HiveRelay operator test root',
    '[extensions]',
    'basicConstraints = critical,CA:TRUE,pathlen:1',
    'keyUsage = critical,keyCertSign,cRLSign',
    'subjectKeyIdentifier = hash',
    ''
  ].join('\n'))
  await writeFile(leafConfig, [
    '[req]',
    'prompt = no',
    'distinguished_name = dn',
    'req_extensions = extensions',
    '[dn]',
    `CN = *.${suffix}`,
    '[extensions]',
    'basicConstraints = critical,CA:FALSE',
    'keyUsage = critical,digitalSignature,keyEncipherment',
    'extendedKeyUsage = serverAuth',
    `subjectAltName = DNS:*.${suffix}`,
    ''
  ].join('\n'))
  await execFileAsync('openssl', [
    'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '30',
    '-config', rootConfig, '-keyout', caKeyPath, '-out', caCertPath
  ])
  await execFileAsync('openssl', [
    'req', '-new', '-newkey', 'rsa:2048', '-nodes',
    '-config', leafConfig, '-keyout', keyPath, '-out', requestPath
  ])
  await execFileAsync('openssl', [
    'x509', '-req', '-in', requestPath,
    '-CA', caCertPath, '-CAkey', caKeyPath, '-CAcreateserial',
    '-days', '30', '-extfile', leafConfig, '-extensions', 'extensions',
    '-out', certificatePath
  ])
  const leafCertificate = await readFile(certificatePath, 'utf8')
  const caCertificate = await readFile(caCertPath, 'utf8')
  const privateKey = await readFile(keyPath, 'utf8')
  const caPrivateKey = await readFile(caKeyPath, 'utf8')
  const leaf = new X509Certificate(leafCertificate)
  return {
    fullchain: leafCertificate + caCertificate,
    leafCertificate,
    privateKey,
    caPrivateKey,
    fingerprint256: leaf.fingerprint256.toUpperCase(),
    spkiSha256: createHash('sha256').update(leaf.publicKey.export({ type: 'spki', format: 'der' })).digest('hex'),
    validTo: new Date(leaf.validToDate?.getTime?.() ?? Date.parse(leaf.validTo)).toISOString()
  }
}

function contractFixture (certificate, overrides = {}) {
  return {
    schema: PUBLIC_HIVE_GATEWAY_OPS_CONTRACT_SCHEMA,
    deploymentProfile: 'public-t1-gateway',
    relay: 'utah',
    channel: 'canary',
    operatorId: 'operator-a',
    registrableDomain: 'operator.example',
    apiHostname: 'relay-api.operator.example',
    suffix: SUFFIX,
    appKey: APP_KEY,
    addressFamilyPolicy: 'dual-stack',
    expectedAddresses: [IPV4, IPV6],
    expectedConnectAddress: '127.0.0.1',
    certificateFingerprint256: certificate.fingerprint256,
    certificateSpkiSha256: certificate.spkiSha256,
    publicSuffixReady: false,
    physicalEnforcementRequired: true,
    finiteProductionPolicy: { ...PUBLIC_HIVE_GATEWAY_FINITE_POLICY },
    release: {
      target: 'v1.2.3',
      expectedPath: '/index.html',
      expectedContentSha256: CONTENT_SHA,
      expectedDriveVersion: '7',
      expectedNginxSha256: NGINX_SHA
    },
    ...overrides
  }
}

function baseEvidenceFixture (contract, now, validTo) {
  return {
    schema: PUBLIC_HIVE_GATEWAY_EVIDENCE_SCHEMA,
    status: 'pass',
    checkedAt: new Date(now).toISOString(),
    mode: 'canary',
    admissionProfile: 'transitional-operator-allowlist-v1',
    release: { target: contract.release.target, sha: RELEASE_SHA },
    config: {
      suffix: contract.suffix,
      appKeyCount: 1,
      apiHost: '127.0.0.1',
      apiPort: 9100,
      gatewayHost: '127.0.0.1',
      gatewayPort: 9200,
      connectAddress: contract.expectedConnectAddress,
      publicSuffixReady: false,
      custodyEnabled: false,
      physicalEnforcementRequired: true,
      finiteProductionPolicy: { ...PUBLIC_HIVE_GATEWAY_FINITE_POLICY }
    },
    static: { ok: true, errors: [], warnings: ['transitional rehearsal'] },
    nginx: { ok: true, errors: [], source: 'active', sha256: NGINX_SHA },
    probe: {
      schema: PUBLIC_HIVE_GATEWAY_PROBE_SCHEMA,
      observedAt: new Date(now).toISOString(),
      origin: contract.origin,
      connectAddress: contract.expectedConnectAddress,
      appKey: APP_KEY,
      path: '/index.html',
      sha256: CONTENT_SHA,
      bytes: 42,
      driveVersion: '7',
      tlsProtocol: 'TLSv1.3',
      peerFingerprint256: contract.certificateFingerprint256,
      peerValidTo: validTo,
      metadataSigned: false,
      checks: Object.fromEntries(PUBLIC_HIVE_GATEWAY_PROBE_CHECKS.map(name => [name, true]))
    },
    probeError: null
  }
}

function runOps (args) {
  return spawnSync(process.execPath, ['scripts/preflight-public-hive-gateway-ops.mjs', ...args], {
    encoding: 'utf8',
    maxBuffer: 4 * 1024 * 1024,
    timeout: 30_000
  })
}