feat(mitm): dynamic per-SNI cert authority for TPROXY (TLS decrypt 1/N) (#4173)

Integrated into release/v3.8.29 — dynamic per-SNI cert authority (TLS decrypt 1/N). Validated: 5/5 tests, typecheck:core, file-size, test-discovery green.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-18 11:23:06 -03:00
committed by GitHub
parent f7880453e2
commit aaa740f7fd
2 changed files with 186 additions and 0 deletions

View File

@@ -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<CaPair> {
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<LeafPair> {
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<CaPair> | null = null;
private readonly contexts = new Map<string, tls.SecureContext>();
constructor(caName = "OmniRoute MITM CA", existingCa?: CaPair) {
this.caName = caName;
if (existingCa) this.caPromise = Promise.resolve(existingCa);
}
private getCa(): Promise<CaPair> {
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<string> {
return (await this.getCa()).cert;
}
/** Get (creating + caching on first use) the SecureContext for an SNI host. */
async getSecureContext(hostname: string): Promise<tls.SecureContext> {
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))));
};
}
}

View File

@@ -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);
});