mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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:
committed by
GitHub
parent
f7880453e2
commit
aaa740f7fd
119
src/mitm/tproxy/dynamicCert.ts
Normal file
119
src/mitm/tproxy/dynamicCert.ts
Normal 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))));
|
||||
};
|
||||
}
|
||||
}
|
||||
67
tests/unit/tproxy-dynamic-cert.test.ts
Normal file
67
tests/unit/tproxy-dynamic-cert.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user