feat(control-plane): real X.509 agent leaf issuance (closes enrollment↔mTLS gap)

- 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
This commit is contained in:
Yaojia Wang
2026-07-06 20:46:01 +02:00
parent 1a8984e851
commit 6efed9772e
12 changed files with 825 additions and 80 deletions

View File

@@ -12,9 +12,10 @@
* 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 } from './store/ports.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'
@@ -22,14 +23,15 @@ 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 } from './ca/sign.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 } from './boot/ca-wiring.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'
@@ -52,6 +54,36 @@ const refuseAllVerifier: CapabilityVerifier = {
},
}
/**
* 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()
@@ -77,7 +109,7 @@ export async function buildControlPlane(
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains, audit })
const caSigner = await buildCaSigner(env, overrides.kmsResolver ?? devKmsResolver())
const leafSigner = createLeafSigner({ hosts: stores.hosts, signer: caSigner, caChainDer })
const leafSigner = await buildLeafSigner(env, stores.hosts, caSigner, caChainDer)
const pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit })
const redeemer = createPairingRedeemer({