/** * Short-lived cert rotation — PLAN_RELAY_AGENT T13 (INV14). Renews the mTLS cert BEFORE expiry via * P3's renewal endpoint using a fresh CSR over the SAME Ed25519 key (pubkey unchanged, only the * cert rotates). Installs the new cert atomically (keystore writeFile is whole-file). A 403 from * the renewal endpoint ⇒ the host was revoked ⇒ onRevoked (⇒ T14 teardown, INV12). * * NOTE (cross-plan / open Q#5): the renewal URL + its auth (mTLS with the current cert vs a short * renewal token) is P3-owned. This derives `${enrollUrl}` → `.../renew` and injects fetch; when P3 * freezes the route, adjust `renewalUrlFor`. Single integration point. */ import { X509Certificate } from 'node:crypto' import type { AgentConfig } from '../config/agentConfig.js' import type { AgentIdentity } from '../keys/identity.js' import type { Keystore } from '../keys/keystore.js' import type { TimerLike } from '../transport/seams.js' import { createBackoff, type BackoffPolicy } from '../transport/backoff.js' import { buildCsr } from '../enroll/csr.js' import { certResponseToPem } from './pem.js' export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry /** * How long after `notAfter` a lapsed leaf may still be swapped for a fresh one (30 days). * * `/renew` is mTLS-authenticated by the very leaf it renews, so a lapsed leaf cannot renew itself — * a deadlock that bricked a host for 8 days in production (the laptop slept through its renewal * window, then the agent logged `client certificate has expired` 6380 times and never recovered). * Inside this window the agent switches to the `/recover` endpoint instead; past it, only a re-pair * can help and the rotator says so once and stops. */ export const DEFAULT_EXPIRED_RENEW_GRACE_MS = 30 * 24 * 60 * 60 * 1000 /** The leaf lapsed longer ago than the recovery grace allows ⇒ operator must re-pair this host. */ export class CertExpiredBeyondGraceError extends Error { constructor( /** How long ago the leaf expired (ms) — non-secret, safe to log. */ readonly expiredForMs: number, /** The grace window that was exceeded (ms). */ readonly graceMs: number, ) { super('client certificate expired beyond the recovery grace window; re-pair required') this.name = 'CertExpiredBeyondGraceError' } } export interface CertRotator { start(): void stop(): void onRotated(cb: () => void): void onRevoked(cb: () => void): void /** A renewal attempt failed (network/HTTP, NOT a 403 revoke). The rotator retries with backoff. */ onError(cb: (err: unknown) => void): void /** * TERMINAL: the leaf expired past the renewal grace window, so no future attempt can succeed. The * rotator has stopped; recovery requires an operator re-pair. */ onExhausted(cb: (err: CertExpiredBeyondGraceError) => void): void } export type RenewOutcome = 'rotated' | 'revoked' /** Derive P3's renewal route from the enroll URL (integration point, open Q#5). */ export function renewalUrlFor(cfg: AgentConfig): string { return cfg.enrollUrl.replace(/\/enroll$/, '/renew') } /** * Recovery route for a leaf that has ALREADY EXPIRED — a sibling PATH on the same enroll host. * * It cannot be `/renew`, because nginx will not forward an expired client certificate at all: under * `ssl_verify_client optional` it answers a bare `400 Bad Request`, and `optional_no_ca` does not * help either — nginx only tolerates CHAIN errors there (`ngx_ssl_verify_error_optional` covers * self-signed / unknown-issuer / unverifiable-leaf, NOT `X509_V_ERR_CERT_HAS_EXPIRED`). So recovery * drops mTLS entirely: it is a plain HTTPS POST carrying the expired cert in the BODY. Nothing is * lost by that — the accompanying CSR is self-signed by the same private key, and the control-plane * signer already enforces CSR proof-of-possession plus `CSR key == registered key`, so possession is * proven exactly as the TLS handshake used to prove it. */ export function recoveryUrlFor(cfg: AgentConfig): string { if (cfg.recoverUrl != null && cfg.recoverUrl.length > 0) return cfg.recoverUrl return cfg.enrollUrl.replace(/\/enroll$/, '/recover') } /** Ms until (validTo − renewBeforeMs), clamped to ≥ 0. */ export function computeRenewDelayMs( certPem: string, renewBeforeMs: number, now: Date, parse: (pem: string) => Date = (p) => new Date(new X509Certificate(p).validTo), ): number { const validTo = parse(certPem).getTime() return Math.max(0, validTo - renewBeforeMs - now.getTime()) } /** * Perform one renewal round-trip. Returns 'rotated' (new cert stored) or 'revoked' (403). Any * other HTTP/network failure throws (caller retries with backoff; the tunnel stays up until the * cert actually expires). */ export async function renewCert( cfg: AgentConfig, id: AgentIdentity, ks: Keystore, fetchImpl: typeof fetch, opts: { url?: string } = {}, ): Promise { const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent') const res = await fetchImpl(opts.url ?? renewalUrlFor(cfg), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ csr }), }) if (res.status === 403) return 'revoked' if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`) // The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the // keystore + frpc (same shape as native enroll). const json = (await res.json()) as { cert?: unknown; caChain?: unknown } const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain) ks.saveCert(certPem, caChainPem) // atomic whole-file install return 'rotated' } /** * One recovery round-trip for an EXPIRED leaf: plain HTTPS (no client cert — see `recoveryUrlFor`) * POSTing the expired cert alongside a fresh CSR over the SAME key. Same outcome contract as * `renewCert`: 'rotated' installs the new leaf, 403 ⇒ 'revoked', anything else throws to the retry. */ export async function recoverCert( cfg: AgentConfig, id: AgentIdentity, ks: Keystore, fetchImpl: typeof fetch, url: string = recoveryUrlFor(cfg), ): Promise { const certs = ks.loadCert() if (certs === null) throw new Error('cannot recover without the expired leaf on disk') const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent') const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cert: certs.certPem, csr }), }) if (res.status === 403) return 'revoked' if (!res.ok) throw new Error(`cert recovery failed: HTTP ${res.status}`) const json = (await res.json()) as { cert?: unknown; caChain?: unknown } const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain) ks.saveCert(certPem, caChainPem) return 'rotated' } export function createCertRotator( cfg: AgentConfig, id: AgentIdentity, ks: Keystore, opts: { renewBeforeMs?: number timer?: TimerLike fetchImpl?: typeof fetch now?: () => Date parseCert?: (pem: string) => Date /** Backoff policy for retrying a FAILED renewal (default 1s→30s). Reset after a success. */ retryBackoff?: BackoffPolicy /** Plain (NON-mTLS) fetch used only for the expired-leaf `/recover` call. */ recoverFetchImpl?: typeof fetch /** Window in which an expired leaf may still be recovered. 0 ⇒ no recovery at all. */ expiredGraceMs?: number } = {}, ): CertRotator { const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS const parseCert = opts.parseCert ?? ((p) => new Date(new X509Certificate(p).validTo)) const timer = opts.timer ?? { setTimeout: (cb, ms) => setTimeout(cb, ms), clearTimeout: (h) => clearTimeout(h as ReturnType), setInterval: (cb, ms) => setInterval(cb, ms), clearInterval: (h) => clearInterval(h as ReturnType), } const doFetch = opts.fetchImpl ?? fetch const recoverFetch = opts.recoverFetchImpl ?? fetch const expiredGraceMs = opts.expiredGraceMs ?? DEFAULT_EXPIRED_RENEW_GRACE_MS const now = opts.now ?? (() => new Date()) const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true }) let handle: unknown = null let rotatedCb: (() => void) | null = null let revokedCb: (() => void) | null = null let errorCb: ((err: unknown) => void) | null = null let exhaustedCb: ((err: CertExpiredBeyondGraceError) => void) | null = null function schedule(): void { const certs = ks.loadCert() if (certs === null) return const delay = computeRenewDelayMs(certs.certPem, renewBeforeMs, now(), parseCert) handle = timer.setTimeout(runRenewal, delay) } /** * How this attempt must be made, derived from how stale the leaf on disk is: * `normal` — still valid ⇒ ordinary mTLS `/renew`; * `recover` — expired but inside the grace window ⇒ plain `/recover` with the cert in the body; * `exhausted` — expired past the grace window ⇒ nothing can succeed; only an operator re-pair. */ function attemptPlan(): { mode: 'normal' | 'recover' | 'exhausted'; expiredForMs: number } { const certs = ks.loadCert() if (certs === null) return { mode: 'normal', expiredForMs: 0 } const expiredForMs = now().getTime() - parseCert(certs.certPem).getTime() if (expiredForMs <= 0) return { mode: 'normal', expiredForMs: 0 } return { mode: expiredForMs > expiredGraceMs ? 'exhausted' : 'recover', expiredForMs } } function runRenewal(): void { const plan = attemptPlan() if (plan.mode === 'exhausted') { // Terminal: report ONCE and arm nothing. The old code retried forever, which is how a single // real failure turned into 6380 identical warnings that buried the signal. handle = null exhaustedCb?.(new CertExpiredBeyondGraceError(plan.expiredForMs, expiredGraceMs)) return } const attempt = plan.mode === 'recover' ? recoverCert(cfg, id, ks, recoverFetch) : renewCert(cfg, id, ks, doFetch) void attempt .then((outcome) => { if (outcome === 'revoked') { revokedCb?.() return } retryBackoff.reset() // a healthy renewal clears the retry backoff for the next cycle rotatedCb?.() schedule() }) .catch((err: unknown) => { // Network/HTTP failure (never a 403 revoke): surface it (caller logs, no secret) and retry // with backoff. The cert is still valid until expiry, so the tunnel stays up meanwhile — a // failed renewal must NEVER tear the supervisor down. errorCb?.(err) handle = timer.setTimeout(runRenewal, retryBackoff.nextDelayMs()) }) } return { start(): void { schedule() }, stop(): void { if (handle !== null) timer.clearTimeout(handle) handle = null }, onRotated(cb): void { rotatedCb = cb }, onRevoked(cb): void { revokedCb = cb }, onError(cb): void { errorCb = cb }, onExhausted(cb): void { exhaustedCb = cb }, } }