/** * Fase 3 / Epic A — dynamic per-SNI certificate authority for the TPROXY capture * mode. * * The legacy MITM cert (`cert/generate.ts`) is a single static self-signed cert, * which works only because AgentBridge DNS-spoofs a fixed set of known hosts. * TPROXY intercepts ARBITRARY hosts, so the listener must present a valid leaf * certificate for whatever SNI the client requests. This module runs a local CA * that issues a leaf per hostname on demand (signed by the CA, cached as a * `tls.SecureContext`). The CA cert is installed in the OS trust store * (reusing the existing cert/install path), so every issued leaf is trusted. * * Built on `selfsigned` (already a dependency; v5 supports CA-signing via * `options.ca`). Security note: a trusted MITM CA that signs any host is a * powerful capability — it is gated behind the explicit, local-only TPROXY * capture mode and the CA private key never leaves the machine. */ import tls from "node:tls"; export interface CaPair { /** PEM private key. */ key: string; /** PEM certificate. */ cert: string; } export interface LeafPair { /** PEM private key for the leaf. */ key: string; /** PEM bundle: leaf certificate followed by the CA certificate (chain). */ cert: string; } /** Generate a long-lived local CA (basicConstraints CA, keyCertSign). */ export async function generateMitmCa(name = "OmniRoute MITM CA"): Promise { const { default: selfsigned } = await import("selfsigned"); const notAfter = new Date(); notAfter.setFullYear(notAfter.getFullYear() + 10); const pems = await selfsigned.generate([{ name: "commonName", value: name }], { keySize: 2048, algorithm: "sha256", notAfterDate: notAfter, extensions: [ { name: "basicConstraints", cA: true, critical: true }, { name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true }, ], }); return { key: pems.private, cert: pems.cert }; } /** Issue a leaf certificate for `hostname`, signed by `ca`. Returns leaf key + * a cert bundle (leaf + CA) so clients can build the trust path. */ export async function issueLeafCert(hostname: string, ca: CaPair): Promise { const { default: selfsigned } = await import("selfsigned"); const notAfter = new Date(); notAfter.setFullYear(notAfter.getFullYear() + 1); const pems = await selfsigned.generate([{ name: "commonName", value: hostname }], { keySize: 2048, algorithm: "sha256", notAfterDate: notAfter, extensions: [{ name: "subjectAltName", altNames: [{ type: 2, value: hostname }] }], ca: { key: ca.key, cert: ca.cert }, }); return { key: pems.private, cert: `${pems.cert.trim()}\n${ca.cert.trim()}\n` }; } /** * Lazily creates a CA and issues/caches one `tls.SecureContext` per SNI host. * Pass an `existingCa` (e.g. loaded from disk) to keep the CA stable across * restarts so the trust store does not need re-installing. */ export class DynamicCertStore { private readonly caName: string; private caPromise: Promise | null = null; private readonly contexts = new Map(); constructor(caName = "OmniRoute MITM CA", existingCa?: CaPair) { this.caName = caName; if (existingCa) this.caPromise = Promise.resolve(existingCa); } private getCa(): Promise { if (!this.caPromise) this.caPromise = generateMitmCa(this.caName); return this.caPromise; } /** The CA cert PEM — install this in the OS trust store. */ async getCaCertPem(): Promise { return (await this.getCa()).cert; } /** Get (creating + caching on first use) the SecureContext for an SNI host. */ async getSecureContext(hostname: string): Promise { const cached = this.contexts.get(hostname); if (cached) return cached; const ca = await this.getCa(); const leaf = await issueLeafCert(hostname, ca); const ctx = tls.createSecureContext({ key: leaf.key, cert: leaf.cert }); this.contexts.set(hostname, ctx); return ctx; } /** Number of distinct hosts with a cached context. */ get size(): number { return this.contexts.size; } /** An SNICallback for `tls.createServer`/`tls.TLSSocket` (`{ SNICallback }`). */ createSNICallback(): ( servername: string, cb: (err: Error | null, ctx?: tls.SecureContext) => void ) => void { return (servername, cb) => { this.getSecureContext(servername) .then((ctx) => cb(null, ctx)) .catch((err) => cb(err instanceof Error ? err : new Error(String(err)))); }; } }