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

@@ -1,53 +1,147 @@
/**
* CSR proof-of-possession (INV14). Models a PKCS#10 CSR as `embeddedPub(32) || sig(64)` where
* `sig = Ed25519(privateKey, CSR_CHALLENGE || embeddedPub)`. Verifying the signature against the
* embedded pubkey proves the requester holds the matching private key (the exact property a real
* PKCS#10 self-signature provides) — using builtin crypto only.
* CSR handling (INV14) — REAL PKCS#10 over the agent's Ed25519 identity.
*
* INTEGRATION SEAM: production parses a real PKCS#10 DER (`@peculiar/x509`) and checks the
* self-signature. Swapping it in does not change `signHostLeaf`'s check ORDER or reject path.
* The agent (`agent/src/enroll/csr.ts`) emits a standard PKCS#10 `CertificationRequest` signed by
* its in-process Ed25519 key. This module parses that request with `@peculiar/x509`, verifies its
* self-signature (proof-of-possession — the requester holds the private key for the embedded
* pubkey), and exposes the embedded raw Ed25519 public key so `sign.ts` can gate on
* embeddedPub == agentPubkey. Parsing/verification is FAIL-CLOSED and returns a UNIFORM result: any
* malformed/forged input yields `{ ok: false }` with no distinguishing detail (see `verifyCsrPoP`).
*
* `@peculiar/x509` uses `tsyringe`, which requires the `reflect-metadata` polyfill to be loaded
* before the library — hence the side-effect import on the first line (must stay first).
*/
import { ed25519Sign, ed25519Verify, type KeyObject } from '../util/crypto.js'
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { webcrypto } from 'node:crypto'
import { ed25519Sign, type KeyObject } from '../util/crypto.js'
const CSR_CHALLENGE = new TextEncoder().encode('relay-cp/csr-pop/v1|')
// Ed25519 signing/verification runs on Node's WebCrypto (Ed25519 supported on Node 20+).
x509.cryptoProvider.set(webcrypto)
/** Test/agent helper: build a CSR proving possession of `privateKey` for `embeddedPub`. */
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
const message = concat(CSR_CHALLENGE, embeddedPub)
const sig = ed25519Sign(privateKey, message)
return concat(embeddedPub, sig)
}
// --- Ed25519 SPKI constants -------------------------------------------------------------------
/** Fixed 12-byte SPKI prefix for an Ed25519 SubjectPublicKeyInfo; the raw key is the trailing 32. */
const ED25519_SPKI_PREFIX = Uint8Array.from([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
])
const ED25519_SPKI_LEN = ED25519_SPKI_PREFIX.length + 32 // 44
const RAW_ED25519_LEN = 32
export interface CsrParts {
readonly embeddedPub: Uint8Array
readonly sig: Uint8Array
}
// --- minimal DER encoding helpers (test/agent-side CSR construction) ---------------------------
/** Parse the modelled CSR bytes. Throws on wrong length. */
export function parseCsr(csr: Uint8Array): CsrParts {
if (csr.length !== 96) throw new Error('malformed csr')
return { embeddedPub: csr.subarray(0, 32), sig: csr.subarray(32, 96) }
}
/**
* Verify CSR proof-of-possession: the embedded pubkey's private key signed the challenge.
* Independent of any registry state (a forged signature is refused even for a registered key).
*/
export function verifyCsrPoP(csr: Uint8Array): { ok: boolean; embeddedPub: Uint8Array } {
let parts: CsrParts
try {
parts = parseCsr(csr)
} catch {
return { ok: false, embeddedPub: new Uint8Array(0) }
function derLen(len: number): Uint8Array {
if (len < 0x80) return Uint8Array.from([len])
const bytes: number[] = []
let n = len
while (n > 0) {
bytes.unshift(n & 0xff)
n >>= 8
}
const message = concat(CSR_CHALLENGE, parts.embeddedPub)
const ok = ed25519Verify(parts.embeddedPub, message, parts.sig)
return { ok, embeddedPub: parts.embeddedPub }
return Uint8Array.from([0x80 | bytes.length, ...bytes])
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length)
out.set(a, 0)
out.set(b, a.length)
function tlv(tag: number, value: Uint8Array): Uint8Array {
const len = derLen(value.length)
const out = new Uint8Array(1 + len.length + value.length)
out[0] = tag
out.set(len, 1)
out.set(value, 1 + len.length)
return out
}
function concat(chunks: readonly Uint8Array[]): Uint8Array {
const out = new Uint8Array(chunks.reduce((s, c) => s + c.length, 0))
let off = 0
for (const c of chunks) {
out.set(c, off)
off += c.length
}
return out
}
const SEQUENCE = 0x30
const SET = 0x31
const INTEGER = 0x02
const BIT_STRING = 0x03
const OID = 0x06
const UTF8_STRING = 0x0c
const CONTEXT_0 = 0xa0
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03]) // 2.5.4.3 commonName
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70]) // 1.3.101.112 Ed25519
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
return tlv(SEQUENCE, concat([algId, pubBits]))
}
function nameFromCn(cn: string): Uint8Array {
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
return tlv(SEQUENCE, tlv(SET, atv))
}
/**
* Build a real PKCS#10 `CertificationRequest` DER for `embeddedPub`, self-signed by `privateKey`
* (Ed25519). Mirrors the agent's on-wire request shape (subject CN, empty attributes). Used by the
* control-plane's own tests to exercise the real parse/verify path; production requests come from
* the agent unchanged.
*/
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
const version = tlv(INTEGER, Uint8Array.from([0x00]))
const requestInfo = tlv(
SEQUENCE,
concat([version, nameFromCn('web-terminal-agent'), spkiFromRawEd25519(embeddedPub), tlv(CONTEXT_0, new Uint8Array(0))]),
)
const signature = ed25519Sign(privateKey, requestInfo)
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
return tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
}
// --- wire decoding + verification --------------------------------------------------------------
const PEM_CSR_RE = /-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/
/**
* Decode the `csr` field as it arrives on the `/enroll` wire into raw PKCS#10 DER bytes. Accepts
* BOTH shapes the codebase produces: the agent sends a PEM `CERTIFICATE REQUEST` block, while the
* control-plane's own HTTP tests send `base64(DER)`. Both normalise to the same DER — this is the
* single place the wire encoding is interpreted (boundary validation, coding-style §Input).
*/
export function decodeCsrWire(wire: string): Uint8Array {
const pem = PEM_CSR_RE.exec(wire)
const body = pem !== null ? pem[1]!.replace(/\s+/g, '') : wire.trim()
return new Uint8Array(Buffer.from(body, 'base64'))
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
/** Extract the raw 32-byte Ed25519 key from a SubjectPublicKeyInfo DER, or null if not Ed25519. */
function rawEd25519FromSpki(spki: Uint8Array): Uint8Array | null {
if (spki.length !== ED25519_SPKI_LEN) return null
for (let i = 0; i < ED25519_SPKI_PREFIX.length; i++) {
if (spki[i] !== ED25519_SPKI_PREFIX[i]) return null
}
return spki.subarray(ED25519_SPKI_PREFIX.length)
}
/**
* Verify CSR proof-of-possession: parse the PKCS#10 DER and check its self-signature against the
* embedded public key. Returns the embedded raw Ed25519 pubkey on success. FAIL-CLOSED and uniform:
* malformed DER, a non-Ed25519 key, or a bad signature all yield `{ ok: false, embeddedPub: [] }`
* with no leak of which check failed. Independent of any registry state.
*/
export async function verifyCsrPoP(csr: Uint8Array): Promise<{ ok: boolean; embeddedPub: Uint8Array }> {
const fail = { ok: false, embeddedPub: new Uint8Array(0) }
try {
const req = new x509.Pkcs10CertificateRequest(toArrayBuffer(csr))
const embeddedPub = rawEd25519FromSpki(new Uint8Array(req.publicKey.rawData))
if (embeddedPub === null || embeddedPub.length !== RAW_ED25519_LEN) return fail
const ok = await req.verify()
return ok ? { ok: true, embeddedPub: new Uint8Array(embeddedPub) } : fail
} catch {
return fail
}
}

View File

@@ -0,0 +1,125 @@
/**
* 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 } : {}),
})
}

View File

@@ -36,7 +36,7 @@ export function createLeafRenewer(deps: LeafRenewerDeps): LeafRenewer {
// A revoked/absent host cannot renew (INV12 + INV14).
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
const pop = verifyCsrPoP(csr)
const pop = await verifyCsrPoP(csr)
if (!pop.ok) throw new LeafSignError('csr_rejected')
// embedded pubkey must equal the host's REGISTERED pubkey (no key substitution on renewal).
if (!timingSafeEqualBytes(pop.embeddedPub, host.agentPubkey)) throw new LeafSignError('csr_rejected')

View File

@@ -1,13 +1,19 @@
/**
* T8 — bind-time mTLS leaf signer, registry-gated (INV14 registry half). ORDER OF CHECKS (all
* T8 — bind-time mTLS leaf signing, registry-gated (INV14 registry half). ORDER OF CHECKS (all
* must pass, SAME reject path):
* 1. CSR proof-of-possession — verify the CSR self-signature against its embedded pubkey.
* 1. CSR proof-of-possession — verify the PKCS#10 self-signature against its embedded pubkey.
* 2. embedded pubkey == caller-supplied `agentPubkey` (no substitution).
* 3. (hostId, agentPubkey) is an ACTIVE, non-revoked row in the host registry.
* A failure at ANY step rejects identically; the KMS `sign()` is NEVER invoked when any check
* fails. Signing itself is `CaSigner.sign()` (KMS, §3.1) — never a raw private key in memory.
* A failure at ANY step rejects identically; issuance is NEVER reached when any check fails.
*
* Two `LeafSigner` implementations share the `assertLeafGate` gate below:
* - `createLeafSigner` (this file) — DEV/legacy placeholder cert (a signed JSON blob, NOT X.509);
* used only when no real CA material is configured (see `main.ts` fallback).
* - `createRealLeafSigner` (`ca/issue.ts`) — the production issuer that emits a real X.509 v3
* Ed25519 leaf with a SPIFFE SAN, signed by the intermediate key.
*/
import type { HostStore } from '../store/ports.js'
import type { HostRecord } from '../model/records.js'
import type { CaSigner } from '../boot/ca-wiring.js'
import { verifyCsrPoP } from './csr.js'
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
@@ -18,14 +24,6 @@ export class LeafSignError extends Error {
}
}
export interface LeafSignerDeps {
readonly hosts: HostStore
readonly signer: CaSigner
readonly caChainDer: readonly Uint8Array[]
/** Leaf validity in seconds (short-lived, INV14). Default 24h. */
readonly leafTtlSec?: number
}
export interface LeafSigner {
signHostLeaf(
hostId: string,
@@ -34,28 +32,56 @@ export interface LeafSigner {
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
}
const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
/** Leaf validity in seconds (short-lived, INV14). Default 24h. */
export const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
/**
* Shared registry + proof-of-possession gate (INV14). Runs the three ordered checks and, on
* success, returns the gated host record so the issuer can build the SPIFFE SAN from the
* authoritative `accountId`/`hostId` binding. Throws `LeafSignError` (uniform) on any failure.
*/
export async function assertLeafGate(
hosts: HostStore,
hostId: string,
agentPubkey: Uint8Array,
csr: Uint8Array,
): Promise<HostRecord> {
// 1. proof-of-possession (independent of registry state)
const pop = await verifyCsrPoP(csr)
if (!pop.ok) throw new LeafSignError('csr_rejected')
// 2. no substitution: CSR pubkey must equal the presented agentPubkey
if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected')
// 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14)
const host = await hosts.get(hostId)
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered')
return host
}
export interface LeafSignerDeps {
readonly hosts: HostStore
readonly signer: CaSigner
readonly caChainDer: readonly Uint8Array[]
readonly leafTtlSec?: number
}
/**
* DEV/legacy signer — emits a signed JSON placeholder (NOT real X.509). Retained so tests and dev
* boots without configured CA material still exercise the gate/reject path. Production uses
* `createRealLeafSigner` (`ca/issue.ts`).
*/
export function createLeafSigner(deps: LeafSignerDeps): LeafSigner {
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
return {
async signHostLeaf(hostId, agentPubkey, csr) {
// 1. proof-of-possession (independent of registry state)
const pop = verifyCsrPoP(csr)
if (!pop.ok) throw new LeafSignError('csr_rejected')
// 2. no substitution: CSR pubkey must equal the presented agentPubkey
if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected')
// 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14)
const host = await deps.hosts.get(hostId)
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered')
const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr)
// Only now do we invoke KMS sign() over the to-be-signed leaf.
// Only now do we invoke KMS sign() over the to-be-signed placeholder.
const notAfter = Math.floor(Date.now() / 1000) + ttl
const tbs = new TextEncoder().encode(
JSON.stringify({
v: 1,
hostId,
hostId: host.hostId,
subjectSpki: bytesToBase64(agentPubkey), // subject pubkey == agentPubkey (assertable in tests)
notAfter,
}),