Files
web-terminal/agent/src/certs/rotation.ts
Yaojia Wang 2af57e6686 feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
2026-07-02 06:10:16 +02:00

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