/** * A6 (FIX H-host-3) — leaf RENEWAL routes (registerable fastify plugin; testable standalone via * `app.inject`, wired into `main.ts` later). Both routes are authenticated by the CURRENT valid client * certificate the mTLS terminator (nginx / frps) already verified — NEVER by anything in the request * body. Deny-by-default, uniform reject. * * POST /renew [mTLS current frp-client cert] * Identity (accountId, subdomain, current pubkey) is extracted from the PRESENTED cert's SANs * (SPIFFE URI `.../host/` via relay-auth `parseSpiffeId`, subject key from the cert), NOT the * body. Look the host up BY THAT SUBDOMAIN, require active + account-consistent, then re-issue via * the host signer with the CURRENT cert's key as the subject key — so the delegated gate enforces * the SAME-KEY rule (no key swap) and stamps `host.subdomain` (the SAME subdomain — no smuggling). * 201 { cert, caChain, notAfter }. * * POST /device/:id/renew [mTLS current device cert] * Identity (accountId, deviceId, current pubkey) from the presented device cert's SANs. Look the * device record up by :id, require active + account/deviceId/key-consistent with the presented * cert, then re-issue via the device signer (stamps `record.subdomainScope`). 201. * * CRITICAL (A4 anti-smuggling lesson): a renew can NEVER change the subdomain, account, or key. The * identity comes from the authenticated current cert + the existing registry record; the body carries * ONLY the new CSR. The re-issued SAN is built from the registry record, never from client input. * * `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep the side-effect first. */ import 'reflect-metadata' import * as x509 from '@peculiar/x509' import { webcrypto, X509Certificate as NodeX509Certificate } from 'node:crypto' import { z } from 'zod' import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' import { parseSpiffeId, type SpiffeKind } from 'relay-auth/src/agent/spiffe.js' import { verifyChain } from 'relay-auth/src/agent/verify-mtls.js' import type { HostRegistry } from '../registry/hosts.js' import type { DeviceRegistry } from '../registry/devices.js' import type { LeafRenewer } from '../ca/rotate.js' import { decodeCsrWire } from '../ca/csr.js' import { bytesToBase64, timingSafeEqualBytes } from '../util/bytes.js' x509.cryptoProvider.set(webcrypto) /** Default per-identity renewal budget (window). Renewal is ~2/3-TTL cadence — abuse is a signal. */ export const DEFAULT_RENEW_RATE_MAX = 30 /** Renewal rate window (ms). */ export const DEFAULT_RENEW_RATE_WINDOW_MS = 60 * 60 * 1000 /** * Hard cap on distinct identities the in-memory limiter tracks. Without a bound the `hits` Map grows * one entry per unique presented identity forever (memory-DoS); at the cap we sweep fully-expired * windows and, if still over, evict the least-recently-seen entries. Evicting an idle entry only * resets a limiter that was about to expire anyway, so the rate guarantee for active identities holds. */ export const RENEW_RATE_MAX_IDENTITIES = 10_000 /** Max base64 length of a submitted CSR (a P-256 PKCS#10 is well under 1 KB; this is generous slack). */ export const MAX_CSR_B64_LEN = 8192 /** Max wire length of a body-carried certificate (a P-256 leaf PEM is ~1 KB; generous slack). */ export const MAX_PRESENTED_CERT_LEN = 16384 /** * Default header the mTLS terminator forwards the verified client cert in (base64 DER). The terminator * MUST set this from `$ssl_client_cert` AND strip any client-supplied copy — a client can never provide * its own current cert. Production wiring can swap in a socket-peer-cert resolver instead. */ export const DEFAULT_CLIENT_CERT_HEADER = 'x-client-cert' /** * How long after `notAfter` a presented leaf may still authenticate its OWN re-issuance (30 days). * * `/renew` is authenticated by the very leaf it renews, so a strict expiry check means a host whose * leaf lapsed can never renew it — it is bricked until an operator re-pairs (this happened: a laptop * slept through its renewal window and the tunnel stayed down for 8 days). Accepting a recently * expired leaf FOR RE-ISSUANCE ONLY breaks that deadlock. * * Nothing else is relaxed: the leaf must still chain to the CA anchor set, its SPIFFE identity must * still resolve to a registry record that is `active` and account-consistent, and `notBefore` is NOT * graced. The residual risk is that a stale stolen leaf stays usable for this window — an attacker * holding an unexpired stolen leaf can already renew indefinitely, so this widens an existing * exposure rather than opening a new one. Set to 0 to restore the strict behaviour. */ export const DEFAULT_EXPIRED_RENEW_GRACE_MS = 30 * 24 * 60 * 60 * 1000 /** Uniform reject: 401 = no/invalid current cert (unauthenticated); 403 = cert valid but not allowed. */ export class RenewRejectError extends Error { constructor(public readonly status: 401 | 403) { super('renew rejected') // uniform message — never leak which check failed this.name = 'RenewRejectError' } } /** Per-identity renewal rate exceeded → 429. */ export class RenewRateLimitError extends Error { constructor() { super('renew rate limited') this.name = 'RenewRateLimitError' } } /** The authenticated identity extracted from a presented client cert (never from the body). */ interface CertIdentity { readonly accountId: string /** SPIFFE id segment: the subdomain for a host cert, the deviceId for a device cert. */ readonly id: string readonly kind: SpiffeKind /** SubjectPublicKeyInfo DER of the current cert's subject key (the SAME-KEY anchor). */ readonly publicKeySpki: Uint8Array } /** Seam for the current mTLS client cert. The terminator provides it; tests inject it. */ export interface PresentedClientCert { /** DER of the client cert the mTLS layer verified for THIS request, or null if none was presented. */ presentedCertDer(req: FastifyRequest): Uint8Array | null } /** Per-identity sliding-window rate limiter for renewals. */ export interface RenewRateLimiter { /** Throws `RenewRateLimitError` when `identity` is over its renewal budget. */ check(identity: string): void /** Number of identities currently tracked (bounded) — for tests/observability. */ size(): number } export interface RenewRateLimitOpts { readonly max?: number readonly windowMs?: number /** Clock (ms) — injectable for tests. */ readonly now?: () => number } /** Optional cap override — defaults to `RENEW_RATE_MAX_IDENTITIES`. */ export interface RenewRateLimitBoundOpts extends RenewRateLimitOpts { readonly maxIdentities?: number } /** * In-process sliding-window renewal limiter (mirrors `registry/devices.ts` `checkRateLimit`), with a * bounded `hits` Map (memory-DoS guard). Each check drops the identity's timestamps older than the * window; when the Map reaches `maxIdentities` it sweeps every fully-expired identity and, if still at * the cap, evicts the least-recently-seen entries so the Map can never grow without bound. */ export function createRenewRateLimiter(opts: RenewRateLimitBoundOpts = {}): RenewRateLimiter { const max = opts.max ?? DEFAULT_RENEW_RATE_MAX const windowMs = opts.windowMs ?? DEFAULT_RENEW_RATE_WINDOW_MS const maxIdentities = opts.maxIdentities ?? RENEW_RATE_MAX_IDENTITIES const now = opts.now ?? (() => Date.now()) const hits = new Map() /** Drop identities whose entire window has expired; if still at the cap, evict oldest-seen entries. */ function boundMap(cutoff: number): void { for (const [id, arr] of hits) { const recent = arr.filter((t) => t > cutoff) if (recent.length === 0) hits.delete(id) else hits.set(id, recent) } if (hits.size < maxIdentities) return // All remaining windows are still active but we are at the cap — evict the least-recently-seen // (smallest max-timestamp) down to below the cap. This bounds memory under a distinct-identity flood. const byRecency = [...hits.entries()].sort((a, b) => Math.max(...a[1]) - Math.max(...b[1])) const evict = hits.size - maxIdentities + 1 for (let i = 0; i < evict && i < byRecency.length; i++) hits.delete(byRecency[i]![0]) } return { check(identity) { const ts = now() const cutoff = ts - windowMs // Sweep before inserting a NEW identity so the Map can never exceed the cap. if (!hits.has(identity) && hits.size >= maxIdentities) boundMap(cutoff) const recent = (hits.get(identity) ?? []).filter((t) => t > cutoff) if (recent.length >= max) { hits.set(identity, recent) // persist the pruned window; do NOT record this rejected attempt throw new RenewRateLimitError() } recent.push(ts) hits.set(identity, recent) }, size() { return hits.size }, } } /** * Default `PresentedClientCert` reading the base64-DER cert the mTLS terminator forwarded in a header. * The terminator is trusted to set the header from the VERIFIED client cert and to strip any inbound * copy; this resolver never trusts a client-supplied value on an unterminated path. */ export function headerPresentedCert(headerName: string = DEFAULT_CLIENT_CERT_HEADER): PresentedClientCert { const name = headerName.toLowerCase() return { presentedCertDer(req) { const raw = req.headers[name] const value = Array.isArray(raw) ? raw[0] : raw if (typeof value !== 'string' || value.length === 0) return null return certWireToDer(value) }, } } /** * Normalize a certificate carried over the wire to raw DER. Accepts base64 DER, PEM, and nginx's * header-safe `$ssl_client_escaped_cert` (URL-encoded PEM): URL-decode if escaped, then strip PEM * armor + whitespace to recover the base64 body. Returns null on anything unparseable. */ export function certWireToDer(value: string): Uint8Array | null { try { let s = value.includes('%') ? safeDecodeUri(value) : value if (s.includes('BEGIN CERTIFICATE')) { s = s.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '') } const der = Buffer.from(s, 'base64') return der.length > 0 ? new Uint8Array(der) : null } catch { return null } } /** URL-decode `value`, returning it unchanged if it is not valid percent-encoding. */ function safeDecodeUri(value: string): string { try { return decodeURIComponent(value) } catch { return value } } /** * Trust-anchor verification of the presented current cert (defense in depth: the mTLS terminator has * already verified it, but this route must not trust a cert the terminator would never have accepted). * Mirrors relay-auth `verify-mtls` `verifyChain`: full path validation to a self-signed CA anchor in * the supplied set (the frp-client-CA for host renews, the device-CA for device renews) PLUS the * cert's own notBefore/notAfter validity window. ANY failure throws `RenewRejectError(401)`. */ function assertPresentedCertTrusted( der: Uint8Array, caAnchorsDer: readonly Uint8Array[], nowMs: number, expiredGraceMs: number, ): void { let leaf: NodeX509Certificate let anchors: NodeX509Certificate[] try { leaf = new NodeX509Certificate(Buffer.from(der)) anchors = caAnchorsDer.map((a) => new NodeX509Certificate(Buffer.from(a))) } catch { throw new RenewRejectError(401) } if (!verifyChain(leaf, anchors)) throw new RenewRejectError(401) const notBefore = new Date(leaf.validFrom).getTime() const notAfter = new Date(leaf.validTo).getTime() if (Number.isNaN(notBefore) || Number.isNaN(notAfter)) throw new RenewRejectError(401) // `notBefore` is never graced — a not-yet-valid cert is nonsense, not a recoverable lapse. Only // `notAfter` gets the bounded renewal grace (see DEFAULT_EXPIRED_RENEW_GRACE_MS). if (nowMs < notBefore) throw new RenewRejectError(401) if (nowMs > notAfter + Math.max(0, expiredGraceMs)) throw new RenewRejectError(401) } /** * Extract the authenticated identity from a presented client cert. Parses the DER, reads the SPIFFE URI * SAN through relay-auth's OWN `parseSpiffeId` (so it can never drift from the verifier), requires the * expected kind, and returns the subject public key SPKI. ANY failure → 401 (unauthenticated). This is * the ONLY identity source — no client-supplied field is ever consulted. When `verify` is supplied (the * kind's CA anchor set + clock), the presented cert is additionally chain- and expiry-validated against * that anchor BEFORE its identity is trusted; production wiring MUST supply anchors. */ function parsePresentedCertIdentity( der: Uint8Array, expectedKind: SpiffeKind, verify?: { readonly caAnchorsDer: readonly Uint8Array[] readonly nowMs: number readonly expiredGraceMs: number }, ): CertIdentity { let leaf: x509.X509Certificate try { leaf = new x509.X509Certificate(der) } catch { throw new RenewRejectError(401) } if (verify !== undefined) assertPresentedCertTrusted(der, verify.caAnchorsDer, verify.nowMs, verify.expiredGraceMs) const san = leaf.getExtension(x509.SubjectAlternativeNameExtension) const uri = san?.names.toJSON().find((n) => n.type === 'url')?.value if (uri === undefined) throw new RenewRejectError(401) let parsed: { accountId: string; id: string; kind: SpiffeKind } try { parsed = parseSpiffeId(uri) } catch { throw new RenewRejectError(401) } if (parsed.kind !== expectedKind) throw new RenewRejectError(401) return { accountId: parsed.accountId, id: parsed.id, kind: parsed.kind, publicKeySpki: new Uint8Array(leaf.publicKey.rawData), } } const RenewBodySchema = z.object({ csr: z.string().min(1).max(MAX_CSR_B64_LEN) }).strict() /** `/recover` additionally carries the EXPIRED leaf itself (base64 DER or PEM). */ const RecoverBodySchema = z .object({ cert: z.string().min(1).max(MAX_PRESENTED_CERT_LEN), csr: z.string().min(1).max(MAX_CSR_B64_LEN), }) .strict() export interface RenewDeps { readonly hosts: HostRegistry readonly devices: DeviceRegistry readonly renewer: LeafRenewer /** Current-cert seam (mTLS terminator provides it). Defaults to the header resolver. */ readonly presentedCert?: PresentedClientCert /** Per-identity renewal rate limiter. Defaults to an in-process sliding window. */ readonly rateLimiter?: RenewRateLimiter /** * frp-client-CA anchor DER(s). When supplied, the presented current HOST cert is chain- and * expiry-validated against these before its identity is trusted (defense in depth). Production * wiring MUST supply them; omitted only by legacy callers/tests that inject an already-trusted cert. */ readonly hostCaAnchorsDer?: readonly Uint8Array[] /** device-CA anchor DER(s) — same role for the DEVICE renew path. */ readonly deviceCaAnchorsDer?: readonly Uint8Array[] /** * Window after `notAfter` in which a presented leaf may still authenticate its own re-issuance. * Defaults to `DEFAULT_EXPIRED_RENEW_GRACE_MS`; 0 restores the strict fail-closed behaviour. */ readonly expiredRenewGraceMs?: number } /** Map any thrown error to a uniform HTTP reject — never leak which internal check failed. */ function sendError(reply: FastifyReply, err: unknown): void { if (err instanceof RenewRejectError) { void reply.code(err.status).send({ error: 'rejected' }) return } if (err instanceof RenewRateLimitError) { void reply.code(429).send({ error: 'rate_limited' }) return } // LeafSignError / DeviceLeafSignError / ZodError / decode failures / anything else → uniform 400. void reply.code(400).send({ error: 'rejected' }) } export function buildRenewRouter(deps: RenewDeps): FastifyPluginAsync { const presentedCert = deps.presentedCert ?? headerPresentedCert() const rateLimiter = deps.rateLimiter ?? createRenewRateLimiter() // `/renew` stays STRICT (0): the mTLS terminator would never forward an expired cert to it anyway. // The grace belongs to `/recover`, the route built for exactly that case. const recoverGraceMs = deps.expiredRenewGraceMs ?? DEFAULT_EXPIRED_RENEW_GRACE_MS return async (app) => { app.post('/renew', async (req, reply) => { try { const der = presentedCert.presentedCertDer(req) if (der === null) throw new RenewRejectError(401) const identity = parsePresentedCertIdentity( der, 'host', deps.hostCaAnchorsDer ? { caAnchorsDer: deps.hostCaAnchorsDer, nowMs: Date.now(), expiredGraceMs: 0 } : undefined, ) rateLimiter.check(`host:${identity.accountId}:${identity.id}`) // Identity is the SUBDOMAIN from the cert — resolve the host the registry actually holds for it. // A revoked, unknown, or account-inconsistent binding is forbidden (deny-by-default). 403. const host = await deps.hosts.getHostBySubdomain(identity.id) if (host === null || host.status === 'revoked' || host.accountId !== identity.accountId) { throw new RenewRejectError(403) } const body = RenewBodySchema.parse(req.body) // Pass the CURRENT cert's key as the subject key → the delegated gate enforces CSR key == // current cert key (SAME-KEY, no swap) AND registered key == current cert key; it stamps // host.subdomain (SAME subdomain — the body cannot smuggle a different one). const leaf = await deps.renewer.renewHostLeaf(host.hostId, identity.publicKeySpki, decodeCsrWire(body.csr)) await reply.code(201).send({ cert: bytesToBase64(leaf.cert), caChain: leaf.caChain.map((c) => bytesToBase64(c)), notAfter: leaf.notAfter.toISOString(), }) } catch (err) { sendError(reply, err) } }) /** * Expired-leaf recovery. The lapsed cert arrives in the BODY because no TLS terminator will * forward an expired client certificate (nginx `optional` → bare 400; `optional_no_ca` tolerates * only chain errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). That makes this route the SOLE * verifier, so it runs the identical trust pipeline as `/renew` — real X.509 path validation to * the frp-client-CA anchors, SPIFFE parse, `notBefore`, registry `active` + account match — and * differs ONLY in allowing a bounded overrun on `notAfter`. * * Possession of the private key is still proven: the CSR is self-signed by it, and the host * signer's delegated gate enforces CSR PoP plus `CSR key == registered key`. A replayed cert * without the key therefore yields, at most, a certificate the attacker cannot authenticate with. * The header channel is deliberately IGNORED here — on this route only the body speaks. */ app.post('/recover', async (req, reply) => { try { const body = RecoverBodySchema.parse(req.body) const der = certWireToDer(body.cert) if (der === null) throw new RenewRejectError(401) const identity = parsePresentedCertIdentity( der, 'host', deps.hostCaAnchorsDer ? { caAnchorsDer: deps.hostCaAnchorsDer, nowMs: Date.now(), expiredGraceMs: recoverGraceMs, } : undefined, ) rateLimiter.check(`recover:${identity.accountId}:${identity.id}`) const host = await deps.hosts.getHostBySubdomain(identity.id) if (host === null || host.status === 'revoked' || host.accountId !== identity.accountId) { throw new RenewRejectError(403) } const leaf = await deps.renewer.renewHostLeaf( host.hostId, identity.publicKeySpki, decodeCsrWire(body.csr), ) await reply.code(201).send({ cert: bytesToBase64(leaf.cert), caChain: leaf.caChain.map((c) => bytesToBase64(c)), notAfter: leaf.notAfter.toISOString(), }) } catch (err) { sendError(reply, err) } }) app.post('/device/:id/renew', async (req, reply) => { try { const der = presentedCert.presentedCertDer(req) if (der === null) throw new RenewRejectError(401) const identity = parsePresentedCertIdentity( der, 'device', deps.deviceCaAnchorsDer ? { caAnchorsDer: deps.deviceCaAnchorsDer, nowMs: Date.now(), expiredGraceMs: 0 } : undefined, ) const id = (req.params as { id: string }).id rateLimiter.check(`device:${identity.id}`) // The registry record for :id is the identity source of truth. Require it active AND that the // presented cert genuinely belongs to it: same account, same deviceId as the path, same key. const record = await deps.devices.getDevice(id) if ( record === null || record.status === 'revoked' || record.accountId !== identity.accountId || identity.id !== id || !timingSafeEqualBytes(record.ecPubkeySpki, identity.publicKeySpki) ) { throw new RenewRejectError(403) } const body = RenewBodySchema.parse(req.body) // The delegated gate re-checks CSR key == record.ecPubkeySpki (== current cert key, verified // above) → SAME-KEY; it stamps record.subdomainScope (SAME scope — no smuggling). const leaf = await deps.renewer.renewDeviceLeaf(id, decodeCsrWire(body.csr)) await reply.code(201).send({ cert: bytesToBase64(leaf.cert), caChain: leaf.caChain.map((c) => bytesToBase64(c)), notAfter: leaf.notAfter.toISOString(), }) } catch (err) { sendError(reply, err) } }) } }