/** * 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 { buildCsr } from '../enroll/csr.js' export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry export interface CertRotator { start(): void stop(): void onRotated(cb: () => void): void onRevoked(cb: () => 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') } /** 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, ): Promise { const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent') const res = await fetchImpl(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}`) const json = (await res.json()) as { cert?: string; caChain?: string } if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') { throw new Error('cert renewal response missing cert/caChain') } ks.saveCert(json.cert, json.caChain) // atomic whole-file install 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 } = {}, ): 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 now = opts.now ?? (() => new Date()) let handle: unknown = null let rotatedCb: (() => void) | null = null let revokedCb: (() => 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) } function runRenewal(): void { void renewCert(cfg, id, ks, doFetch) .then((outcome) => { if (outcome === 'revoked') { revokedCb?.() return } rotatedCb?.() schedule() }) .catch(() => { // network error: retry after renewBeforeMs; the tunnel stays up meanwhile. handle = timer.setTimeout(runRenewal, renewBeforeMs) }) } return { start(): void { schedule() }, stop(): void { if (handle !== null) timer.clearTimeout(handle) handle = null }, onRotated(cb): void { rotatedCb = cb }, onRevoked(cb): void { revokedCb = cb }, } }