From a2004060c57eba798f8dc4a7a5cd146b4b69c5b6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:37:27 -0300 Subject: [PATCH] feat(mitm): root-CA + per-host leaf certs for AgentBridge static server (#6684) (#7731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the AgentBridge static server's single self-signed leaf cert (scoped only to the 4 antigravity hosts) with a persisted local root CA + per-SNI leaf certs, reusing the CA/leaf crypto already proven for the TPROXY capture mode (tproxy/dynamicCert.ts). server.cjs switches from a static key/cert to an SNICallback so every host in MITM_TOOL_HOSTS gets a matching leaf, not just antigravity. - src/mitm/cert/rootCa.ts: load-or-generate-once CA persistence (ca.key/ca.crt under /mitm/), private key chmod 0o600. - src/mitm/cert/migration.ts: pure migration gate — an already-trusted legacy leaf install stays on the old leaf until the operator opts in via MITM_ROOT_CA_ENABLED=true; a fresh install gets the CA model automatically. A CA that can sign a leaf for any host is materially more powerful than the old fixed-SAN leaf, so the switch is never silent for an already-trusted install. - src/mitm/cert/install.ts: installCaCert() — thin wrapper over the existing cert-path-agnostic installCertResult(), same omniroute-mitm.crt trust-store slot the old leaf used (supersedes it, no dual-trust cleanup needed). - src/mitm/manager.ts: wires the migration gate + CA load/install into the bridge-start sequence, passes the resolved MITM_CERT_MODE to the spawned server.cjs child so it can't drift from manager.ts's decision. - src/mitm/server.cjs: async-bootstraps server creation behind the same MITM_CERT_MODE gate; default ("legacy") reproduces the exact prior synchronous behavior. The CJS/ESM boundary (server.cjs is spawned via plain `node`, no TS loader) is crossed via a new _internal/rootCaShim.cjs CJS twin of the CA/leaf crypto, matching the established pattern of the sibling _internal/*.cjs shims in this file. Validated: 14 new unit tests (CA generate-once, 0o600 key perms, CA basicConstraints, leaf issuance across every MITM_TOOL_HOSTS host, SAN match, chain validation against the CA, leaf caching, migration-gate branches) plus a manual live smoke test spawning server.cjs in both legacy and root-ca mode (confirmed a real TLS handshake with SNI api.githubcopilot.com returns a CA-issued leaf for that host). Deferred to VPS live validation (OS-trust-store mutation is not unit-testable): actual OS trust-store install of the CA cert via installCaCert() on Linux/macOS/Windows. --- changelog.d/features/6684-mitm-root-ca.md | 1 + docs/frameworks/AGENTBRIDGE.md | 16 + docs/security/MITM-TPROXY-DECRYPT.md | 23 +- src/mitm/_internal/rootCaShim.cjs | 108 ++++ src/mitm/cert/install.ts | 18 + src/mitm/cert/migration.ts | 43 ++ src/mitm/cert/rootCa.ts | 73 +++ src/mitm/manager.ts | 47 +- src/mitm/server.cjs | 509 ++++++++++-------- tests/unit/mitm-cert-migration-6684.test.ts | 74 +++ .../mitm-root-ca-leaf-issuance-6684.test.ts | 74 +++ .../mitm-root-ca-persistence-6684.test.ts | 65 +++ 12 files changed, 810 insertions(+), 241 deletions(-) create mode 100644 changelog.d/features/6684-mitm-root-ca.md create mode 100644 src/mitm/_internal/rootCaShim.cjs create mode 100644 src/mitm/cert/migration.ts create mode 100644 src/mitm/cert/rootCa.ts create mode 100644 tests/unit/mitm-cert-migration-6684.test.ts create mode 100644 tests/unit/mitm-root-ca-leaf-issuance-6684.test.ts create mode 100644 tests/unit/mitm-root-ca-persistence-6684.test.ts diff --git a/changelog.d/features/6684-mitm-root-ca.md b/changelog.d/features/6684-mitm-root-ca.md new file mode 100644 index 0000000000..d5661a4e0a --- /dev/null +++ b/changelog.d/features/6684-mitm-root-ca.md @@ -0,0 +1 @@ +- **feat(mitm):** the AgentBridge static MITM server (`server.cjs`) can now issue a per-host TLS leaf from a persisted local root CA (`src/mitm/cert/rootCa.ts`, reusing the CA/leaf crypto already proven for TPROXY in `tproxy/dynamicCert.ts`) instead of a single static self-signed leaf scoped only to the 4 antigravity hosts — a fresh install covers the full `MITM_TOOL_HOSTS` set automatically; an install that already trusted the old leaf keeps using it until the operator opts in via `MITM_ROOT_CA_ENABLED=true` (`src/mitm/cert/migration.ts`), so no existing install is silently upgraded to the more powerful any-host-signing CA trust model (#6684). diff --git a/docs/frameworks/AGENTBRIDGE.md b/docs/frameworks/AGENTBRIDGE.md index cb75cb9c06..76949f46fe 100644 --- a/docs/frameworks/AGENTBRIDGE.md +++ b/docs/frameworks/AGENTBRIDGE.md @@ -84,6 +84,22 @@ The core MITM server runs as a Node.js CJS child process (to avoid rewriting the `TARGET_HOSTS` is loaded from `DATA_DIR/mitm/targets.json` (written by `targets/index.ts` at boot), allowing dynamic updates without restarting the CJS server. +> **Root-CA model (#6684).** The per-SNI-cert-signed-by-a-CA description above +> is the persisted root-CA model added in #6684 (`src/mitm/cert/rootCa.ts` + +> `src/mitm/_internal/rootCaShim.cjs`, reusing the CA/leaf crypto already +> proven for TPROXY in `src/mitm/tproxy/dynamicCert.ts`) — it replaces the +> older single static self-signed leaf (`src/mitm/cert/generate.ts`, still +> scoped only to the antigravity hosts) that a bare `server.crt`/`server.key` +> pair on disk indicates. **Migration behavior**: a fresh install (no prior +> `server.crt`) gets the root-CA model automatically; an install that already +> trusted the old static leaf keeps using it until the operator sets +> `MITM_ROOT_CA_ENABLED=true` and restarts the bridge (`src/mitm/cert/migration.ts` +> is the pure decision function — a trusted MITM CA that can sign a leaf for +> **any** host is materially more powerful than the old fixed-SAN leaf, so the +> switch is never silent for an already-trusted install). The CA cert installs +> into the same `omniroute-mitm.crt` trust-store slot the old leaf used +> (`cert/install.ts::installCaCert`) — no dual-trust cleanup needed. + ### 2.3 Handler base (`src/mitm/handlers/base.ts`) All agent handlers extend `MitmHandlerBase`: diff --git a/docs/security/MITM-TPROXY-DECRYPT.md b/docs/security/MITM-TPROXY-DECRYPT.md index a8a443c680..9162955de5 100644 --- a/docs/security/MITM-TPROXY-DECRYPT.md +++ b/docs/security/MITM-TPROXY-DECRYPT.md @@ -118,9 +118,26 @@ The loader probes, in priority order: ## §4 The per-SNI dynamic CA and trust-store installer -The static AgentBridge MITM cert works only because AgentBridge DNS-spoofs a -**fixed** host set. TPROXY intercepts **arbitrary** hosts, so the listener must -present a valid leaf for whatever SNI the client requests. +> **#6684 update:** the AgentBridge static server (`src/mitm/server.cjs`) now +> shares this same CA/leaf architecture pattern instead of a single static +> self-signed leaf. It uses a **distinct** CA instance +> (`src/mitm/cert/rootCa.ts`, persisted at `/mitm/ca.key`/`ca.crt`) +> and installs under the pre-existing `omniroute-mitm.crt` trust-store slot +> (superseding the old single leaf there — no dual-trust cleanup needed), +> kept fully separate from TPROXY's own `omniroute-tproxy-ca.crt` slot below. +> Fresh AgentBridge installs get the CA model automatically; an install that +> already trusted the old static leaf keeps using it until the operator opts +> in via `MITM_ROOT_CA_ENABLED=true` (see `src/mitm/cert/migration.ts`) — a +> trusted MITM CA that can sign a leaf for **any** host is materially more +> powerful than the old fixed-SAN leaf, so the switch is never silent for an +> already-trusted install. + +Historically, the static AgentBridge MITM cert worked only because AgentBridge +DNS-spoofs a **fixed** host set (now unified with the model below). TPROXY +intercepts **arbitrary** hosts, so its listener must present a valid leaf for +whatever SNI the client requests — the same requirement AgentBridge now has +for the full `MITM_TOOL_HOSTS` set (9 tool entries) instead of just the 4 +antigravity hosts. ### Dynamic CA (`src/mitm/tproxy/dynamicCert.ts`) diff --git a/src/mitm/_internal/rootCaShim.cjs b/src/mitm/_internal/rootCaShim.cjs new file mode 100644 index 0000000000..8d677569d7 --- /dev/null +++ b/src/mitm/_internal/rootCaShim.cjs @@ -0,0 +1,108 @@ +/** + * CJS twin of the root-CA persistence + per-host leaf issuance used by + * `src/mitm/server.cjs` (#6684). + * + * This file exists for the same reason the sibling `_internal/*.cjs` shims + * do: `server.cjs` runs as a standalone CommonJS process (spawned via plain + * `node server.cjs`, no TS/ESM loader — see `src/mitm/manager.ts`'s + * `spawn(process.execPath, [MITM_SERVER_PATH], ...)`), so it cannot + * `import()` the ESM/TS sources directly. The CA-generation and leaf-signing + * parameters below are copied byte-for-byte from the proven, already-tested + * TS implementation in `src/mitm/tproxy/dynamicCert.ts` + * (`generateMitmCa`/`issueLeafCert`) — do not let this drift from that file; + * any change to the signing options there should be mirrored here. + * + * Persistence layout mirrors `src/mitm/cert/rootCa.ts` exactly (same + * `ca.key`/`ca.crt` file names under `/mitm/`), so a CA generated + * by one is loaded by the other without conversion. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const tls = require("tls"); + +const CA_KEY_FILE = "ca.key"; +const CA_CERT_FILE = "ca.crt"; + +async function generateMitmCa(name) { + const { default: selfsigned } = await import("selfsigned"); + const notAfter = new Date(); + notAfter.setFullYear(notAfter.getFullYear() + 10); + const pems = await selfsigned.generate([{ name: "commonName", value: name || "OmniRoute MITM CA" }], { + 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 }; +} + +async function issueLeafCert(hostname, ca) { + 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` }; +} + +/** Load the persisted CA from `certDir`, generating + persisting one on first run. */ +async function loadOrCreateMitmCa(certDir) { + const keyPath = path.join(certDir, CA_KEY_FILE); + const certPath = path.join(certDir, CA_CERT_FILE); + + if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { + return { + key: fs.readFileSync(keyPath, "utf-8"), + cert: fs.readFileSync(certPath, "utf-8"), + keyPath, + certPath, + }; + } + + const ca = await generateMitmCa("OmniRoute MITM CA"); + + if (!fs.existsSync(certDir)) fs.mkdirSync(certDir, { recursive: true }); + fs.writeFileSync(keyPath, ca.key); + fs.writeFileSync(certPath, ca.cert); + fs.chmodSync(keyPath, 0o600); + + return { key: ca.key, cert: ca.cert, keyPath, certPath }; +} + +/** Lazily issue + cache one `tls.SecureContext` per SNI host, signed by `ca`. */ +class DynamicCertStore { + constructor(ca) { + this.ca = ca; + this.contexts = new Map(); + } + + async getSecureContext(hostname) { + const cached = this.contexts.get(hostname); + if (cached) return cached; + const leaf = await issueLeafCert(hostname, this.ca); + const ctx = tls.createSecureContext({ key: leaf.key, cert: leaf.cert }); + this.contexts.set(hostname, ctx); + return ctx; + } + + createSNICallback() { + return (servername, cb) => { + this.getSecureContext(servername) + .then((ctx) => cb(null, ctx)) + .catch((err) => cb(err instanceof Error ? err : new Error(String(err)))); + }; + } +} + +module.exports = { loadOrCreateMitmCa, issueLeafCert, DynamicCertStore }; diff --git a/src/mitm/cert/install.ts b/src/mitm/cert/install.ts index 82fc4a0d91..9eb2a8c94e 100644 --- a/src/mitm/cert/install.ts +++ b/src/mitm/cert/install.ts @@ -314,6 +314,24 @@ export async function installCertResult( } } +/** + * Install the persisted MITM root CA cert (`cert/rootCa.ts`) into the OS + * trust store. Named wrapper over {@link installCertResult} for call-site + * clarity — the underlying platform installers + * (`installCertLinux`/`installCertMac`/`installCertWindows`) are already + * cert-path-agnostic and keep writing to the same `omniroute-mitm.crt` + * trust-store slot the old single-leaf install used, so the CA cert simply + * supersedes the old leaf under that slot; no new slot, no dual-trust + * cleanup needed. Distinct from TPROXY's own `omniroute-tproxy-ca.crt` slot + * (`src/mitm/tproxy/caTrust.ts`), which this feature does not touch. #6684 + */ +export async function installCaCert( + sudoPassword: string, + caCertPath: string +): Promise { + return installCertResult(sudoPassword, caCertPath); +} + async function installCertMac(sudoPassword: string, certPath: string): Promise { try { await execFileWithPassword( diff --git a/src/mitm/cert/migration.ts b/src/mitm/cert/migration.ts new file mode 100644 index 0000000000..c8dd8c7f63 --- /dev/null +++ b/src/mitm/cert/migration.ts @@ -0,0 +1,43 @@ +import path from "path"; +import fs from "fs"; + +// #6684: migration gate between the legacy single self-signed leaf +// (`cert/generate.ts` → `server.crt`/`server.key`) and the new persisted +// root-CA + per-host-leaf model (`cert/rootCa.ts` → `ca.crt`/`ca.key`). +// +// A trusted MITM CA that can sign a certificate for ANY host is materially +// more powerful than today's fixed-SAN leaf, so switching an already-trusted +// install to the CA model must be an explicit, opt-in transition — never a +// silent upgrade that could re-trigger (or skip) an OS trust prompt a user +// isn't expecting. This module is a pure decision function so the gate is +// unit-testable without touching the filesystem beyond the passed-in dir. + +export type CertMigrationDecision = "use-legacy-leaf" | "use-root-ca"; + +/** + * Decide which cert model a run of the AgentBridge static server should use. + * + * - A pre-existing legacy leaf (`server.crt`/`server.key`) with no CA pair + * yet present means an already-trusted install: keep serving the legacy + * leaf this run (unchanged behavior) unless the operator has explicitly + * opted in via `rootCaEnabled`. + * - Anything else (fresh install, or explicit opt-in) proceeds to the CA + * model — `loadOrCreateMitmCa()` will generate-once or load the persisted + * pair as appropriate. + */ +export function decideCertMigration( + certDir: string, + rootCaEnabled: boolean +): CertMigrationDecision { + if (rootCaEnabled) return "use-root-ca"; + + const hasLegacyLeaf = + fs.existsSync(path.join(certDir, "server.crt")) && + fs.existsSync(path.join(certDir, "server.key")); + const hasCaPair = + fs.existsSync(path.join(certDir, "ca.crt")) && fs.existsSync(path.join(certDir, "ca.key")); + + if (hasLegacyLeaf && !hasCaPair) return "use-legacy-leaf"; + + return "use-root-ca"; +} diff --git a/src/mitm/cert/rootCa.ts b/src/mitm/cert/rootCa.ts new file mode 100644 index 0000000000..aefa4ac014 --- /dev/null +++ b/src/mitm/cert/rootCa.ts @@ -0,0 +1,73 @@ +import path from "path"; +import fs from "fs"; +import { resolveMitmDataDir } from "../dataDir.ts"; +import { generateMitmCa, type CaPair } from "../tproxy/dynamicCert.ts"; + +// #6684: persisted local root CA for the AgentBridge static server, so it can +// issue a per-host leaf for every host in `MITM_TOOL_HOSTS` (not just the 4 +// antigravity hosts the legacy single self-signed leaf covers) without +// re-prompting the OS trust store on every restart. Reuses the exact +// `generateMitmCa()` crypto already proven for the TPROXY capture mode +// (`../tproxy/dynamicCert.ts`) — this module only adds the disk-persistence +// layer (load-if-present, generate-once-otherwise, restrictive file mode on +// the private key). + +export interface MitmCaPair extends CaPair { + keyPath: string; + certPath: string; +} + +const CA_KEY_FILE = "ca.key"; +const CA_CERT_FILE = "ca.crt"; + +/** Directory the CA key/cert pair (and legacy leaf) live under. */ +export function resolveMitmCertDir(): string { + return path.join(resolveMitmDataDir(), "mitm"); +} + +function caPaths(certDir: string): { keyPath: string; certPath: string } { + return { + keyPath: path.join(certDir, CA_KEY_FILE), + certPath: path.join(certDir, CA_CERT_FILE), + }; +} + +/** + * Load the persisted MITM root CA from disk if both `ca.key`/`ca.crt` exist; + * otherwise generate a fresh CA (via `generateMitmCa()`) and persist it. The + * private key file is chmod'd `0o600` (owner read/write only) immediately + * after writing — it must never be group/world-readable. + * + * Idempotent across restarts: once written, a later call returns the exact + * same key/cert bytes without regenerating, so the OS trust-store install + * only has to happen once per machine. + */ +export async function loadOrCreateMitmCa( + certDir: string = resolveMitmCertDir() +): Promise { + const { keyPath, certPath } = caPaths(certDir); + + if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { + return { + key: fs.readFileSync(keyPath, "utf-8"), + cert: fs.readFileSync(certPath, "utf-8"), + keyPath, + certPath, + }; + } + + const ca = await generateMitmCa(); + + if (!fs.existsSync(certDir)) { + fs.mkdirSync(certDir, { recursive: true }); + } + + fs.writeFileSync(keyPath, ca.key); + fs.writeFileSync(certPath, ca.cert); + // Owner-only read/write — the CA private key must never be group/world- + // readable (it can sign a trusted leaf for any host). No-op on Windows, + // which does not honor POSIX chmod bits. + fs.chmodSync(keyPath, 0o600); + + return { key: ca.key, cert: ca.cert, keyPath, certPath }; +} diff --git a/src/mitm/manager.ts b/src/mitm/manager.ts index 94fcf33b69..cbfc8f9f85 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -5,7 +5,9 @@ import { resolveMitmDataDir } from "./dataDir.ts"; import { removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts"; import { provisionDnsEntries } from "./dns/provision.ts"; import { generateCert } from "./cert/generate.ts"; -import { installCertResult } from "./cert/install.ts"; +import { installCertResult, installCaCert } from "./cert/install.ts"; +import { loadOrCreateMitmCa, resolveMitmCertDir } from "./cert/rootCa.ts"; +import { decideCertMigration } from "./cert/migration.ts"; import { ALL_TARGETS } from "./targets/index.ts"; import { detectAgent } from "./detection/index.ts"; import type { AgentId, DetectionResult, MitmTarget } from "./types.ts"; @@ -478,14 +480,36 @@ async function startMitmInternal( ); } - // 1. Generate SSL certificate if not exists - const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt"); - if (!fs.existsSync(certPath)) { - log.info("Generating SSL certificate..."); + // 1. Generate (or load the persisted) certificate material. #6684: a + // pre-existing trusted legacy leaf keeps this run on the legacy + // self-signed path (no silent trust-model upgrade — a MITM root CA that + // can sign a leaf for ANY host is materially more powerful than the old + // fixed-SAN leaf); fresh installs, and installs with `MITM_ROOT_CA_ENABLED + // =true`, get the persisted root-CA + per-host-leaf model instead + // (`cert/rootCa.ts`, reusing the CA/leaf crypto proven for TPROXY in + // `tproxy/dynamicCert.ts`). + const certDir = resolveMitmCertDir(); + const rootCaEnabled = process.env.MITM_ROOT_CA_ENABLED === "true"; + const migrationDecision = decideCertMigration(certDir, rootCaEnabled); + let certPath: string; + if (migrationDecision === "use-legacy-leaf") { + certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt"); + if (!fs.existsSync(certPath)) { + log.info("Generating SSL certificate..."); + try { + await generateCert(); + } catch (err) { + log.error({ err }, "Failed to generate SSL certificate"); + throw err; + } + } + } else { + log.info("Loading (or generating) persisted MITM root CA..."); try { - await generateCert(); + const ca = await loadOrCreateMitmCa(certDir); + certPath = ca.certPath; } catch (err) { - log.error({ err }, "Failed to generate SSL certificate"); + log.error({ err }, "Failed to load/generate MITM root CA"); throw err; } } @@ -496,7 +520,10 @@ async function startMitmInternal( // (mirrors the best-effort "continuing" pattern used for DNS below). (#4546) let certTrusted = false; try { - const certResult = await installCertResult(sudoPassword, certPath); + const certResult = + migrationDecision === "use-root-ca" + ? await installCaCert(sudoPassword, certPath) + : await installCertResult(sudoPassword, certPath); certTrusted = certResult.installed; if (!certResult.installed) { log.warn( @@ -549,6 +576,10 @@ async function startMitmInternal( ROUTER_API_KEY: apiKey, MITM_LOCAL_PORT: String(port), INSPECTOR_INTERNAL_INGEST_TOKEN: ingestToken, + // #6684: tell the spawned server.cjs which cert model this run resolved + // to (Step 1 above) so its own gate can't drift from manager.ts's + // migration decision. + MITM_CERT_MODE: migrationDecision === "use-root-ca" ? "root-ca" : "legacy", NODE_ENV: "production", }, detached: false, diff --git a/src/mitm/server.cjs b/src/mitm/server.cjs index ac8a27d83c..443788d7d2 100644 --- a/src/mitm/server.cjs +++ b/src/mitm/server.cjs @@ -235,10 +235,41 @@ function writeStats() { } } -const sslOptions = { - key: fs.readFileSync(path.join(certDir, "server.key")), - cert: fs.readFileSync(path.join(certDir, "server.crt")), -}; +// #6684: root-CA + per-host leaf certs, gated by MITM_CERT_MODE (set by +// manager.ts from its own migration-gate decision, `cert/migration.ts`) so +// server.cjs never drifts from what manager.ts installed/trusted. Default +// (unset/"legacy") reproduces the exact prior behavior — a single static +// self-signed leaf read synchronously at module scope — so existing installs +// are never silently upgraded to the new trust model. +const CERT_MODE = process.env.MITM_CERT_MODE === "root-ca" ? "root-ca" : "legacy"; + +function loadLegacySslOptions() { + return { + key: fs.readFileSync(path.join(certDir, "server.key")), + cert: fs.readFileSync(path.join(certDir, "server.crt")), + }; +} + +// The root-CA path needs an SNICallback fed by the per-host leaf issuer in +// `_internal/rootCaShim.cjs` (a CJS twin of `cert/rootCa.ts` + +// `tproxy/dynamicCert.ts` — see that file's header for why it's duplicated +// rather than imported). Resolved once during async bootstrap below. +async function loadRootCaSslOptions() { + const { loadOrCreateMitmCa, issueLeafCert, DynamicCertStore } = require("./_internal/rootCaShim.cjs"); + const ca = await loadOrCreateMitmCa(certDir); + const certStore = new DynamicCertStore({ key: ca.key, cert: ca.cert }); + const defaultHost = [...TARGET_HOSTS][0]; + // Node's TLS server needs a default key/cert even with SNICallback (used + // only when a client sends no SNI at all) — warm the cache for the default + // host at the same time so the very first request for it is instant. + const defaultLeaf = await issueLeafCert(defaultHost, { key: ca.key, cert: ca.cert }); + await certStore.getSecureContext(defaultHost); + return { + key: defaultLeaf.key, + cert: defaultLeaf.cert, + SNICallback: certStore.createSNICallback(), + }; +} // Log directory for request/response dumps const LOG_DIR = path.join(__dirname, "../../logs/mitm"); @@ -552,235 +583,253 @@ async function intercept(req, res, bodyBuffer, override, sourceModel) { } } -const server = https.createServer(sslOptions, async (req, res) => { - stats.totalRequests++; - stats.lastRequestAt = new Date().toISOString(); - writeStats(); +// #6684: server creation (and everything below that closes over `server`) is +// wrapped in an async bootstrap because the root-CA path (`loadRootCaSslOptions`) +// needs to load/generate CA material before the TLS server can be created. +// server.cjs is spawned as a standalone child process (not `require()`d), so +// nothing needs its top-level exports to resolve synchronously — deferring +// server creation into this async function is safe. In "legacy" mode +// (default) `loadLegacySslOptions()` resolves synchronously, so startup +// timing for existing installs is unchanged. +async function startMitmServer() { + const sslOptions = + CERT_MODE === "root-ca" ? await loadRootCaSslOptions() : loadLegacySslOptions(); - const bodyBuffer = await collectBodyRaw(req); - const host = String(req.headers.host || "") - .split(":")[0] - .toLowerCase(); - const model = bodyBuffer.length > 0 ? extractModel(bodyBuffer) : null; - - vlog( - 1, - `[MITM] ${req.method} ${host}${req.url} | body: ${bodyBuffer.length}B | model: ${model || "N/A"}` - ); - - if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer); - - if (req.headers["x-omniroute-source"] === "omniroute") { - vlog(1, `[MITM] → PASSTHROUGH (OmniRoute source loop)`); - return passthrough(req, res, bodyBuffer); - } - - if (!TARGET_HOSTS.has(host)) { - vlog(1, `[MITM] → PASSTHROUGH (host ${host} not in target list)`); - return passthrough(req, res, bodyBuffer); - } - - const agentId = TARGET_HOST_AGENT.get(host) || "antigravity"; - const routeConfig = standaloneRoutingShim.getAgentRouteConfig(agentId); - const isChatRequest = routeConfig.chatUrlPatterns.some((p) => req.url.includes(p)); - - if (!isChatRequest) { - vlog(1, `[MITM] → PASSTHROUGH (URL ${req.url} does not match chat patterns)`); - return passthrough(req, res, bodyBuffer); - } - - const mappedOverride = getMappedOverride(model, agentId); - - if (!mappedOverride) { - vlog(1, `[MITM] → PASSTHROUGH (model "${model}" has no MITM alias mapping)`); - return passthrough(req, res, bodyBuffer); - } - - stats.interceptedRequests++; - stats.lastInterceptAt = new Date().toISOString(); - writeStats(); - - vlog( - 1, - `[MITM] INTERCEPTED ${agentId} ${model} → ${mappedOverride.model || model}` + - (mappedOverride.reasoningEffort ? ` (reasoningEffort=${mappedOverride.reasoningEffort})` : "") - ); - return intercept(req, res, bodyBuffer, mappedOverride, model); -}); - -// ========================================================================= -// C1 — CONNECT handler: bypass + passthrough TCP support (plan 11 §4.6). -// -// Clients (browsers, IDE agents acting as HTTP proxy clients) send a -// CONNECT request before opening a TLS tunnel. The original `https.Server` -// has no built-in CONNECT handler because it expects connections to come -// pre-routed (typically via /etc/hosts DNS spoofing). For AgentBridge we -// also accept clients configured with HTTPS_PROXY/HTTP_PROXY, where every -// HTTPS request arrives as CONNECT. For those: -// -// - bypass hostname → raw TCP pipe upstream, NO TLS decrypt, NO log of -// body or headers. Privacy contract: bypass = "never see content". -// - passthrough (host not in TARGET_HOSTS, no bypass match) → raw TCP -// pipe upstream so the user's system never loses internet for hosts -// outside our scope. Acceptance criterion §12 #16. -// - target hostname → write 200 Connection Established and pipe the -// client socket into the local TLS-terminating port so the existing -// https.createServer can decrypt and route via the normal flow. -// -// Note: in the DNS-spoof mode (IDE points at 127.0.0.1 via /etc/hosts), -// IDEs reach the server directly without CONNECT; the existing -// `https.createServer` request handler still applies for those. The -// CONNECT handler only fires for clients that explicitly speak proxy. -// ========================================================================= - -function parseConnectAuthority(authority) { - // CONNECT host[:port] - const idx = authority.lastIndexOf(":"); - if (idx === -1) return { host: authority.toLowerCase(), port: 443 }; - const host = authority.slice(0, idx).toLowerCase(); - const port = Number.parseInt(authority.slice(idx + 1), 10); - return { - host, - port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : 443, - }; -} - -function rawTcpForward(clientSocket, head, host, port, label) { - const targetSocket = net.connect(port, host, () => { - clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - if (head && head.length > 0) targetSocket.write(head); - targetSocket.pipe(clientSocket); - clientSocket.pipe(targetSocket); - // Reap a half-open/hung tunnel after the idle timeout so neither side leaks - // an fd when the upstream never sends FIN/RST (Gap 10). - const destroyBoth = () => { - clientSocket.destroy(); - targetSocket.destroy(); - }; - clientSocket.setTimeout(MITM_IDLE_TIMEOUT_MS, destroyBoth); - targetSocket.setTimeout(MITM_IDLE_TIMEOUT_MS, destroyBoth); - }); - - // Best-effort cleanup; never crash the proxy on tunnel errors. - const onErr = (label2) => (err) => { - console.error(`[MITM] ${label} TCP forward ${label2} error: ${err.message}`); - try { - clientSocket.destroy(); - } catch { - // ignore - } - try { - targetSocket.destroy(); - } catch { - // ignore - } - }; - targetSocket.on("error", onErr("upstream")); - clientSocket.on("error", onErr("client")); - clientSocket.on("close", () => { - try { - targetSocket.destroy(); - } catch { - // ignore - } - }); - targetSocket.on("close", () => { - try { - clientSocket.destroy(); - } catch { - // ignore - } - }); -} - -// CONNECT handler — scope note (plan 11 §4.6): -// -// This fires ONLY when a client uses this server as an explicit HTTPS proxy and -// sends a `CONNECT host:port` line *inside* an already-established TLS session -// (HTTPS-proxy-tunneled-in-TLS). The primary "no config required" AgentBridge -// flow does NOT use it: there the IDE is pointed at 127.0.0.1 via /etc/hosts DNS -// spoofing and opens TLS DIRECTLY, so requests are routed by the decrypted Host -// header in the request handler above (target → intercept, otherwise passthrough). -// Likewise, bypass/passthrough for *unmapped* hosts in the DNS-spoof model is -// handled by DNS scoping (only spoofed hosts ever resolve to 127.0.0.1), and the -// System-wide proxy mode (plan 12 §2.5.4) routes through httpProxyServer.ts (:8080), -// which has its own CONNECT handling. This handler is retained for the explicit- -// proxy edge case and to honor the routeBypass precedence (bypass > target > -// passthrough); true on-wire bypass-without-decrypt at :443 under direct TLS would -// require SNI sniffing on the raw 'connection' event, which is intentionally out -// of scope for this release. -server.on("connect", (req, clientSocket, head) => { - const authority = String(req.url || ""); - const { host: connectHost, port: connectPort } = parseConnectAuthority(authority); - - const decision = routeBypass(connectHost); - - if (decision === "bypass") { - // Privacy: bypass hosts are never logged with body/headers and never - // TLS-decrypted. Only the hostname appears in console output. - vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → BYPASS (TCP tunnel)`); - rawTcpForward(clientSocket, head, connectHost, connectPort, "bypass"); - return; - } - - if (decision === "target") { - // Hand the tunnel off to the local TLS-terminating server so the existing - // https.createServer request handler can decrypt and route. We write the - // 200 response ourselves and then `emit("connection")` so the TLS layer - // picks the socket up. - vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → TARGET (TLS terminate locally)`); - clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - if (head && head.length > 0) clientSocket.unshift(head); - server.emit("connection", clientSocket); - return; - } - - // decision === "passthrough" - vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → PASSTHROUGH (TCP tunnel)`); - rawTcpForward(clientSocket, head, connectHost, connectPort, "passthrough"); -}); - -// Bound full-request / header / keep-alive lifetimes so a slow or hung client -// cannot pin a connection indefinitely (Gap 10). -server.requestTimeout = MITM_IDLE_TIMEOUT_MS * 5; // hard cap on a full request -server.headersTimeout = MITM_IDLE_TIMEOUT_MS; // time allowed to send headers -server.keepAliveTimeout = MITM_IDLE_TIMEOUT_MS; // idle keep-alive window - -server.listen(LOCAL_PORT, () => { - stats.startedAt = new Date().toISOString(); - writeStats(); - console.log(`🚀 MITM ready on :${LOCAL_PORT} → ${ROUTER_URL}`); -}); - -server.on("connection", (socket) => { - // Guard against double-counting: a CONNECT "target" tunnel re-emits an - // already-counted socket into the TLS layer via emit("connection") above. - if (socket.__mitmCounted) return; - socket.__mitmCounted = true; - // Reap idle sockets so hung connections cannot exhaust fds (Gap 10). - socket.setTimeout(MITM_IDLE_TIMEOUT_MS, () => socket.destroy()); - stats.activeConnections++; - writeStats(); - socket.on("close", () => { - stats.activeConnections = Math.max(0, stats.activeConnections - 1); + const server = https.createServer(sslOptions, async (req, res) => { + stats.totalRequests++; + stats.lastRequestAt = new Date().toISOString(); writeStats(); - }); -}); -server.on("error", (error) => { - if (error.code === "EADDRINUSE") { - console.error(`❌ Port ${LOCAL_PORT} already in use`); - } else if (error.code === "EACCES") { - console.error(`❌ Permission denied for port ${LOCAL_PORT}`); - } else { - console.error(`❌ ${error.message}`); + const bodyBuffer = await collectBodyRaw(req); + const host = String(req.headers.host || "") + .split(":")[0] + .toLowerCase(); + const model = bodyBuffer.length > 0 ? extractModel(bodyBuffer) : null; + + vlog( + 1, + `[MITM] ${req.method} ${host}${req.url} | body: ${bodyBuffer.length}B | model: ${model || "N/A"}` + ); + + if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer); + + if (req.headers["x-omniroute-source"] === "omniroute") { + vlog(1, `[MITM] → PASSTHROUGH (OmniRoute source loop)`); + return passthrough(req, res, bodyBuffer); + } + + if (!TARGET_HOSTS.has(host)) { + vlog(1, `[MITM] → PASSTHROUGH (host ${host} not in target list)`); + return passthrough(req, res, bodyBuffer); + } + + const agentId = TARGET_HOST_AGENT.get(host) || "antigravity"; + const routeConfig = standaloneRoutingShim.getAgentRouteConfig(agentId); + const isChatRequest = routeConfig.chatUrlPatterns.some((p) => req.url.includes(p)); + + if (!isChatRequest) { + vlog(1, `[MITM] → PASSTHROUGH (URL ${req.url} does not match chat patterns)`); + return passthrough(req, res, bodyBuffer); + } + + const mappedOverride = getMappedOverride(model, agentId); + + if (!mappedOverride) { + vlog(1, `[MITM] → PASSTHROUGH (model "${model}" has no MITM alias mapping)`); + return passthrough(req, res, bodyBuffer); + } + + stats.interceptedRequests++; + stats.lastInterceptAt = new Date().toISOString(); + writeStats(); + + vlog( + 1, + `[MITM] INTERCEPTED ${agentId} ${model} → ${mappedOverride.model || model}` + + (mappedOverride.reasoningEffort ? ` (reasoningEffort=${mappedOverride.reasoningEffort})` : "") + ); + return intercept(req, res, bodyBuffer, mappedOverride, model); + }); + + // ========================================================================= + // C1 — CONNECT handler: bypass + passthrough TCP support (plan 11 §4.6). + // + // Clients (browsers, IDE agents acting as HTTP proxy clients) send a + // CONNECT request before opening a TLS tunnel. The original `https.Server` + // has no built-in CONNECT handler because it expects connections to come + // pre-routed (typically via /etc/hosts DNS spoofing). For AgentBridge we + // also accept clients configured with HTTPS_PROXY/HTTP_PROXY, where every + // HTTPS request arrives as CONNECT. For those: + // + // - bypass hostname → raw TCP pipe upstream, NO TLS decrypt, NO log of + // body or headers. Privacy contract: bypass = "never see content". + // - passthrough (host not in TARGET_HOSTS, no bypass match) → raw TCP + // pipe upstream so the user's system never loses internet for hosts + // outside our scope. Acceptance criterion §12 #16. + // - target hostname → write 200 Connection Established and pipe the + // client socket into the local TLS-terminating port so the existing + // https.createServer can decrypt and route via the normal flow. + // + // Note: in the DNS-spoof mode (IDE points at 127.0.0.1 via /etc/hosts), + // IDEs reach the server directly without CONNECT; the existing + // `https.createServer` request handler still applies for those. The + // CONNECT handler only fires for clients that explicitly speak proxy. + // ========================================================================= + + function parseConnectAuthority(authority) { + // CONNECT host[:port] + const idx = authority.lastIndexOf(":"); + if (idx === -1) return { host: authority.toLowerCase(), port: 443 }; + const host = authority.slice(0, idx).toLowerCase(); + const port = Number.parseInt(authority.slice(idx + 1), 10); + return { + host, + port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : 443, + }; } + + function rawTcpForward(clientSocket, head, host, port, label) { + const targetSocket = net.connect(port, host, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head && head.length > 0) targetSocket.write(head); + targetSocket.pipe(clientSocket); + clientSocket.pipe(targetSocket); + // Reap a half-open/hung tunnel after the idle timeout so neither side leaks + // an fd when the upstream never sends FIN/RST (Gap 10). + const destroyBoth = () => { + clientSocket.destroy(); + targetSocket.destroy(); + }; + clientSocket.setTimeout(MITM_IDLE_TIMEOUT_MS, destroyBoth); + targetSocket.setTimeout(MITM_IDLE_TIMEOUT_MS, destroyBoth); + }); + + // Best-effort cleanup; never crash the proxy on tunnel errors. + const onErr = (label2) => (err) => { + console.error(`[MITM] ${label} TCP forward ${label2} error: ${err.message}`); + try { + clientSocket.destroy(); + } catch { + // ignore + } + try { + targetSocket.destroy(); + } catch { + // ignore + } + }; + targetSocket.on("error", onErr("upstream")); + clientSocket.on("error", onErr("client")); + clientSocket.on("close", () => { + try { + targetSocket.destroy(); + } catch { + // ignore + } + }); + targetSocket.on("close", () => { + try { + clientSocket.destroy(); + } catch { + // ignore + } + }); + } + + // CONNECT handler — scope note (plan 11 §4.6): + // + // This fires ONLY when a client uses this server as an explicit HTTPS proxy and + // sends a `CONNECT host:port` line *inside* an already-established TLS session + // (HTTPS-proxy-tunneled-in-TLS). The primary "no config required" AgentBridge + // flow does NOT use it: there the IDE is pointed at 127.0.0.1 via /etc/hosts DNS + // spoofing and opens TLS DIRECTLY, so requests are routed by the decrypted Host + // header in the request handler above (target → intercept, otherwise passthrough). + // Likewise, bypass/passthrough for *unmapped* hosts in the DNS-spoof model is + // handled by DNS scoping (only spoofed hosts ever resolve to 127.0.0.1), and the + // System-wide proxy mode (plan 12 §2.5.4) routes through httpProxyServer.ts (:8080), + // which has its own CONNECT handling. This handler is retained for the explicit- + // proxy edge case and to honor the routeBypass precedence (bypass > target > + // passthrough); true on-wire bypass-without-decrypt at :443 under direct TLS would + // require SNI sniffing on the raw 'connection' event, which is intentionally out + // of scope for this release. + server.on("connect", (req, clientSocket, head) => { + const authority = String(req.url || ""); + const { host: connectHost, port: connectPort } = parseConnectAuthority(authority); + + const decision = routeBypass(connectHost); + + if (decision === "bypass") { + // Privacy: bypass hosts are never logged with body/headers and never + // TLS-decrypted. Only the hostname appears in console output. + vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → BYPASS (TCP tunnel)`); + rawTcpForward(clientSocket, head, connectHost, connectPort, "bypass"); + return; + } + + if (decision === "target") { + // Hand the tunnel off to the local TLS-terminating server so the existing + // https.createServer request handler can decrypt and route. We write the + // 200 response ourselves and then `emit("connection")` so the TLS layer + // picks the socket up. + vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → TARGET (TLS terminate locally)`); + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head && head.length > 0) clientSocket.unshift(head); + server.emit("connection", clientSocket); + return; + } + + // decision === "passthrough" + vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → PASSTHROUGH (TCP tunnel)`); + rawTcpForward(clientSocket, head, connectHost, connectPort, "passthrough"); + }); + + // Bound full-request / header / keep-alive lifetimes so a slow or hung client + // cannot pin a connection indefinitely (Gap 10). + server.requestTimeout = MITM_IDLE_TIMEOUT_MS * 5; // hard cap on a full request + server.headersTimeout = MITM_IDLE_TIMEOUT_MS; // time allowed to send headers + server.keepAliveTimeout = MITM_IDLE_TIMEOUT_MS; // idle keep-alive window + + server.listen(LOCAL_PORT, () => { + stats.startedAt = new Date().toISOString(); + writeStats(); + console.log(`🚀 MITM ready on :${LOCAL_PORT} → ${ROUTER_URL}`); + }); + + server.on("connection", (socket) => { + // Guard against double-counting: a CONNECT "target" tunnel re-emits an + // already-counted socket into the TLS layer via emit("connection") above. + if (socket.__mitmCounted) return; + socket.__mitmCounted = true; + // Reap idle sockets so hung connections cannot exhaust fds (Gap 10). + socket.setTimeout(MITM_IDLE_TIMEOUT_MS, () => socket.destroy()); + stats.activeConnections++; + writeStats(); + socket.on("close", () => { + stats.activeConnections = Math.max(0, stats.activeConnections - 1); + writeStats(); + }); + }); + + server.on("error", (error) => { + if (error.code === "EADDRINUSE") { + console.error(`❌ Port ${LOCAL_PORT} already in use`); + } else if (error.code === "EACCES") { + console.error(`❌ Permission denied for port ${LOCAL_PORT}`); + } else { + console.error(`❌ ${error.message}`); + } + process.exit(1); + }); + + process.on("SIGTERM", () => { + server.close(() => process.exit(0)); + }); + process.on("SIGINT", () => { + server.close(() => process.exit(0)); + }); +} + +startMitmServer().catch((err) => { + console.error(`❌ ${sanitizeErrorMessage(err && err.message)}`); process.exit(1); }); - -process.on("SIGTERM", () => { - server.close(() => process.exit(0)); -}); -process.on("SIGINT", () => { - server.close(() => process.exit(0)); -}); diff --git a/tests/unit/mitm-cert-migration-6684.test.ts b/tests/unit/mitm-cert-migration-6684.test.ts new file mode 100644 index 0000000000..111eef5369 --- /dev/null +++ b/tests/unit/mitm-cert-migration-6684.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { decideCertMigration } from "../../src/mitm/cert/migration.ts"; + +// #6684: migration gate between the legacy single self-signed leaf and the +// new persisted root-CA model. A trusted MITM CA that can sign a cert for +// ANY host is materially more powerful than the old fixed-SAN leaf, so an +// already-trusted install must never be silently upgraded. + +function tmpCertDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mitm-migration-test-")); +} + +function touch(filePath: string): void { + fs.writeFileSync(filePath, "test"); +} + +test("decideCertMigration: existing legacy leaf, no CA pair, flag off → stay on legacy leaf", () => { + const certDir = tmpCertDir(); + try { + touch(path.join(certDir, "server.crt")); + touch(path.join(certDir, "server.key")); + assert.equal(decideCertMigration(certDir, false), "use-legacy-leaf"); + } finally { + fs.rmSync(certDir, { recursive: true, force: true }); + } +}); + +test("decideCertMigration: no legacy leaf and no CA pair (fresh install) → use root CA", () => { + const certDir = tmpCertDir(); + try { + assert.equal(decideCertMigration(certDir, false), "use-root-ca"); + } finally { + fs.rmSync(certDir, { recursive: true, force: true }); + } +}); + +test("decideCertMigration: legacy leaf present but explicit opt-in flag on → use root CA", () => { + const certDir = tmpCertDir(); + try { + touch(path.join(certDir, "server.crt")); + touch(path.join(certDir, "server.key")); + assert.equal(decideCertMigration(certDir, true), "use-root-ca"); + } finally { + fs.rmSync(certDir, { recursive: true, force: true }); + } +}); + +test("decideCertMigration: CA pair already persisted → use root CA even without the flag", () => { + const certDir = tmpCertDir(); + try { + touch(path.join(certDir, "server.crt")); + touch(path.join(certDir, "server.key")); + touch(path.join(certDir, "ca.crt")); + touch(path.join(certDir, "ca.key")); + assert.equal(decideCertMigration(certDir, false), "use-root-ca"); + } finally { + fs.rmSync(certDir, { recursive: true, force: true }); + } +}); + +test("decideCertMigration: partial legacy pair (only server.crt) is treated as no legacy install", () => { + const certDir = tmpCertDir(); + try { + touch(path.join(certDir, "server.crt")); + assert.equal(decideCertMigration(certDir, false), "use-root-ca"); + } finally { + fs.rmSync(certDir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/mitm-root-ca-leaf-issuance-6684.test.ts b/tests/unit/mitm-root-ca-leaf-issuance-6684.test.ts new file mode 100644 index 0000000000..568f535eaa --- /dev/null +++ b/tests/unit/mitm-root-ca-leaf-issuance-6684.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { X509Certificate } from "node:crypto"; + +import { generateMitmCa, issueLeafCert, DynamicCertStore } from "../../src/mitm/tproxy/dynamicCert.ts"; +import { MITM_TOOL_HOSTS } from "../../src/shared/constants/mitmToolHosts.ts"; + +// #6684: leaf issuance for the full MITM_TOOL_HOSTS host set, reusing the CA +// module already proven for TPROXY (`tproxy/dynamicCert.ts`) — issuing per +// host is what lets the AgentBridge static server cover every tool host +// (previously only the 4 antigravity hosts had a matching SAN). + +function allHosts(): string[] { + const set = new Set(); + for (const hosts of Object.values(MITM_TOOL_HOSTS)) { + for (const h of hosts) set.add(h); + } + return [...set]; +} + +test("issueLeafCert: every MITM_TOOL_HOSTS host resolves a SecureContext without throwing", async () => { + const ca = await generateMitmCa("Test MITM CA"); + const store = new DynamicCertStore("Test MITM CA", ca); + for (const host of allHosts()) { + const ctx = await store.getSecureContext(host); + assert.ok(ctx, `expected a SecureContext for ${host}`); + } +}); + +test("issueLeafCert: issued leaf SAN matches the requested hostname", async () => { + const ca = await generateMitmCa("Test MITM CA"); + for (const host of allHosts()) { + const leaf = await issueLeafCert(host, ca); + const leafPem = leaf.cert.split(/(?=-----BEGIN CERTIFICATE-----)/)[0]; + const cert = new X509Certificate(leafPem); + assert.ok( + cert.subjectAltName && cert.subjectAltName.includes(host), + `expected SAN to include ${host}, got ${cert.subjectAltName}` + ); + } +}); + +test("issueLeafCert: the leaf chain validates against the CA cert", async () => { + const ca = await generateMitmCa("Test MITM CA"); + const host = "chain-check.example.com"; + const leaf = await issueLeafCert(host, ca); + const leafPem = leaf.cert.split(/(?=-----BEGIN CERTIFICATE-----)/)[0]; + const leafCert = new X509Certificate(leafPem); + const caCert = new X509Certificate(ca.cert); + assert.equal(leafCert.checkIssued(caCert), true); + assert.equal(leafCert.verify(caCert.publicKey), true); +}); + +test("DynamicCertStore: repeated getSecureContext for the same host is cached (no re-issuance)", async () => { + const ca = await generateMitmCa("Test MITM CA"); + const store = new DynamicCertStore("Test MITM CA", ca); + const host = "cached-host.example.com"; + const first = await store.getSecureContext(host); + const second = await store.getSecureContext(host); + assert.equal(first, second, "expected the identical cached SecureContext instance"); + assert.equal(store.size, 1); +}); + +test("drift guard: a host added to mitmToolHosts.ts needs no matching entry in generate.ts/rootCa.ts", async () => { + // SNICallback issues a leaf on demand for any hostname — there is no + // per-host allowlist to keep in sync, unlike the legacy static leaf whose + // single SAN list was scoped to ANTIGRAVITY_TARGET.hosts (#6494). + const ca = await generateMitmCa("Test MITM CA"); + const store = new DynamicCertStore("Test MITM CA", ca); + const neverRegisteredHost = "some-brand-new-tool-host.example.com"; + assert.ok(!allHosts().includes(neverRegisteredHost)); + const ctx = await store.getSecureContext(neverRegisteredHost); + assert.ok(ctx); +}); diff --git a/tests/unit/mitm-root-ca-persistence-6684.test.ts b/tests/unit/mitm-root-ca-persistence-6684.test.ts new file mode 100644 index 0000000000..63d08a91c3 --- /dev/null +++ b/tests/unit/mitm-root-ca-persistence-6684.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { loadOrCreateMitmCa } from "../../src/mitm/cert/rootCa.ts"; + +// #6684: root-CA persistence — the OS trust prompt must only fire once per +// machine, so `loadOrCreateMitmCa()` must generate on first run and load +// (byte-identical) on every subsequent run, with a restrictively-permissioned +// private key file. + +function tmpCertDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mitm-root-ca-test-")); +} + +test("loadOrCreateMitmCa: first call with an empty dir generates and persists a CA", async () => { + const certDir = tmpCertDir(); + try { + assert.equal(fs.existsSync(path.join(certDir, "ca.key")), false); + const ca = await loadOrCreateMitmCa(certDir); + assert.ok(ca.key.includes("PRIVATE KEY")); + assert.ok(ca.cert.includes("CERTIFICATE")); + assert.equal(fs.existsSync(path.join(certDir, "ca.key")), true); + assert.equal(fs.existsSync(path.join(certDir, "ca.crt")), true); + } finally { + fs.rmSync(certDir, { recursive: true, force: true }); + } +}); + +test("loadOrCreateMitmCa: a second call loads the same CA instead of regenerating", async () => { + const certDir = tmpCertDir(); + try { + const first = await loadOrCreateMitmCa(certDir); + const second = await loadOrCreateMitmCa(certDir); + assert.equal(second.key, first.key); + assert.equal(second.cert, first.cert); + } finally { + fs.rmSync(certDir, { recursive: true, force: true }); + } +}); + +test("loadOrCreateMitmCa: the written CA private key file mode is 0o600", { skip: process.platform === "win32" }, async () => { + const certDir = tmpCertDir(); + try { + const ca = await loadOrCreateMitmCa(certDir); + const mode = fs.statSync(ca.keyPath).mode & 0o777; + assert.equal(mode, 0o600); + } finally { + fs.rmSync(certDir, { recursive: true, force: true }); + } +}); + +test("loadOrCreateMitmCa: the CA cert carries CA basicConstraints (matches generateMitmCa)", async () => { + const certDir = tmpCertDir(); + try { + const ca = await loadOrCreateMitmCa(certDir); + const { X509Certificate } = await import("node:crypto"); + const cert = new X509Certificate(ca.cert); + assert.equal(cert.ca, true); + } finally { + fs.rmSync(certDir, { recursive: true, force: true }); + } +});