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:
93
relay-run/src/wiring/mtls-verifier.ts
Normal file
93
relay-run/src/wiring/mtls-verifier.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* B2 · registry-backed MtlsVerifier (INV4/INV14) — replaces the Phase-0 stub in `relay-world.ts:149`
|
||||
* that trusted ANY peer cert. Wraps relay-auth's `verifyAgentCert` against the SHARED host registry
|
||||
* (the same `HostRegistryPort` B1 builds over Postgres) and the pinned agent-CA bundle, returning
|
||||
* `{ hostId, accountId }` ONLY for an enrolled, unrevoked host whose leaf chains to our CA and is in
|
||||
* its validity window. Every other outcome is `null` (fail-closed).
|
||||
*
|
||||
* INV3: `hostId`/`accountId` originate ONLY from the authenticated cert material (SPIFFE-ID) matched
|
||||
* against the registry — never from a client-supplied field. INV14: the pubkey is bound to the
|
||||
* registry AFTER CA-chain validation (a cert can chain to our CA yet still be denied if not enrolled).
|
||||
*
|
||||
* ── ASYNC IMPEDANCE (flagged for the orchestrator; adaptation per task B2) ──────────────────────
|
||||
* The term-relay `MtlsVerifier.verifyPeer` (agent-listener.ts:16) is declared SYNC and its caller
|
||||
* (agent-listener.ts:93) does NOT await it (`if (verified === null)`). A registry-backed verifier
|
||||
* CANNOT be sync — `HostRegistryPort.getById` returns a Promise, so `verifyAgentCert` is async. This
|
||||
* factory therefore returns an `AsyncMtlsVerifier` (Promise-returning `verifyPeer`). Wiring it into the
|
||||
* data-plane requires `MtlsVerifier.verifyPeer` to become async END-TO-END: `agent-listener.ts` must
|
||||
* `await` the result, and the `relay-world.ts:149` stub must be replaced. Those files are OUTSIDE B2's
|
||||
* Owns. Until that migration lands, assigning this into the sync slot type-errors at the wiring site
|
||||
* (`Promise<…>` is not assignable to `{…}|null`) — a useful compile-time tripwire, not a silent bug.
|
||||
*/
|
||||
import {
|
||||
verifyAgentCert,
|
||||
defaultParseX509,
|
||||
type ParseCert,
|
||||
type HostRegistryPort,
|
||||
} from 'relay-auth'
|
||||
|
||||
const PEM_LINE_WIDTH = 64
|
||||
/** Non-global (safe for `.test()`): asserts a bundle actually holds a PEM CERTIFICATE block. */
|
||||
const PEM_CERT_RE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
|
||||
|
||||
/**
|
||||
* Async analog of term-relay's sync `MtlsVerifier` (agent-listener.ts). See ASYNC IMPEDANCE above:
|
||||
* a registry lookup is inherently async, so `verifyPeer` returns a Promise.
|
||||
*/
|
||||
export interface AsyncMtlsVerifier {
|
||||
verifyPeer(peerCertDer: Uint8Array): Promise<{ hostId: string; accountId: string } | null>
|
||||
}
|
||||
|
||||
export interface MtlsVerifierDeps {
|
||||
/** Pinned agent-CA bundle (PEM: intermediate(s) + root). NEVER the browser LE chain (INV14). */
|
||||
readonly caChainPem: string
|
||||
/** Shared host registry (same port B1 builds over Postgres). accountId/hostId only from here (INV3). */
|
||||
readonly hosts: HostRegistryPort
|
||||
/** Epoch-SECONDS provider (compared against the leaf's notBefore/notAfter). */
|
||||
readonly now: () => number
|
||||
/** Optional observability seam for a registry/parse fault; the verifier still fails closed. */
|
||||
readonly onError?: (e: unknown) => void
|
||||
/** Test seam: cert parser (defaults to production `defaultParseX509`). */
|
||||
readonly parse?: ParseCert
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap raw DER bytes (`getPeerCertificate(true).raw`) into a PEM certificate block with 64-column
|
||||
* base64, the shape node's `X509Certificate` (via `verifyAgentCert`) parses.
|
||||
*/
|
||||
export function derToPem(der: Uint8Array): string {
|
||||
const b64 = Buffer.from(der).toString('base64')
|
||||
const wrapped = b64.match(new RegExp(`.{1,${PEM_LINE_WIDTH}}`, 'g'))?.join('\n') ?? ''
|
||||
return `-----BEGIN CERTIFICATE-----\n${wrapped}\n-----END CERTIFICATE-----\n`
|
||||
}
|
||||
|
||||
export function createMtlsVerifier(deps: MtlsVerifierDeps): AsyncMtlsVerifier {
|
||||
const { caChainPem, hosts, now } = deps
|
||||
const onError = deps.onError ?? (() => {})
|
||||
const parse = deps.parse ?? defaultParseX509
|
||||
|
||||
// Fail fast at construction on a misconfigured CA bundle (INV14: a real pinned CA is required; an
|
||||
// empty/garbage bundle would otherwise silently deny every agent with an opaque `chain_invalid`).
|
||||
if (typeof caChainPem !== 'string' || !PEM_CERT_RE.test(caChainPem)) {
|
||||
throw new Error('createMtlsVerifier: caChainPem must contain at least one PEM CERTIFICATE block')
|
||||
}
|
||||
|
||||
return {
|
||||
async verifyPeer(peerCertDer) {
|
||||
if (peerCertDer === undefined || peerCertDer === null || peerCertDer.length === 0) {
|
||||
return null // no client cert presented → fail closed
|
||||
}
|
||||
try {
|
||||
const leafPem = derToPem(peerCertDer)
|
||||
const result = await verifyAgentCert(leafPem, caChainPem, now(), hosts, parse)
|
||||
if (!result.ok || result.hostId === undefined || result.accountId === undefined) {
|
||||
return null // expired / chain-invalid / not-enrolled / revoked / account-mismatch → fail closed
|
||||
}
|
||||
return { hostId: result.hostId, accountId: result.accountId }
|
||||
} catch (e: unknown) {
|
||||
onError(e) // registry/DB fault: surface it, never throw out of verifyPeer → fail closed
|
||||
return null
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user