Files
web-terminal/control-plane/src/ca/issue.ts
Yaojia Wang 6efed9772e 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
2026-07-06 20:46:01 +02:00

126 lines
5.7 KiB
TypeScript

/**
* Real X.509 leaf issuance (INV14). After the shared `assertLeafGate` passes, emit an X.509 v3
* Ed25519 leaf whose subject key is the enrolled agent pubkey and whose only SAN is the host's
* SPIFFE-ID URI — signed by the intermediate Ed25519 key. This is what `relay-auth`'s
* `verifyAgentCert` accepts (it walks leaf → intermediate → self-signed root and parses the
* `URI:spiffe://relay.<domain>/account/<a>/host/<h>` SAN).
*
* The SPIFFE-ID is built with relay-auth's OWN builder (`spiffeIdFor`, deep-imported) so the emitted
* SAN can never drift from the verifier's parser. The intermediate PRIVATE key is a WebCrypto
* `CryptoKey` imported non-extractable (never serialised back out — INV9).
*
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep it first.
*/
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { webcrypto, randomBytes } from 'node:crypto'
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
import type { HostStore } from '../store/ports.js'
import { assertLeafGate, DEFAULT_LEAF_TTL_SEC, type LeafSigner } from './sign.js'
x509.cryptoProvider.set(webcrypto)
/** Backdate notBefore slightly to tolerate small clock skew between control-plane and relay. */
const CLOCK_SKEW_SEC = 60
export interface RealLeafSignerDeps {
readonly hosts: HostStore
/** Intermediate Ed25519 PRIVATE signing key (WebCrypto, non-extractable). */
readonly intermediateKey: CryptoKey
/** Intermediate subject as a Name — used verbatim as the leaf issuer so `checkIssued` matches. */
readonly issuerName: x509.Name
/** DER of [intermediate, root] returned to the agent as its CA bundle (INV14). */
readonly caChainDer: readonly Uint8Array[]
/** Bare trust domain; the SPIFFE builder prepends `relay.`. */
readonly trustDomain: string
readonly leafTtlSec?: number
}
/** Import a raw 32-byte Ed25519 public key as a verifying WebCrypto CryptoKey (via SPKI DER). */
async function importEd25519Public(raw: Uint8Array): Promise<CryptoKey> {
const prefix = Uint8Array.from([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
])
const spki = new Uint8Array(prefix.length + raw.length)
spki.set(prefix, 0)
spki.set(raw, prefix.length)
return webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
}
/**
* Build the production leaf signer. Every issued leaf: X.509 v3, Ed25519 subject = agentPubkey,
* URI SAN = the host's SPIFFE-ID, CA:false, KeyUsage digitalSignature, EKU clientAuth, validity
* [now-skew, now+ttl]. Returns leaf DER + the injected CA chain DER; `redeem.ts` PEM-wraps both.
*/
export function createRealLeafSigner(deps: RealLeafSignerDeps): LeafSigner {
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
return {
async signHostLeaf(hostId, agentPubkey, csr) {
const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr)
const spiffe = spiffeIdFor(host.accountId, host.hostId, deps.trustDomain)
const subjectKey = await importEd25519Public(agentPubkey)
const now = Date.now()
const leaf = await x509.X509CertificateGenerator.create({
serialNumber: randomBytes(16).toString('hex'),
subject: `CN=${host.hostId}`,
issuer: deps.issuerName,
notBefore: new Date(now - CLOCK_SKEW_SEC * 1000),
notAfter: new Date(now + ttl * 1000),
publicKey: subjectKey,
signingKey: deps.intermediateKey,
signingAlgorithm: { name: 'Ed25519' },
extensions: [
new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }]),
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
],
})
return { cert: new Uint8Array(leaf.rawData), caChain: deps.caChainDer }
},
}
}
export interface LoadRealLeafSignerInput {
readonly hosts: HostStore
/** Intermediate Ed25519 private key, PKCS#8 PEM. */
readonly intermediateKeyPem: string
/** Intermediate certificate, PEM (single block). */
readonly intermediateCertPem: string
/** Self-signed root certificate, PEM (single block). */
readonly rootCertPem: string
readonly trustDomain: string
readonly leafTtlSec?: number
}
function pemToDer(pem: string): ArrayBuffer {
const body = pem.replace(/-----BEGIN [^-]+-----/g, '').replace(/-----END [^-]+-----/g, '').replace(/\s+/g, '')
const bytes = Buffer.from(body, 'base64')
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
/**
* Construct a real leaf signer from PEM material (boot path). Imports the intermediate private key
* (non-extractable — never re-serialised, INV9) and derives the issuer Name + CA chain DER from the
* certs. THROWS on unreadable/malformed material so the control-plane fails fast at boot.
*/
export async function loadRealLeafSigner(input: LoadRealLeafSignerInput): Promise<LeafSigner> {
const intermediateKey = await webcrypto.subtle.importKey(
'pkcs8',
pemToDer(input.intermediateKeyPem),
{ name: 'Ed25519' },
false, // non-extractable: the raw private key can never leave the process (INV9)
['sign'],
)
const intermediateCert = new x509.X509Certificate(input.intermediateCertPem)
const rootCert = new x509.X509Certificate(input.rootCertPem)
return createRealLeafSigner({
hosts: input.hosts,
intermediateKey,
issuerName: intermediateCert.subjectName,
caChainDer: [new Uint8Array(intermediateCert.rawData), new Uint8Array(rootCert.rawData)],
trustDomain: input.trustDomain,
...(input.leafTtlSec !== undefined ? { leafTtlSec: input.leafTtlSec } : {}),
})
}