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).
159 lines
7.1 KiB
TypeScript
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)
|
|
})
|
|
})
|