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.
This commit is contained in:
136
agent/src/certs/rotation.ts
Normal file
136
agent/src/certs/rotation.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user