/** * A6 (FIX H-host-3) — POST /renew (host) + POST /device/:id/renew (device) route tests (fastify * inject). Both routes are authenticated ONLY by the CURRENT client cert the mTLS terminator forwards * (injected here via the `x-client-cert` header); the body carries ONLY the new CSR. Proves: a valid * current cert + a valid SAME-KEY CSR → 201 with a real X.509 leaf carrying the SAME subdomain (no * smuggling); a missing / malformed current cert → 401; a revoked / unknown / mismatched identity → * 403; a DIFFERENT-key CSR → 400 (MVP same-key); over the per-identity rate → 429. */ import 'reflect-metadata' import { describe, test, expect } from 'vitest' import Fastify, { type FastifyInstance } from 'fastify' import * as x509 from '@peculiar/x509' import { webcrypto, generateKeyPairSync, randomUUID } from 'node:crypto' import { createMemoryStores } from '../src/store/memory.js' import { createHostRegistry, type HostRegistry } from '../src/registry/hosts.js' import { createDeviceRegistry, createMemoryDeviceStore, type DeviceRegistry } 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, type DeviceLeafSigner } from '../src/ca/device-issue.js' import { createLeafRenewer } from '../src/ca/rotate.js' import type { LeafSigner } from '../src/ca/sign.js' import { inProcessP256CaSigner, type CaSigner } from '../src/boot/ca-wiring.js' import { buildRenewRouter, createRenewRateLimiter, type RenewDeps } from '../src/api/renew.js' x509.cryptoProvider.set(webcrypto) const DAY_MS = 24 * 60 * 60 * 1000 const TRUST_DOMAIN = 'terminal.yaojia.wang' const ZONE = 'terminal.yaojia.wang' interface P256Ca { readonly caSigner: CaSigner readonly caCert: x509.X509Certificate readonly caDer: Uint8Array } 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, true), ], signer: caSigner, sigAlg: 'ecdsa-p256', }) return { caSigner, caCert: new x509.X509Certificate(caDer), caDer } } async function spkiOf(pub: CryptoKey): Promise { return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub)) } /** base64-DER cert value for the `x-client-cert` header (what the mTLS terminator forwards). */ function certHeader(der: Uint8Array): string { return Buffer.from(der).toString('base64') } function b64Csr(der: Uint8Array): string { return Buffer.from(der).toString('base64') } function appWith(deps: RenewDeps): FastifyInstance { const app = Fastify({ logger: false }) void app.register(buildRenewRouter(deps)) return app } // ── HOST /renew ────────────────────────────────────────────────────────────────────────────────── interface HostCtx { hosts: HostRegistry renewer: ReturnType hostSigner: LeafSigner ca: P256Ca subdomain: string spki: Uint8Array keys: { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey } csr: Uint8Array currentCertDer: Uint8Array accountId: string deviceSigner: DeviceLeafSigner } async function hostCtx(subdomain = 'alice', hosts?: HostRegistry): Promise { const ca = await makeP256Ca('frp-client-CA') const stores = createMemoryStores() const reg = 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 reg.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: ZONE, }) const throwawayDeviceStore = createMemoryDeviceStore() const deviceSigner = createDeviceLeafSigner({ signer: (await makeP256Ca('device-CA')).caSigner, issuer: 'CN=device-CA', caChainDer: [], sanBaseDomain: ZONE, trustDomain: TRUST_DOMAIN, devices: throwawayDeviceStore, }) const renewer = createLeafRenewer({ hostSigner, deviceSigner, deviceExpiry: createDeviceRegistry({ devices: throwawayDeviceStore }), }) // The CURRENT cert = an initial leaf issued for this host (what the mTLS layer would present). const { cert: currentCertDer } = await hostSigner.signHostLeaf(host.hostId, spki, csr) return { hosts: reg, renewer, hostSigner, ca, subdomain, spki, keys, csr, currentCertDer, accountId, deviceSigner } } function hostDeps(ctx: HostCtx, extra?: Partial): RenewDeps { return { hosts: ctx.hosts, devices: createDeviceRegistry({ devices: createMemoryDeviceStore() }), renewer: ctx.renewer, ...extra, } } describe('A6 POST /renew (host, mTLS current cert)', () => { test('valid current cert + same-key CSR → 201 real X.509 leaf carrying the SAME subdomain', async () => { const ctx = await hostCtx('alice') const app = appWith(hostDeps(ctx)) await app.ready() const res = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: b64Csr(ctx.csr) }, }) expect(res.statusCode).toBe(201) const body = JSON.parse(res.body) expect(typeof body.notAfter).toBe('string') expect(Array.isArray(body.caChain)).toBe(true) const leaf = new x509.X509Certificate(new Uint8Array(Buffer.from(body.cert, 'base64'))) const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON() expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' }) }) test('missing current cert → 401', async () => { const ctx = await hostCtx('alice') const app = appWith(hostDeps(ctx)) await app.ready() const res = await app.inject({ method: 'POST', url: '/renew', payload: { csr: b64Csr(ctx.csr) } }) expect(res.statusCode).toBe(401) }) test('malformed current cert bytes → 401', async () => { const ctx = await hostCtx('alice') const app = appWith(hostDeps(ctx)) await app.ready() const res = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(Uint8Array.from([0, 1, 2, 3])) }, payload: { csr: b64Csr(ctx.csr) }, }) expect(res.statusCode).toBe(401) }) test('CSR with a DIFFERENT key (key-swap attempt) → 400 (MVP same-key)', async () => { const ctx = await hostCtx('alice') const app = appWith(hostDeps(ctx)) await app.ready() const { der: otherCsr } = await buildCsrEc('CN=alice') const res = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: b64Csr(otherCsr) }, }) expect(res.statusCode).toBe(400) }) test('revoked host → 403', async () => { const ctx = await hostCtx('alice') const host = await ctx.hosts.getHostBySubdomain('alice') await ctx.hosts.setHostStatus(host!.hostId, 'revoked') const app = appWith(hostDeps(ctx)) await app.ready() const res = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: b64Csr(ctx.csr) }, }) expect(res.statusCode).toBe(403) }) test('current cert whose subdomain is unknown to the registry → 403', async () => { const ctx = await hostCtx('alice') // Route uses a FRESH empty registry — the presented cert's subdomain resolves to nothing. const emptyStores = createMemoryStores() const app = appWith({ hosts: createHostRegistry({ hosts: emptyStores.hosts }), devices: createDeviceRegistry({ devices: createMemoryDeviceStore() }), renewer: ctx.renewer, }) await app.ready() const res = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: b64Csr(ctx.csr) }, }) expect(res.statusCode).toBe(403) }) test('a renewal CSR that requests a foreign subject is stamped with the REGISTRY subdomain (no smuggling)', async () => { const ctx = await hostCtx('alice') const app = appWith(hostDeps(ctx)) await app.ready() // Same key, but a malicious subject label — the issuer must ignore it and stamp 'alice'. const evilCsr = new Uint8Array( ( await x509.Pkcs10CertificateRequestGenerator.create({ name: 'CN=bob.terminal.yaojia.wang', keys: ctx.keys, signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, }) ).rawData, ) const res = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: b64Csr(evilCsr) }, }) expect(res.statusCode).toBe(201) const leaf = new x509.X509Certificate(new Uint8Array(Buffer.from(JSON.parse(res.body).cert, 'base64'))) const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON() expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' }) expect(names).not.toContainEqual({ type: 'dns', value: 'bob.terminal.yaojia.wang' }) }) test('over the per-identity renewal rate → 429', async () => { const ctx = await hostCtx('alice') const app = appWith(hostDeps(ctx, { rateLimiter: createRenewRateLimiter({ max: 1 }) })) await app.ready() const first = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: b64Csr(ctx.csr) }, }) expect(first.statusCode).toBe(201) const second = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: b64Csr(ctx.csr) }, }) expect(second.statusCode).toBe(429) }) }) // ── DEVICE /device/:id/renew ───────────────────────────────────────────────────────────────────── interface DeviceCtx { devices: DeviceRegistry renewer: ReturnType deviceSigner: DeviceLeafSigner ca: P256Ca deviceId: string spki: Uint8Array csr: Uint8Array currentCertDer: Uint8Array } async function registerDeviceLeaf( registry: DeviceRegistry, store: ReturnType, deviceSigner: DeviceLeafSigner, scope = 'alice', ) { const { der: csr, keys } = await buildCsrEc('CN=web-terminal-device') const spki = await spkiOf(keys.publicKey) const record = await registry.registerDevice({ accountId: randomUUID(), subdomainScope: scope, ecPubkeySpki: spki, attestationLevel: 'none' }) const { cert: currentCertDer } = await deviceSigner.signDeviceLeaf(record.deviceId, csr) return { record, csr, spki, currentCertDer } } async function deviceCtx(scope = 'alice'): Promise { const ca = await makeP256Ca('device-CA') const store = createMemoryDeviceStore() const registry = createDeviceRegistry({ devices: store }) const deviceSigner = createDeviceLeafSigner({ signer: ca.caSigner, issuer: ca.caCert.subjectName, caChainDer: [ca.caDer], sanBaseDomain: ZONE, trustDomain: TRUST_DOMAIN, devices: store, }) const hostSigner = createFrpClientLeafSigner({ hosts: createMemoryStores().hosts, signer: ca.caSigner, issuerName: 'CN=frp-client-CA', caChainDer: [], trustDomain: TRUST_DOMAIN, dnsZone: ZONE, }) const renewer = createLeafRenewer({ hostSigner, deviceSigner, deviceExpiry: registry }) const { record, csr, spki, currentCertDer } = await registerDeviceLeaf(registry, store, deviceSigner, scope) return { devices: registry, renewer, deviceSigner, ca, deviceId: record.deviceId, spki, csr, currentCertDer } } function deviceDeps(ctx: DeviceCtx): RenewDeps { return { hosts: createHostRegistry({ hosts: createMemoryStores().hosts }), devices: ctx.devices, renewer: ctx.renewer, } } describe('A6 POST /device/:id/renew (device, mTLS current cert)', () => { test('valid current cert + same-key CSR → 201 real X.509 leaf with the SAME subdomainScope', async () => { const ctx = await deviceCtx('alice') const app = appWith(deviceDeps(ctx)) await app.ready() const res = await app.inject({ method: 'POST', url: `/device/${ctx.deviceId}/renew`, headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: b64Csr(ctx.csr) }, }) expect(res.statusCode).toBe(201) const leaf = new x509.X509Certificate(new Uint8Array(Buffer.from(JSON.parse(res.body).cert, 'base64'))) const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON() expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' }) }) test('missing current cert → 401', async () => { const ctx = await deviceCtx('alice') const app = appWith(deviceDeps(ctx)) await app.ready() const res = await app.inject({ method: 'POST', url: `/device/${ctx.deviceId}/renew`, payload: { csr: b64Csr(ctx.csr) } }) expect(res.statusCode).toBe(401) }) test('CSR with a DIFFERENT key → 400 (MVP same-key)', async () => { const ctx = await deviceCtx('alice') const app = appWith(deviceDeps(ctx)) await app.ready() const { der: otherCsr } = await buildCsrEc('CN=web-terminal-device') const res = await app.inject({ method: 'POST', url: `/device/${ctx.deviceId}/renew`, headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: b64Csr(otherCsr) }, }) expect(res.statusCode).toBe(400) }) test("presenting device X's cert against a DIFFERENT device's :id → 403", async () => { const ctx = await deviceCtx('alice') // A second, distinct device in the same registry. const store = createMemoryDeviceStore() const registry = createDeviceRegistry({ devices: store }) const otherSigner = createDeviceLeafSigner({ signer: ctx.ca.caSigner, issuer: ctx.ca.caCert.subjectName, caChainDer: [ctx.ca.caDer], sanBaseDomain: ZONE, trustDomain: TRUST_DOMAIN, devices: store, }) const other = await registerDeviceLeaf(registry, store, otherSigner, 'alice') // Route registry holds BOTH devices; present X's cert to Y's path. const merged = createMemoryDeviceStore() const mergedReg = createDeviceRegistry({ devices: merged }) const recX = await mergedReg.registerDevice({ accountId: randomUUID(), subdomainScope: 'alice', ecPubkeySpki: ctx.spki, attestationLevel: 'none' }) const recY = await mergedReg.registerDevice({ accountId: randomUUID(), subdomainScope: 'alice', ecPubkeySpki: other.spki, attestationLevel: 'none' }) void recX const app = appWith({ hosts: createHostRegistry({ hosts: createMemoryStores().hosts }), devices: mergedReg, renewer: ctx.renewer }) await app.ready() const res = await app.inject({ method: 'POST', url: `/device/${recY.deviceId}/renew`, // Y's path headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, // X's cert (SPIFFE deviceId ≠ recY) payload: { csr: b64Csr(ctx.csr) }, }) expect(res.statusCode).toBe(403) }) test('revoked device → 403', async () => { const ctx = await deviceCtx('alice') await ctx.devices.setDeviceStatus(ctx.deviceId, 'revoked') const app = appWith(deviceDeps(ctx)) await app.ready() const res = await app.inject({ method: 'POST', url: `/device/${ctx.deviceId}/renew`, headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: b64Csr(ctx.csr) }, }) expect(res.statusCode).toBe(403) }) }) // ── CP6a: bounded rate-limiter hits Map (memory-DoS guard) ───────────────────────────────────────── describe('CP6a createRenewRateLimiter — bounded hits Map', () => { test('sweeps fully-expired identities so the Map does not grow unbounded', () => { let t = 0 const rl = createRenewRateLimiter({ max: 5, windowMs: 1000, maxIdentities: 2, now: () => t }) rl.check('a') rl.check('b') expect(rl.size()).toBe(2) // at the cap t = 5000 // a & b are now well past their 1s window rl.check('c') // inserting a new identity at the cap sweeps the two stale windows first expect(rl.size()).toBe(1) // only 'c' survives }) test('caps tracked identities under a distinct-identity flood (all within window)', () => { const rl = createRenewRateLimiter({ max: 5, windowMs: 60_000, maxIdentities: 2, now: () => 0 }) for (let i = 0; i < 100; i++) rl.check(`id-${i}`) expect(rl.size()).toBeLessThanOrEqual(2) }) }) // ── CP6b + CP6c: body size cap + presented-cert chain/expiry verification ─────────────────────────── describe('CP6b POST /renew — CSR length cap', () => { test('an over-long CSR is rejected by the body schema → 400', async () => { const ctx = await hostCtx('alice') const app = appWith(hostDeps(ctx)) await app.ready() const res = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: 'A'.repeat(9000) }, // > MAX_CSR_B64_LEN (8192) }) expect(res.statusCode).toBe(400) }) }) describe('CP6c POST /renew — presented current-cert chain + expiry verification', () => { /** Mint a host leaf (SPIFFE host SAN, chained to the frp-client-CA) with a caller-chosen validity. */ async function mintHostLeaf(ctx: HostCtx, notBefore: Date, notAfter: Date, signer = ctx.ca.caSigner): Promise { const spiffe = `spiffe://relay.terminal.yaojia.wang/account/${ctx.accountId}/host/${ctx.subdomain}` return assembleCertificate({ subjectPublicKey: ctx.spki, subject: `CN=${ctx.subdomain}`, issuer: ctx.ca.caCert.subjectName, serialNumber: Uint8Array.from([0x09]), notBefore, notAfter, extensions: [ new x509.SubjectAlternativeNameExtension([ { type: 'dns', value: `${ctx.subdomain}.terminal.yaojia.wang` }, { type: 'url', value: spiffe }, ]), new x509.BasicConstraintsExtension(false, undefined, true), new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true), new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]), ], signer, sigAlg: 'ecdsa-p256', }) } test('a valid current cert that CHAINS to the supplied anchor still renews → 201 (no regression)', async () => { const ctx = await hostCtx('alice') const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] })) await app.ready() const res = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(ctx.currentCertDer) }, payload: { csr: b64Csr(ctx.csr) }, }) expect(res.statusCode).toBe(201) }) test('an EXPIRED current cert (valid chain, notAfter in the past) is rejected → 401', async () => { const ctx = await hostCtx('alice') const now = Date.now() const expired = await mintHostLeaf(ctx, new Date(now - 2 * DAY_MS), new Date(now - DAY_MS)) const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] })) await app.ready() const res = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(expired) }, payload: { csr: b64Csr(ctx.csr) }, }) expect(res.statusCode).toBe(401) }) test('a current cert signed by an UNTRUSTED CA (not in the anchor set) is rejected → 401', async () => { const ctx = await hostCtx('alice') const rogueCa = await makeP256Ca('rogue-CA') const now = Date.now() const rogue = await mintHostLeaf(ctx, new Date(now - 60_000), new Date(now + DAY_MS), rogueCa.caSigner) const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] })) await app.ready() const res = await app.inject({ method: 'POST', url: '/renew', headers: { 'x-client-cert': certHeader(rogue) }, payload: { csr: b64Csr(ctx.csr) }, }) expect(res.statusCode).toBe(401) }) })