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).
This commit is contained in:
158
relay-run/tests/wiring/mtls-verifier.test.ts
Normal file
158
relay-run/tests/wiring/mtls-verifier.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user