`POST /renew` is authenticated by mTLS with the very leaf it renews, so once that leaf lapsed the host could never renew it and the tunnel stayed down until an operator re-paired by hand. Production hit exactly this: the Mac slept through its 8h renewal window, the 24h leaf expired, and the agent then logged `client certificate has expired; renew before dialling` 6380 times over 8 days without recovering. Three layers independently refused an expired leaf, so all three had to move: - agent `buildTlsOptions` gains an opt-in `expiredGraceMs`. Absent/0 keeps the historical fail-closed behaviour, and the TUNNEL dial never passes it — only the renew transport does. Past the window it throws the new `CertExpiredBeyondGraceError`. - agent rotator routes an already-expired leaf to a separate recovery endpoint and treats "beyond grace" as TERMINAL: report once via `onExhausted`, stop retrying, and name the fix (re-pair) instead of spamming warnings forever. - control-plane `assertPresentedCertTrusted` grants a bounded grace on `notAfter` only. Chain validation, SPIFFE identity, `notBefore`, and the registry `active`/account checks are all unchanged, so revocation still bites. - new `deploy/nginx/recover-mtls.conf` (:8472). The strict enroll vhost cannot host this: under `ssl_verify_client optional` nginx answers a bare 400 as soon as a presented cert fails verification, so an expired leaf never reaches the location — and the directive is server-level, not per-location. Grace defaults to 30 days on both ends and is configurable (`RECOVER_URL`, `expiredRenewGraceMs`). The honest trade is recorded in the code: a stale stolen leaf stays reusable for the window, which widens an existing exposure (an unexpired stolen leaf already renews indefinitely) rather than opening a new one. Verified: agent 296/296, control-plane 286/286, tsc clean on both.
615 lines
24 KiB
TypeScript
615 lines
24 KiB
TypeScript
/**
|
|
* 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<P256Ca> {
|
|
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<Uint8Array> {
|
|
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<typeof createLeafRenewer>
|
|
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<HostCtx> {
|
|
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>): 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<typeof createLeafRenewer>
|
|
deviceSigner: DeviceLeafSigner
|
|
ca: P256Ca
|
|
deviceId: string
|
|
spki: Uint8Array
|
|
csr: Uint8Array
|
|
currentCertDer: Uint8Array
|
|
}
|
|
|
|
async function registerDeviceLeaf(
|
|
registry: DeviceRegistry,
|
|
store: ReturnType<typeof createMemoryDeviceStore>,
|
|
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<DeviceCtx> {
|
|
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<Uint8Array> {
|
|
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)
|
|
})
|
|
|
|
/**
|
|
* POLICY CHANGE (expired-leaf deadlock fix): `/renew` is mTLS-authenticated by the very leaf it
|
|
* renews, so refusing every expired leaf meant a host whose leaf lapsed could NEVER renew and was
|
|
* bricked until a manual re-pair. A recently-expired leaf is now accepted for RE-ISSUANCE ONLY,
|
|
* inside a bounded window. Everything else stays fail-closed — the tests below pin that down.
|
|
*/
|
|
test('a leaf expired INSIDE the grace window renews → 201 (breaks the deadlock)', async () => {
|
|
const ctx = await hostCtx('alice')
|
|
const now = Date.now()
|
|
const expired = await mintHostLeaf(ctx, new Date(now - 9 * DAY_MS), new Date(now - 8 * 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(201)
|
|
})
|
|
|
|
test('a leaf expired BEYOND the grace window is rejected → 401', async () => {
|
|
const ctx = await hostCtx('alice')
|
|
const now = Date.now()
|
|
const stale = await mintHostLeaf(ctx, new Date(now - 60 * DAY_MS), new Date(now - 31 * 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(stale) },
|
|
payload: { csr: b64Csr(ctx.csr) },
|
|
})
|
|
expect(res.statusCode).toBe(401)
|
|
})
|
|
|
|
test('grace 0 restores the strict fail-closed behaviour → 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], expiredRenewGraceMs: 0 }),
|
|
)
|
|
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('grace NEVER bypasses revocation — revoked host with an in-grace leaf → 403', async () => {
|
|
const ctx = await hostCtx('alice')
|
|
const now = Date.now()
|
|
const expired = await mintHostLeaf(ctx, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS))
|
|
const host = await ctx.hosts.getHostBySubdomain('alice')
|
|
await ctx.hosts.setHostStatus(host!.hostId, 'revoked')
|
|
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(403)
|
|
})
|
|
|
|
test('grace NEVER bypasses chain validation — untrusted CA + in-grace leaf → 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 - 9 * DAY_MS),
|
|
new Date(now - 8 * 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)
|
|
})
|
|
|
|
test('grace applies ONLY to notAfter — a not-yet-valid leaf is still rejected → 401', async () => {
|
|
const ctx = await hostCtx('alice')
|
|
const now = Date.now()
|
|
const future = await mintHostLeaf(ctx, new Date(now + DAY_MS), new Date(now + 2 * 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(future) },
|
|
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)
|
|
})
|
|
})
|