/** * A6 (FIX H-host-3) — leaf RENEWAL upgraded off the DEV JSON-blob placeholder to REAL X.509. Proves * `createLeafRenewer` re-issues a tool-parseable, CA-verifiable X.509 v3 leaf (host + device) that * carries the SAME subdomain the registry holds (no smuggling), enforces the SAME-KEY rule (a * different-key CSR is rejected), and refuses a revoked identity — all by DELEGATING to the P-256 * issuers (`frpclient-issue` / `device-issue`) that route through `assembleCertificate`. The final * `buildCaSigner` block (startup KMS key-policy fail-fast, INV9) is unrelated to rotation but colocated. */ import 'reflect-metadata' import { describe, test, expect } from 'vitest' import * as x509 from '@peculiar/x509' import { webcrypto, generateKeyPairSync, randomUUID } from 'node:crypto' import { parseSpiffeId } from 'relay-auth/src/agent/spiffe.js' import { createMemoryStores } from '../src/store/memory.js' import { createHostRegistry } from '../src/registry/hosts.js' import { createDeviceRegistry, createMemoryDeviceStore } from '../src/registry/devices.js' import { fingerprint } from '../src/ca/fingerprint.js' import { assembleCertificate } from '../src/ca/x509-assembler.js' import { buildCsrEc } from '../src/ca/csr-ec.js' import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js' import { createDeviceLeafSigner } from '../src/ca/device-issue.js' import { createLeafRenewer } from '../src/ca/rotate.js' import { LeafSignError } from '../src/ca/sign.js' import { DeviceLeafSignError } from '../src/ca/device-issue.js' import { buildCaSigner, inProcessCaSigner, inProcessP256CaSigner, type CaSigner, type KmsResolver, } from '../src/boot/ca-wiring.js' import { loadEnv } from '../src/env.js' import { bytesToBase64 } from '../src/util/bytes.js' x509.cryptoProvider.set(webcrypto) const DAY_MS = 24 * 60 * 60 * 1000 const TRUST_DOMAIN = 'terminal.yaojia.wang' const DNS_ZONE = 'terminal.yaojia.wang' const BASE_DOMAIN = 'terminal.yaojia.wang' interface P256Ca { readonly caSigner: CaSigner readonly caCert: x509.X509Certificate readonly caDer: Uint8Array } /** Self-signed P-256 CA (subject key == signer key) so leaves chain to a real CA. */ async function makeP256Ca(cn: string): Promise { const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' }) const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' })) const caSigner = inProcessP256CaSigner(caKeys.privateKey) const now = Date.now() const caDer = await assembleCertificate({ subjectPublicKey: caSpki, subject: `CN=${cn}`, issuer: `CN=${cn}`, serialNumber: Uint8Array.from([0x01]), notBefore: new Date(now - DAY_MS), notAfter: new Date(now + 365 * DAY_MS), extensions: [ new x509.BasicConstraintsExtension(true, undefined, true), new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), ], signer: caSigner, sigAlg: 'ecdsa-p256', }) return { caSigner, caCert: new x509.X509Certificate(caDer), caDer } } async function importP256Public(spkiDer: Uint8Array): Promise { return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']) } async function spkiOf(pub: CryptoKey): Promise { return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub)) } // ── Host renewal setup ───────────────────────────────────────────────────────────────────────────── async function hostRenewFixture(subdomain = 'alice') { const ca = await makeP256Ca('frp-client-CA') const stores = createMemoryStores() const hosts = createHostRegistry({ hosts: stores.hosts }) const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`) const spki = await spkiOf(keys.publicKey) const accountId = randomUUID() const host = await hosts.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) }) const hostSigner = createFrpClientLeafSigner({ hosts: stores.hosts, signer: ca.caSigner, issuerName: ca.caCert.subjectName, caChainDer: [ca.caDer], trustDomain: TRUST_DOMAIN, dnsZone: DNS_ZONE, }) // Device signer + expiry renewer are required by the renewer shape but unused in host tests — a // throwaway pair sharing one store. const throwawayDeviceStore = createMemoryDeviceStore() const deviceSigner = createDeviceLeafSigner({ signer: (await makeP256Ca('device-CA')).caSigner, issuer: 'CN=device-CA', caChainDer: [], sanBaseDomain: BASE_DOMAIN, trustDomain: TRUST_DOMAIN, devices: throwawayDeviceStore, }) const renewer = createLeafRenewer({ hostSigner, deviceSigner, deviceExpiry: createDeviceRegistry({ devices: throwawayDeviceStore }), }) return { ca, stores, hosts, host, csr, spki, keys, accountId, renewer } } describe('A6 renewHostLeaf → REAL X.509 (FIX H-host-3)', () => { test('active host + same-key CSR → a re-parseable, CA-verifiable X.509 leaf carrying the SAME subdomain', async () => { const { ca, host, csr, accountId, renewer } = await hostRenewFixture('alice') const { cert, caChain, notAfter } = await renewer.renewHostLeaf(host.hostId, host.agentPubkey, csr) // (1) it is a REAL X.509 cert — not a JSON blob — that re-parses. expect(() => new x509.X509Certificate(cert)).not.toThrow() const leaf = new x509.X509Certificate(cert) // (2) it chains to the frp-client-CA (signature verifies under the CA key). const caPub = await importP256Public(ca.caSigner.publicKeyRaw) expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true) // (3) SAN carries the registry subdomain as the dNSName enforcement key + the host SPIFFE URI. const san = leaf.getExtension(x509.SubjectAlternativeNameExtension) const names = san!.names.toJSON() expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' }) const uri = names.find((n) => n.type === 'url')!.value expect(parseSpiffeId(uri)).toEqual({ accountId, id: 'alice', kind: 'host' }) // (4) renewal surfaces a real future expiry + the CA chain. expect(notAfter.getTime()).toBeGreaterThan(Date.now()) expect(caChain).toEqual([ca.caDer]) }) test('a CSR with a DIFFERENT key (key-swap attempt) → rejected (MVP same-key)', async () => { const { host, renewer } = await hostRenewFixture('alice') const { der: otherCsr } = await buildCsrEc('CN=alice') // fresh, different P-256 key await expect(renewer.renewHostLeaf(host.hostId, host.agentPubkey, otherCsr)).rejects.toBeInstanceOf(LeafSignError) }) test('a revoked host cannot renew (INV12+INV14 loop)', async () => { const { hosts, host, csr, renewer } = await hostRenewFixture('alice') await hosts.setHostStatus(host.hostId, 'revoked') await expect(renewer.renewHostLeaf(host.hostId, host.agentPubkey, csr)).rejects.toBeInstanceOf(LeafSignError) }) test('the re-issued subdomain comes from the REGISTRY, never the CSR subject (no smuggling)', async () => { const { host, keys, renewer } = await hostRenewFixture('alice') // A malicious renewal CSR over the SAME key but with a foreign subject label. const evilCsr = new Uint8Array( ( await x509.Pkcs10CertificateRequestGenerator.create({ name: 'CN=bob.terminal.yaojia.wang', keys, signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, }) ).rawData, ) const { cert } = await renewer.renewHostLeaf(host.hostId, host.agentPubkey, evilCsr) const names = new x509.X509Certificate(cert).getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON() expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' }) // still alice expect(names).not.toContainEqual({ type: 'dns', value: 'bob.terminal.yaojia.wang' }) }) }) // ── Device renewal setup ──────────────────────────────────────────────────────────────────────────── async function deviceRenewFixture(subdomainScope = 'alice', now?: () => number) { const ca = await makeP256Ca('device-CA') const store = createMemoryDeviceStore() const registry = createDeviceRegistry({ devices: store, ...(now ? { now } : {}) }) const { der: csr, keys } = await buildCsrEc('CN=web-terminal-device') const spki = await spkiOf(keys.publicKey) const record = await registry.registerDevice({ accountId: randomUUID(), subdomainScope, ecPubkeySpki: spki, attestationLevel: 'none', }) const deviceSigner = createDeviceLeafSigner({ signer: ca.caSigner, issuer: ca.caCert.subjectName, caChainDer: [ca.caDer], sanBaseDomain: BASE_DOMAIN, trustDomain: TRUST_DOMAIN, devices: store, }) // Host signer is required by the renewer shape but unused in device tests — a throwaway. const hostSigner = createFrpClientLeafSigner({ hosts: createMemoryStores().hosts, signer: (await makeP256Ca('frp-client-CA')).caSigner, issuerName: 'CN=frp-client-CA', caChainDer: [], trustDomain: TRUST_DOMAIN, dnsZone: DNS_ZONE, }) const renewer = createLeafRenewer({ hostSigner, deviceSigner, deviceExpiry: registry }) return { ca, store, registry, record, csr, spki, keys, renewer } } describe('A6 renewDeviceLeaf → REAL X.509 (FIX H-host-3)', () => { test('active device + same-key CSR → a CA-verifiable X.509 leaf carrying the SAME subdomainScope', async () => { const { ca, record, csr, renewer } = await deviceRenewFixture('alice') const { cert, caChain, notAfter } = await renewer.renewDeviceLeaf(record.deviceId, csr) const leaf = new x509.X509Certificate(cert) const caPub = await importP256Public(ca.caSigner.publicKeyRaw) expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true) const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON() expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' }) const uri = names.find((n) => n.type === 'url')!.value expect(parseSpiffeId(uri)).toEqual({ accountId: record.accountId, id: record.deviceId, kind: 'device' }) expect(notAfter.getTime()).toBeGreaterThan(Date.now()) expect(caChain).toEqual([ca.caDer]) }) test('a CSR with a DIFFERENT key → rejected (MVP same-key)', async () => { const { record, renewer } = await deviceRenewFixture('alice') const { der: otherCsr } = await buildCsrEc('CN=web-terminal-device') await expect(renewer.renewDeviceLeaf(record.deviceId, otherCsr)).rejects.toBeInstanceOf(DeviceLeafSignError) }) test('a revoked device cannot renew', async () => { const { registry, record, csr, renewer } = await deviceRenewFixture('alice') await registry.setDeviceStatus(record.deviceId, 'revoked') await expect(renewer.renewDeviceLeaf(record.deviceId, csr)).rejects.toBeInstanceOf(DeviceLeafSignError) }) test('renewing TWICE across an advanced clock EXTENDS validity — the 2nd notAfter is later than the 1st', async () => { // Regression for the H finding: the device signer stamps record.notAfter (set once at enroll), so // without a per-renewal bump both renewals returned a byte-identical expiry — eventually in the // PAST. Advance the registry clock a comfortable margin (X.509 notAfter is second-resolution) so // the increase is unambiguous in the DER, then assert the second expiry strictly exceeds the first. let clock = Date.now() const { record, csr, renewer } = await deviceRenewFixture('alice', () => clock) const first = await renewer.renewDeviceLeaf(record.deviceId, csr) clock += 2 * DAY_MS const second = await renewer.renewDeviceLeaf(record.deviceId, csr) expect(second.notAfter.getTime()).toBeGreaterThan(first.notAfter.getTime()) // And the extension is REAL (roughly the clock advance), not a rounding artefact. expect(second.notAfter.getTime() - first.notAfter.getTime()).toBeGreaterThanOrEqual(DAY_MS) }) }) describe('T1/T15 buildCaSigner startup key-policy check (INV9, §3.1)', () => { const env = () => loadEnv({ PG_URL: 'postgres://u:p@localhost:5432/cp', REDIS_URL: 'redis://localhost:6379', CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(new Uint8Array(32).fill(1)), CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate', CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem', NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem', BASE_DOMAIN: 'term.example.com', }) test('fails fast when the KMS key ref cannot be resolved', async () => { const resolver: KmsResolver = { async resolve() { throw new Error('no such key') } } await expect(buildCaSigner(env(), resolver)).rejects.toThrow(/could not be resolved/) }) test('fails fast when the key policy is broader than the service principal', async () => { const resolver: KmsResolver = { async resolve() { return { signer: inProcessCaSigner(), policyRestrictedToServicePrincipal: false } }, } await expect(buildCaSigner(env(), resolver)).rejects.toThrow(/policy is broader/) }) test('succeeds when policy is restricted', async () => { const resolver: KmsResolver = { async resolve() { return { signer: inProcessCaSigner(), policyRestrictedToServicePrincipal: true } }, } await expect(buildCaSigner(env(), resolver)).resolves.toBeDefined() }) })