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.
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
/**
|
||
* 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
|
||
},
|
||
}
|
||
}
|