Files
web-terminal/relay-run/tests/wiring/mtls-verifier.test.ts
Yaojia Wang aa1912b962 feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts
RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass):
- A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script.
- B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3).
- B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14).
- B3: store-backed RouteResolver (subdomain->hostId).
- B4: Redis relay:revocations subscriber -> tunnel teardown (INV12).
- B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint.
- B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol.
- C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps.
- D1: same-origin static serve of relay-web/public from the browser WSS.
- E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md.
Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on
browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2);
scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
2026-07-06 16:13:34 +02:00

159 lines
7.1 KiB
TypeScript

/**
* B2 tests — registry-backed MtlsVerifier (INV4/INV14, fail-closed).
*
* Two layers, mirroring relay-auth/test/mtls.test.ts:
* 1. REAL X.509 path: a self-signed root → leaf chain (relay-auth's committed mTLS fixtures) fed to
* `verifyPeer` as DER bytes, exercising the production `defaultParseX509` chain walk + DER→PEM
* conversion end-to-end against a fake host registry.
* 2. Deterministic seam: an injected `ParseCert` isolates the DER→PEM conversion + registry-gating +
* fail-closed mapping without depending on fixture contents.
*/
import { readFileSync } from 'node:fs'
import { X509Certificate } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { describe, it, expect } from 'vitest'
import { defaultParseX509, spiffeIdFor, type ParseCert, type ParsedCert } from 'relay-auth'
import type { HostRegistryPort } from 'relay-auth'
import { createMtlsVerifier, derToPem } from '../../src/wiring/mtls-verifier.js'
import { fakeHostRegistry, makeHostRecord } from '../../src/wiring/memory-stores.js'
// Reuse the committed real mTLS fixtures (leaf chains to ca-chain; SPIFFE account/acct-A/host/host-1).
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '../../../relay-auth/test/fixtures/mtls')
const readFixture = (name: string): string => readFileSync(join(FIXTURES, name), 'utf8')
const leafPem = readFixture('leaf.pem')
const caChainPem = readFixture('ca-chain.pem')
const foreignLeafPem = readFixture('foreign-leaf.pem')
const leafDer = new Uint8Array(new X509Certificate(leafPem).raw)
const foreignDer = new Uint8Array(new X509Certificate(foreignLeafPem).raw)
// A time strictly inside the leaf's validity window (fixtures are long-lived; do not hardcode).
const parsedLeaf = defaultParseX509(leafPem, caChainPem)
const IN_WINDOW = parsedLeaf.notBefore + 60
const enrolledHosts = (): HostRegistryPort =>
fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1')])
describe('derToPem (DER → PEM)', () => {
it('wraps DER as a 64-column PEM CERTIFICATE block that round-trips back to the same DER', () => {
const pem = derToPem(leafDer)
expect(pem.startsWith('-----BEGIN CERTIFICATE-----\n')).toBe(true)
expect(pem.trimEnd().endsWith('-----END CERTIFICATE-----')).toBe(true)
// No base64 body line exceeds the PEM 64-column width.
const body = pem.split('\n').slice(1, -2)
for (const line of body) expect(line.length).toBeLessThanOrEqual(64)
// Parsing the produced PEM yields byte-identical DER.
expect(new Uint8Array(new X509Certificate(pem).raw)).toEqual(leafDer)
})
})
describe('createMtlsVerifier — real X.509 path (INV14)', () => {
it('accepts an enrolled, in-date leaf chaining to the pinned CA → {hostId, accountId}', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toEqual({ hostId: 'host-1', accountId: 'acct-A' })
})
it('refuses a leaf signed by a foreign CA not in the pinned bundle → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(foreignDer)).toBeNull()
})
it('refuses a valid leaf whose host is NOT in the registry (INV4) → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: fakeHostRegistry([]), now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses when the registry account ≠ the cert SPIFFE account → null', async () => {
const hosts = fakeHostRegistry([makeHostRecord('acct-B', 'host-1', 'sub-host-1')])
const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses a revoked host (INV12/registry gate) → null', async () => {
const hosts = fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1', 'revoked')])
const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses an expired leaf → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notAfter + 100 })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses a not-yet-valid leaf → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notBefore - 100 })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
})
describe('createMtlsVerifier — fail-closed on malformed / absent input', () => {
it('returns null for an empty DER (no client cert presented)', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(new Uint8Array(0))).toBeNull()
})
it('returns null for garbage DER bytes that are not a certificate', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(new Uint8Array([1, 2, 3, 4, 5]))).toBeNull()
})
})
describe('createMtlsVerifier — construction validation (INV14)', () => {
it('throws when the CA bundle is empty', () => {
expect(() => createMtlsVerifier({ caChainPem: '', hosts: enrolledHosts(), now: () => IN_WINDOW })).toThrow()
})
it('throws when the CA bundle has no PEM CERTIFICATE block', () => {
expect(() =>
createMtlsVerifier({ caChainPem: 'not a certificate', hosts: enrolledHosts(), now: () => IN_WINDOW }),
).toThrow()
})
})
// ── Deterministic seam: isolate DER→PEM + registry gating from fixture contents ──────────────────
const okParsed = (spiffeUri: string): ParsedCert => ({
spiffeUri,
notBefore: 0,
notAfter: 4_000_000_000,
chainValid: true,
})
describe('createMtlsVerifier — ParseCert seam', () => {
it('feeds verifyAgentCert the exact PEM produced by derToPem from the input DER', async () => {
let seenLeafPem: string | null = null
const capturingParse: ParseCert = (leaf) => {
seenLeafPem = leaf
return okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com'))
}
const der = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1])
const v = createMtlsVerifier({
caChainPem,
hosts: enrolledHosts(),
now: () => 1_000_000,
parse: capturingParse,
})
expect(await v.verifyPeer(der)).toEqual({ hostId: 'host-1', accountId: 'acct-A' })
expect(seenLeafPem).toBe(derToPem(der))
})
it('fails closed (null) and reports to onError when the registry lookup throws', async () => {
const errors: unknown[] = []
const throwingHosts: HostRegistryPort = {
getById: async () => {
throw new Error('registry unavailable')
},
}
const v = createMtlsVerifier({
caChainPem,
hosts: throwingHosts,
now: () => 1_000_000,
onError: (e) => errors.push(e),
parse: () => okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com')),
})
expect(await v.verifyPeer(new Uint8Array([1, 2, 3]))).toBeNull()
expect(errors).toHaveLength(1)
})
})