- ca/issue.ts: real X.509 v3 Ed25519 leaf with SPIFFE URI SAN, signed by the intermediate; SAN built via relay-auth spiffeIdFor so it can't drift - ca/csr.ts: parse real PKCS#10 (agent's PEM) + async PoP verify; decodeCsrWire accepts PEM or base64(DER) - main.ts/env.ts: real issuer when CA_INTERMEDIATE_KEY_PATH present, else dev fallback - interop.test.ts: oracle proving relay-auth verifyAgentCert accepts the leaf (6 tests) - deps: @peculiar/x509, reflect-metadata
149 lines
6.6 KiB
TypeScript
149 lines
6.6 KiB
TypeScript
/**
|
|
* T11 — control-plane bootstrap. Wires env → stores → services → Fastify routes. The runnable
|
|
* v0.9-MVP default uses the in-memory stores; swapping in the Postgres/Redis-backed adapters
|
|
* (db/pool.ts + db/migrations + an ioredis client) is the Testcontainers integration step (PLAN §10).
|
|
*
|
|
* CROSS-PACKAGE INTEGRATION POINTS (injected, not hard-imported):
|
|
* - `CapabilityVerifier` — P5 owns `verifyCapabilityToken` + the signing key. Injected here; the
|
|
* default stub REFUSES all tokens (fail-closed) until P5 is wired.
|
|
* - `RevocationBus` — publish side over Redis `relay:revocations` (P1 subscribes). Default is the
|
|
* in-memory bus for single-process dev.
|
|
* - `KmsResolver` — P5/§3.1 KMS custody of the CA intermediate key. Default is an in-process
|
|
* Ed25519 signer (DEV ONLY — NOT a real KMS).
|
|
*/
|
|
import Fastify, { type FastifyInstance } from 'fastify'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import type { ControlPlaneEnv } from './env.js'
|
|
import { createMemoryStores } from './store/memory.js'
|
|
import type { Stores, HostStore } from './store/ports.js'
|
|
import { createAuditLog } from './audit/log.js'
|
|
import { createAccountRegistry } from './registry/accounts.js'
|
|
import { createHostRegistry } from './registry/hosts.js'
|
|
import { createSessionRegistry } from './registry/sessions.js'
|
|
import { createSubdomainAssigner } from './subdomain/assign.js'
|
|
import { createPairingIssuer } from './pairing/issue.js'
|
|
import { createPairingRedeemer } from './pairing/redeem.js'
|
|
import { createLeafSigner, type LeafSigner } from './ca/sign.js'
|
|
import { loadRealLeafSigner } from './ca/issue.js'
|
|
import { createRoutingTable } from './routing/table.js'
|
|
import { createInMemoryRevocationBus, type TestableRevocationBus } from './routing/bus.js'
|
|
import { createMeteringCollector } from './metering/collect.js'
|
|
import { createDeprovisioner } from './deprovision/deprovision.js'
|
|
import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
|
|
import { buildRouter } from './api/provision.js'
|
|
import { buildCaSigner, inProcessCaSigner, type KmsResolver, type CaSigner } from './boot/ca-wiring.js'
|
|
import { configureCapabilityVerifyKey } from './boot/verifier.js'
|
|
import type { RevocationBus } from 'relay-contracts'
|
|
|
|
export interface ControlPlaneOverrides {
|
|
readonly stores?: Stores
|
|
readonly verifier?: CapabilityVerifier
|
|
readonly bus?: RevocationBus & Partial<TestableRevocationBus>
|
|
readonly kmsResolver?: KmsResolver
|
|
readonly caChainDer?: readonly Uint8Array[]
|
|
}
|
|
|
|
/**
|
|
* Default fail-closed verifier — refuses everything until a real (P5) verifier is injected (INV6).
|
|
* Async-shaped to match `CapabilityVerifier`: an async body that throws rejects the promise, so the
|
|
* authorizer's `await` surfaces it as a 401.
|
|
*/
|
|
const refuseAllVerifier: CapabilityVerifier = {
|
|
async verify(): Promise<never> {
|
|
throw new Error('capability verification not configured (P5 integration point)')
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Choose the leaf signer. When the intermediate PRIVATE key file is present on disk, issue REAL
|
|
* X.509 leaves (production); when absent, fall back to the dev placeholder signer so tests and
|
|
* key-less dev boots still run. A present-but-unreadable/malformed key FAILS FAST (INV9).
|
|
*/
|
|
async function buildLeafSigner(
|
|
env: ControlPlaneEnv,
|
|
hosts: HostStore,
|
|
caSigner: CaSigner,
|
|
caChainDer: readonly Uint8Array[],
|
|
): Promise<LeafSigner> {
|
|
if (!existsSync(env.caIntermediateKeyPath)) {
|
|
return createLeafSigner({ hosts, signer: caSigner, caChainDer })
|
|
}
|
|
try {
|
|
return await loadRealLeafSigner({
|
|
hosts,
|
|
intermediateKeyPem: readFileSync(env.caIntermediateKeyPath, 'utf8'),
|
|
intermediateCertPem: readFileSync(env.caIntermediateCertPath, 'utf8'),
|
|
rootCertPem: readFileSync(env.caRootCertPath, 'utf8'),
|
|
trustDomain: env.relayTrustDomain,
|
|
})
|
|
} catch (err: unknown) {
|
|
// Never echo key material — only that loading failed and which path shape was configured (INV9).
|
|
throw new Error(
|
|
`failed to load CA leaf-signing material: ${err instanceof Error ? err.message : 'unknown'}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/** In-process KMS resolver — DEV ONLY. Production injects a real non-exportable KMS key (§3.1). */
|
|
function devKmsResolver(): KmsResolver {
|
|
const signer = inProcessCaSigner()
|
|
return {
|
|
async resolve() {
|
|
return { signer, policyRestrictedToServicePrincipal: true }
|
|
},
|
|
}
|
|
}
|
|
|
|
export async function buildControlPlane(
|
|
env: ControlPlaneEnv,
|
|
overrides: ControlPlaneOverrides = {},
|
|
): Promise<{ app: FastifyInstance; stores: Stores }> {
|
|
const stores = overrides.stores ?? createMemoryStores()
|
|
const bus: RevocationBus = overrides.bus ?? createInMemoryRevocationBus()
|
|
const caChainDer = overrides.caChainDer ?? []
|
|
|
|
const audit = createAuditLog(stores.audit)
|
|
const accounts = createAccountRegistry({ accounts: stores.accounts, audit })
|
|
const hosts = createHostRegistry({ hosts: stores.hosts, audit })
|
|
createSessionRegistry({ sessions: stores.sessions, hosts: stores.hosts })
|
|
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains, audit })
|
|
|
|
const caSigner = await buildCaSigner(env, overrides.kmsResolver ?? devKmsResolver())
|
|
const leafSigner = await buildLeafSigner(env, stores.hosts, caSigner, caChainDer)
|
|
|
|
const pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit })
|
|
const redeemer = createPairingRedeemer({
|
|
pairing: stores.pairing,
|
|
hosts,
|
|
subdomains,
|
|
leafSigner,
|
|
pairingMaxRedeemAttempts: env.pairingMaxRedeemAttempts,
|
|
audit,
|
|
})
|
|
|
|
const routing = createRoutingTable({
|
|
routes: stores.routes,
|
|
nodeStatus: async (nodeId) => (await stores.nodes.get(nodeId))?.status ?? null,
|
|
})
|
|
createMeteringCollector({ metering: stores.metering, hosts })
|
|
const deprovisioner = createDeprovisioner({ hosts, routing })
|
|
void bus // reserved for the node-coordinator / revoker wiring (drain + revoke publishers)
|
|
|
|
// A real (injected) verifier is P5's async `verifyCapabilityToken`, which reads its Ed25519 key
|
|
// from relay-auth's startup registry — so load that key from env at boot. The fail-closed default
|
|
// never reads a key, so leave relay-auth's registry untouched when nothing is injected (INV6).
|
|
const verifier = overrides.verifier ?? refuseAllVerifier
|
|
if (overrides.verifier !== undefined) {
|
|
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
|
|
}
|
|
|
|
const authorizer = createAuthorizer({
|
|
verifier,
|
|
expectedAud: env.baseDomain,
|
|
})
|
|
|
|
const app = Fastify({ logger: false })
|
|
await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner }))
|
|
return { app, stores }
|
|
}
|