Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/features/6684-mitm-root-ca.md
Original file line number Diff line number Diff line change
@@ -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).
16 changes: 16 additions & 0 deletions docs/frameworks/AGENTBRIDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
23 changes: 20 additions & 3 deletions docs/security/MITM-TPROXY-DECRYPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<DATA_DIR>/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`)

Expand Down
108 changes: 108 additions & 0 deletions src/mitm/_internal/rootCaShim.cjs
Original file line number Diff line number Diff line change
@@ -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 `<DATA_DIR>/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 };
18 changes: 18 additions & 0 deletions src/mitm/cert/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CertInstallResult> {
return installCertResult(sudoPassword, caCertPath);
}

async function installCertMac(sudoPassword: string, certPath: string): Promise<void> {
try {
await execFileWithPassword(
Expand Down
43 changes: 43 additions & 0 deletions src/mitm/cert/migration.ts
Original file line number Diff line number Diff line change
@@ -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";
}
73 changes: 73 additions & 0 deletions src/mitm/cert/rootCa.ts
Original file line number Diff line number Diff line change
@@ -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<MitmCaPair> {
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 };
}
47 changes: 39 additions & 8 deletions src/mitm/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading