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

@@ -35,7 +35,7 @@ describe('T8 signHostLeaf — INV14 registry-gated + CSR PoP', () => {
const forged = new Uint8Array(96)
forged.set(publicKeyRaw, 0)
forged.set(randomBytes(64), 32)
expect(verifyCsrPoP(forged).ok).toBe(false)
expect((await verifyCsrPoP(forged)).ok).toBe(false)
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, forged)).rejects.toBeInstanceOf(LeafSignError)
expect(signSpy).not.toHaveBeenCalled()
})

View File

@@ -0,0 +1,220 @@
/**
* OBJECTIVE ORACLE (INV4/INV14) — proves the control-plane's REAL leaf issuer emits an X.509 leaf
* that relay-auth's `verifyAgentCert` accepts, WITHOUT deploying either service.
*
* A throwaway Ed25519 root→intermediate chain is generated in-test; a leaf is issued through
* `createRealLeafSigner` for a random account/host + agent pubkey; then relay-auth verifies the
* leaf against the intermediate+root bundle with a fake host registry. Positive path asserts
* `{ ok: true, hostId, accountId }`; negatives assert the exact reject reasons.
*/
import 'reflect-metadata'
import { describe, test, expect } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto, randomUUID } from 'node:crypto'
import { verifyAgentCert, defaultParseX509 } from 'relay-auth/src/agent/verify-mtls.js'
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
import type { HostRecord } from 'relay-contracts'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createRealLeafSigner, loadRealLeafSigner } from '../src/ca/issue.js'
import { buildCsr } from '../src/ca/csr.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
x509.cryptoProvider.set(webcrypto)
const TRUST_DOMAIN = 'example.com'
const DAY_MS = 24 * 60 * 60 * 1000
interface Ca {
readonly intKey: CryptoKey
readonly issuerName: x509.Name
readonly caChainDer: readonly Uint8Array[]
readonly caChainPem: string
readonly intermediateKeyPem: string
readonly intermediateCertPem: string
readonly rootCertPem: string
}
type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
function derToPemLabeled(der: Uint8Array, label: string): string {
const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''
return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`
}
async function makeCa(): Promise<Ca> {
const subtle = webcrypto.subtle
const rootKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
const intKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
const now = Date.now()
const rootCert = await x509.X509CertificateGenerator.createSelfSigned({
serialNumber: '01',
name: 'CN=relay-root',
notBefore: new Date(now - DAY_MS),
notAfter: new Date(now + DAY_MS),
keys: rootKeys,
signingAlgorithm: { name: 'Ed25519' },
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
})
const intCert = await x509.X509CertificateGenerator.create({
serialNumber: '02',
subject: 'CN=relay-intermediate',
issuer: rootCert.subjectName,
notBefore: new Date(now - DAY_MS),
notAfter: new Date(now + DAY_MS),
publicKey: intKeys.publicKey,
signingKey: rootKeys.privateKey,
signingAlgorithm: { name: 'Ed25519' },
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
})
const intKeyPkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', intKeys.privateKey))
return {
intKey: intKeys.privateKey,
issuerName: intCert.subjectName,
caChainDer: [new Uint8Array(intCert.rawData), new Uint8Array(rootCert.rawData)],
caChainPem: `${intCert.toString('pem')}\n${rootCert.toString('pem')}`,
intermediateKeyPem: derToPemLabeled(intKeyPkcs8, 'PRIVATE KEY'),
intermediateCertPem: intCert.toString('pem'),
rootCertPem: rootCert.toString('pem'),
}
}
function derToPem(der: Uint8Array): string {
return new x509.X509Certificate(der).toString('pem')
}
/** Minimal relay-contracts HostRecord for the fake registry (only accountId/status are read). */
function fakeHost(hostId: string, accountId: string, status: HostRecord['status']): HostRecord {
return {
hostId,
accountId,
subdomain: 'host',
agentPubkey: new Uint8Array(32),
enrollFpr: 'fpr',
status,
lastSeen: new Date().toISOString(),
createdAt: new Date().toISOString(),
revokedAt: status === 'revoked' ? new Date().toISOString() : null,
} as HostRecord
}
async function issueLeaf() {
const ca = await makeCa()
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const accountId = randomUUID()
const { publicKeyRaw, privateKey } = generateEd25519()
const host = await hosts.bindHost({
accountId,
subdomain: 'alice',
agentPubkey: publicKeyRaw,
enrollFpr: fingerprint(publicKeyRaw),
})
const signer = createRealLeafSigner({
hosts: stores.hosts,
intermediateKey: ca.intKey,
issuerName: ca.issuerName,
caChainDer: ca.caChainDer,
trustDomain: TRUST_DOMAIN,
})
const { cert, caChain } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
return { ca, accountId, hostId: host.hostId, publicKeyRaw, leafPem: derToPem(cert), caChain }
}
describe('interop: control-plane real leaf ⇄ relay-auth verifyAgentCert', () => {
test('valid leaf for an active enrolled host → { ok: true, hostId, accountId }', async () => {
const { ca, accountId, hostId, leafPem, caChain } = await issueLeaf()
expect(caChain).toHaveLength(2) // intermediate + root DER
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
expect(res).toEqual({ ok: true, hostId, accountId })
})
test('SPIFFE accountId ≠ registry accountId → spiffe_account_mismatch', async () => {
const { ca, hostId, leafPem } = await issueLeaf()
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, randomUUID(), 'online') : null) }
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
expect(res.ok).toBe(false)
expect(res.reason).toBe('spiffe_account_mismatch')
})
test('revoked host → host_revoked', async () => {
const { ca, accountId, hostId, leafPem } = await issueLeaf()
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'revoked') : null) }
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
expect(res.ok).toBe(false)
expect(res.reason).toBe('host_revoked')
})
test('expired leaf → expired', async () => {
const ca = await makeCa()
const accountId = randomUUID()
const hostId = randomUUID()
const { publicKeyRaw } = generateEd25519()
const spiffe = spiffeIdFor(accountId, hostId, TRUST_DOMAIN)
const prefix = Uint8Array.from([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00])
const spki = new Uint8Array(44)
spki.set(prefix, 0)
spki.set(publicKeyRaw, 12)
const subjectKey = await webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
const now = Date.now()
const expiredLeaf = await x509.X509CertificateGenerator.create({
serialNumber: '0e',
subject: `CN=${hostId}`,
issuer: ca.issuerName,
notBefore: new Date(now - 2 * DAY_MS),
notAfter: new Date(now - DAY_MS), // already expired
publicKey: subjectKey,
signingKey: ca.intKey,
signingAlgorithm: { name: 'Ed25519' },
extensions: [new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }])],
})
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
const res = await verifyAgentCert(
expiredLeaf.toString('pem'),
ca.caChainPem,
Math.floor(Date.now() / 1000),
registry,
defaultParseX509,
)
expect(res.ok).toBe(false)
expect(res.reason).toBe('expired')
})
test('boot path: loadRealLeafSigner (PEM material) issues a verifiable leaf', async () => {
const ca = await makeCa()
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const accountId = randomUUID()
const { publicKeyRaw, privateKey } = generateEd25519()
const host = await hosts.bindHost({
accountId,
subdomain: 'boot',
agentPubkey: publicKeyRaw,
enrollFpr: fingerprint(publicKeyRaw),
})
const signer = await loadRealLeafSigner({
hosts: stores.hosts,
intermediateKeyPem: ca.intermediateKeyPem,
intermediateCertPem: ca.intermediateCertPem,
rootCertPem: ca.rootCertPem,
trustDomain: TRUST_DOMAIN,
})
const { cert } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
const registry = {
getById: async (id: string) => (id === host.hostId ? fakeHost(host.hostId, accountId, 'online') : null),
}
const res = await verifyAgentCert(derToPem(cert), ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
expect(res).toEqual({ ok: true, hostId: host.hostId, accountId })
})
test('intermediate-only bundle (no root) → chain_invalid', async () => {
const { ca, accountId, hostId, leafPem } = await issueLeaf()
const intermediateOnly = ca.caChainPem.split('-----END CERTIFICATE-----')[0]! + '-----END CERTIFICATE-----\n'
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
const res = await verifyAgentCert(leafPem, intermediateOnly, Math.floor(Date.now() / 1000), registry, defaultParseX509)
expect(res.ok).toBe(false)
expect(res.reason).toBe('chain_invalid')
})
})