diff --git a/src/mitm/tproxy/dynamicCert.ts b/src/mitm/tproxy/dynamicCert.ts new file mode 100644 index 0000000000..69cbb8c059 --- /dev/null +++ b/src/mitm/tproxy/dynamicCert.ts @@ -0,0 +1,119 @@ +/** + * 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)))); + }; + } +} diff --git a/tests/unit/tproxy-dynamic-cert.test.ts b/tests/unit/tproxy-dynamic-cert.test.ts new file mode 100644 index 0000000000..43de66ab1d --- /dev/null +++ b/tests/unit/tproxy-dynamic-cert.test.ts @@ -0,0 +1,67 @@ +/** + * Fase 3 / Epic A — dynamic per-SNI certificate authority for the TPROXY capture + * mode. The legacy MITM cert is a single static self-signed cert (DNS-spoof of + * known hosts); TPROXY intercepts ARBITRARY hosts, so it needs a local CA that + * issues a leaf cert per SNI hostname on demand (cached). Built on `selfsigned` + * (which supports CA-signing via `options.ca`), parsed/asserted with Node's + * built-in `crypto.X509Certificate`. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { X509Certificate } from "node:crypto"; + +const { generateMitmCa, issueLeafCert, DynamicCertStore } = await import( + "../../src/mitm/tproxy/dynamicCert.ts" +); + +test("generateMitmCa produces a CA certificate (basicConstraints CA, key+cert PEM)", async () => { + const ca = await generateMitmCa("OmniRoute MITM CA (test)"); + assert.match(ca.key, /-----BEGIN (RSA )?PRIVATE KEY-----/); + assert.match(ca.cert, /-----BEGIN CERTIFICATE-----/); + const x = new X509Certificate(ca.cert); + assert.equal(x.ca, true, "CA cert must have basicConstraints CA:TRUE"); + assert.match(x.subject, /OmniRoute MITM CA \(test\)/); +}); + +test("issueLeafCert issues a host leaf signed by the CA, with SAN + chain", async () => { + const ca = await generateMitmCa("OmniRoute MITM CA (test)"); + const leaf = await issueLeafCert("api.stripe.com", ca); + assert.match(leaf.key, /PRIVATE KEY-----/); + // cert bundle = leaf + CA chain so clients can build the path + const certs = leaf.cert.match(/-----BEGIN CERTIFICATE-----/g) ?? []; + assert.ok(certs.length >= 2, "leaf bundle should include the leaf + CA cert"); + + const leafX = new X509Certificate(leaf.cert); + assert.equal(leafX.ca, false, "leaf must not be a CA"); + assert.match(leafX.subjectAltName ?? "", /api\.stripe\.com/); + // signed by the CA → issuer matches CA subject + const caX = new X509Certificate(ca.cert); + assert.equal(leafX.issuer, caX.subject, "leaf issuer must equal CA subject"); + assert.ok(leafX.verify(caX.publicKey), "leaf signature must verify against the CA public key"); +}); + +test("DynamicCertStore caches one SecureContext per hostname", async () => { + const store = new DynamicCertStore("OmniRoute MITM CA (test)"); + const a1 = await store.getSecureContext("a.example.com"); + const a2 = await store.getSecureContext("a.example.com"); + const b1 = await store.getSecureContext("b.example.com"); + assert.equal(a1, a2, "same host → cached SecureContext instance"); + assert.notEqual(a1, b1, "different host → different SecureContext"); + assert.equal(store.size, 2, "two distinct hosts cached"); +}); + +test("DynamicCertStore.createSNICallback resolves a context for the SNI host", async () => { + const store = new DynamicCertStore("OmniRoute MITM CA (test)"); + const cb = store.createSNICallback(); + const ctx = await new Promise((resolve, reject) => + cb("dynamic.example.org", (err: Error | null, c: unknown) => (err ? reject(err) : resolve(c))) + ); + assert.ok(ctx, "SNICallback must yield a SecureContext"); +}); + +test("the CA exposes its cert PEM so it can be installed in the trust store", async () => { + const store = new DynamicCertStore("OmniRoute MITM CA (test)"); + const caPem = await store.getCaCertPem(); + assert.match(caPem, /-----BEGIN CERTIFICATE-----/); + assert.equal(new X509Certificate(caPem).ca, true); +});