Files
web-terminal/agent/src/certs/rotation.ts
Yaojia Wang 5509c81eee fix(tunnel): recover an expired leaf over plain HTTPS, not mTLS
Correction to the previous commit. Its recovery vhost could not work: nginx
will not forward an expired client certificate in ANY `ssl_verify_client` mode.
`optional` answers a bare `400 The SSL certificate error` before any location
runs, and `optional_no_ca` only tolerates CHAIN failures — see nginx's
`ngx_ssl_verify_error_optional()`, which covers self-signed / unknown-issuer /
unverifiable-leaf and NOT `X509_V_ERR_CERT_HAS_EXPIRED`. Verified live against
the deployed :8472 server, which rejected the real expired leaf.

So recovery drops mTLS instead of trying to bend it:

- agent: `buildTlsOptions` and the mTLS renew transport go back to being
  strictly fail-closed on expiry — the relaxation is gone from the TLS layer
  entirely. The rotator now decides per attempt: valid → mTLS `/renew`,
  expired-inside-grace → plain `/recover`, expired-beyond-grace → terminal
  `onExhausted` with no request issued at all.
- new `recoverCert` POSTs `{cert, csr}` with no client certificate. Possession
  of the private key is still proven: the CSR is self-signed by it and the host
  signer already enforces CSR PoP plus `CSR key == registered key`, so a
  replayed (public) cert without the key yields at most a certificate the
  attacker cannot authenticate with.
- control-plane: `/renew` is strict again (grace 0, matching the terminator).
  The grace lives on the new `POST /recover`, which reads the cert from the BODY
  and ignores the `x-client-cert` header, then runs the identical trust
  pipeline: X.509 path validation to the frp-client-CA anchors, SPIFFE parse,
  `notBefore` (never graced), registry `active` + account match. Revocation
  still bites.
- deploy: no new vhost, no new SNI, no DNS. One `location = /recover` merged
  into the existing enroll vhost, documented in
  `deploy/nginx/enroll-recover-location.md` with the nginx source citation.

The load-bearing new test is "a self-signed cert with a FORGED SPIFFE SAN is
refused → 401": nginx no longer validates the chain on this path, so that
assertion is what keeps `/recover` from being a cert vending machine.

Verified: agent 289/289, control-plane 290/290, tsc clean on both.
2026-07-29 09:52:17 +02:00

263 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<RenewOutcome> {
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<RenewOutcome> {
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<typeof setTimeout>),
setInterval: (cb, ms) => setInterval(cb, ms),
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
}
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
},
}
}