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
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2411,7 +2411,7 @@ How each client is pointed at the proxy (set automatically in the child env):
| hermes | `HTTPS_PROXY` (plain-http rides absolute-form forward-proxy requests) | `SSL_CERT_FILE` → `combined-ca.pem` (+ legacy `HERMES_CA_BUNDLE` → `root-ca.pem`) |
| dsh | `HTTPS_PROXY` (+ `HTTP_PROXY` for plain-http) + `DEEPSEEK_BASE_URL`; **loopback-only** isolated `DSH_HOME` | `SSL_CERT_FILE` → `combined-ca.pem` |

`NODE_EXTRA_CA_CERTS` *appends* to the built-in trust store, so it points at the MITM root alone (`root-ca.pem`). `SSL_CERT_FILE` *replaces* the default CA bundle, so for codex/dsh/hermes it points at `combined-ca.pem` — a bundle containing the MITM root **plus** the OS trust store and the Node public roots. The OS-store merge matters because some launched clients treat this file as their *entire* trust pool (codex's rustls HTTP stack replaces rather than appends, #1807): any direct, non-proxied connection of theirs must validate against this file alone, so the file must be a superset of what the OS itself trusts (Windows cert stores via PowerShell, macOS keychains via `security(1)`; Linux already reads the filesystem bundle; rebuilt at most once per 24 h). Keeping pip/git/curl style TLS (blind-tunnelled, real certificates) working inside the child env is the original reason for the bundle (#152; hermes since #1375, because current hermes resolves ambient trust only through `SSL_CERT_FILE`; OS-store merge since #1807).
`NODE_EXTRA_CA_CERTS` *appends* to the built-in trust store, so it points at the MITM root alone (`root-ca.pem`). `SSL_CERT_FILE` *replaces* the default CA bundle, so for codex/dsh/hermes it points at `combined-ca.pem` — a bundle containing the MITM root **plus** the OS trust store and the Node public roots, narrowed to CA certificates only (#5802: darwin's `security(1)` keychain export includes non-CA leaf identities such as `com.apple.kerberos.kdc`, and strict replace-semantics clients like codex's rustls stack reject the whole file when any entry fails to parse). The OS-store merge matters because some launched clients treat this file as their *entire* trust pool (codex's rustls HTTP stack replaces rather than appends, #1807): any direct, non-proxied connection of theirs must validate against this file alone, so the file must be a superset of what the OS itself trusts (Windows cert stores via PowerShell, macOS keychains via `security(1)`; Linux already reads the filesystem bundle; rebuilt at most once per 24 h). Keeping pip/git/curl style TLS (blind-tunnelled, real certificates) working inside the child env is the original reason for the bundle (#152; hermes since #1375, because current hermes resolves ambient trust only through `SSL_CERT_FILE`; OS-store merge since #1807).

Claude Code is routed through the `/bili/` URL form via its own base-URL variables rather than cert MITM — every upstream, including a pre-configured `ANTHROPIC_BASE_URL` relay and the Vertex endpoint under `CLAUDE_CODE_USE_VERTEX=1`; no CA trust is required. Older builds ignored proxy env vars outright, but newer ones (e.g. 2.1.301) honour `HTTPS_PROXY`, so the launcher merges the loopback hosts into `NO_PROXY`/`no_proxy` (preserving any value already set) to keep the `/bili/` leg direct regardless of which behavior the child has (#3446).

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2378,7 +2378,7 @@ MITM 只对一份**白名单**中的模型域名生效(`open.bigmodel.cn`、`a
| hermes | `HTTPS_PROXY`(明文 http 走 absolute-form 正向代理请求) | `SSL_CERT_FILE` → `combined-ca.pem`(另设旧版 `HERMES_CA_BUNDLE` → `root-ca.pem`) |
| dsh | `HTTPS_PROXY`(明文 http 另加 `HTTP_PROXY`)+ `DEEPSEEK_BASE_URL`;**仅回环**隔离 `DSH_HOME` | `SSL_CERT_FILE` → `combined-ca.pem` |

`NODE_EXTRA_CA_CERTS` 是**追加**到内置信任库,所以只指向 MITM 根证书(`root-ca.pem`)即可。`SSL_CERT_FILE` 会**替换**默认 CA bundle,所以 codex/dsh/hermes 指向 `combined-ca.pem` —— 包含 MITM 根证书**加上**操作系统信任库与 Node 公共根。合并 OS 信任库很关键:部分被启动的客户端把这个文件当作**整个**信任池(codex 的 rustls HTTP 栈是替换而非追加,#1807),它们的直连(不经代理)连接只能靠这个文件校验,因此文件必须是 OS 自身信任集合的超集(Windows 经 PowerShell 读证书库、macOS 经 `security(1)` 读钥匙串;Linux 本就直读文件系统 bundle;最多每 24 小时重建一次)。最初引入该 bundle 是为了让子进程环境里 pip/git/curl 类 TLS(盲转发、真证书)不受影响(#152;hermes 自 #1375 起,因为当前 hermes 只经 `SSL_CERT_FILE` 解析环境信任;OS 信任库合并自 #1807 起)。
`NODE_EXTRA_CA_CERTS` 是**追加**到内置信任库,所以只指向 MITM 根证书(`root-ca.pem`)即可。`SSL_CERT_FILE` 会**替换**默认 CA bundle,所以 codex/dsh/hermes 指向 `combined-ca.pem` —— 包含 MITM 根证书**加上**操作系统信任库与 Node 公共根,且只保留 CA 证书(#5802:darwin 的 `security(1)` 钥匙串导出会混入非 CA 叶子身份证书(如 `com.apple.kerberos.kdc`),codex 这类替换语义客户端只要有一个条目解析失败就拒绝整份文件)。合并 OS 信任库很关键:部分被启动的客户端把这个文件当作**整个**信任池(codex 的 rustls HTTP 栈是替换而非追加,#1807),它们的直连(不经代理)连接只能靠这个文件校验,因此文件必须是 OS 自身信任集合的超集(Windows 经 PowerShell 读证书库、macOS 经 `security(1)` 读钥匙串;Linux 本就直读文件系统 bundle;最多每 24 小时重建一次)。最初引入该 bundle 是为了让子进程环境里 pip/git/curl 类 TLS(盲转发、真证书)不受影响(#152;hermes 自 #1375 起,因为当前 hermes 只经 `SSL_CERT_FILE` 解析环境信任;OS 信任库合并自 #1807 起)。

Claude Code 的 undici fetch 忽略 `HTTPS_PROXY`,所以证书 MITM 拦不到它。claude 的所有上游 —— 包括预先配置的 `ANTHROPIC_BASE_URL` relay,以及 `CLAUDE_CODE_USE_VERTEX=1` 下的 Vertex 端点 —— 一律改走 `/bili/` URL 形式,通过客户端自己的 base URL 变量下发;无需任何 CA 信任。

Expand Down
74 changes: 69 additions & 5 deletions src/ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { log as loggerLog } from "./logger.js";
const ROOT_CERT_FILE = "root-ca.pem";
const ROOT_KEY_FILE = "root-ca-key.pem";
const COMBINED_CA_FILE = "combined-ca.pem";
// #5802: format marker line at the top of the bundle. A FRESH file lacking it
// predates the CA-only merge policy and gets rebuilt once after upgrade —
// without it a pre-fix bundle (carrying keychain leaf certs) would survive
// until its 24 h age expired and the fix would look inert for a day.
const BUNDLE_MARKER = "# billion-context combined-ca (ca-only v1, #5802)";
const ROOT_CN = "billion-context MITM Root CA";
// #1807: the combined bundle merges the OS trust store, which costs a
// PowerShell spawn on Windows — bound the rebuild to once per day per machine.
Expand Down Expand Up @@ -193,14 +198,62 @@ export function collectOsStorePems(platform: NodeJS.Platform = process.platform)
}
}

/** Rebuild combined-ca.pem unless a fresh copy (< 24h) already carries the
* current MITM root CA — the OS-store merge makes each rebuild expensive on
* Windows, so steady-state startups reuse the existing file. */
// #5802: RFC 5280 KeyUsage extension — OID 2.5.29.15. Per §4.1 the extension
// body is [BOOLEAN critical] OCTET STRING wrapping a BIT STRING
// (04 LL | 03 LL <unused-bits> <flags…>); the critical flag is OPTIONAL and
// skipped when present (RFC-recommended for CA certs, so most real-world
// anchors carry it). Logical bit 5 (keyCertSign) maps to mask 0x04 of the
// FIRST flag octet under any valid unused-bits count, so the test is
// independent of the encoder's padding choice. Scanned on the raw DER because
// Node's X509Certificate.keyUsage getter is unusable in current runtimes
// (returns undefined for every cert) and node-forge cannot parse non-RSA
// subjects — both would misjudge ECDSA CAs.
const KEY_USAGE_OID = Buffer.from([0x06, 0x03, 0x55, 0x1d, 0x0f]);
const KEY_CERT_SIGN_BIT = 0x04;

function hasKeyCertSign(raw: Buffer): boolean {
let i = raw.indexOf(KEY_USAGE_OID);
while (i >= 0) {
let b = i + KEY_USAGE_OID.length;
if (raw[b] === 0x01 && raw[b + 1] === 0x01) b += 3; // optional critical BOOLEAN
if (raw[b] === 0x04 && (raw[b + 1] === 0x04 || raw[b + 1] === 0x05)
&& raw[b + 2] === 0x03 && (raw[b + 3] === 0x02 || raw[b + 3] === 0x03)) {
return (raw[b + 5] & KEY_CERT_SIGN_BIT) !== 0;
}
i = raw.indexOf(KEY_USAGE_OID, i + 1);
}
return false;
}

/** #5802: keep a combined-bundle entry only if it can act as a trust anchor —
* basicConstraints CA:TRUE (X509Certificate.ca), or keyUsage Certificate
* Sign for anchors predating the extension. security(1) find-certificate -a
* exports EVERY keychain entry (Apple system identities, app/content-signing
* and S-MIME leaves alike), not just anchors; replacement-semantics clients
* (codex/rustls) parse every entry of SSL_CERT_FILE strictly, so one
* RFC-violating leaf (com.apple.kerberos.kdc, duplicate EKU) aborts their
* whole HTTP client. Unparseable entries anchor nothing and drop too. */
export function isCaLike(pem: string): boolean {
try {
const x = new crypto.X509Certificate(pem);
return x.ca || hasKeyCertSign(x.raw);
} catch {
return false;
}
}

/** Rebuild combined-ca.pem unless a fresh copy (< 24h) already carries both
* the current MITM root CA and the #5802 format marker — the OS-store merge
* makes each rebuild expensive on Windows, so steady-state startups reuse
* the existing file. */
function writeCombinedBundle(): void {
const file = path.join(caDir(), COMBINED_CA_FILE);
try {
const st = fs.statSync(file);
if (Date.now() - st.mtimeMs < COMBINED_MAX_AGE_MS && fs.readFileSync(file, "utf8").includes(rootCertPem!.trim())) return;
if (Date.now() - st.mtimeMs < COMBINED_MAX_AGE_MS) {
const existing = fs.readFileSync(file, "utf8");
if (existing.includes(rootCertPem!.trim()) && existing.includes(BUNDLE_MARKER)) return;
}
} catch { }
const byFingerprint = new Map<string, string>();
const add = (pem: string): void => {
Expand All @@ -216,7 +269,18 @@ function writeCombinedBundle(): void {
for (const pem of collectOsStorePems()) add(pem);
for (const pem of tls.rootCertificates) add(pem);
add(rootCertPem!);
const body = [...byFingerprint.values()].map((pem) => (pem.endsWith("\n") ? pem : pem + "\n")).join("");
// #5802: single choke point over ALL sources (keychain/PowerShell exports,
// filesystem bundle, Mozilla snapshot, user SSL_CERT_FILE) — the bundle
// must hold trust anchors only; see isCaLike.
let kept = [...byFingerprint.values()].filter(isCaLike);
if (kept.length === 0) {
// Availability floor: the MITM root always passes the filter, so an
// empty result means certificate parsing itself is broken — degrade
// to the pre-#5802 shape (loudly) instead of shipping an empty pool.
loggerLog("error", `[ca] CA-only filter dropped all ${byFingerprint.size} certificates including the MITM root — falling back to the unfiltered bundle; certificate parsing appears broken, report this`);
kept = [...byFingerprint.values()];
}
const body = BUNDLE_MARKER + "\n" + kept.map((pem) => (pem.endsWith("\n") ? pem : pem + "\n")).join("");
try {
fs.writeFileSync(file, body, { mode: 0o644 });
} catch (err) {
Expand Down
99 changes: 98 additions & 1 deletion tests/ca-combined.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import tls from "node:tls";
import test from "node:test";
import { combinedCaPath, collectOsStorePems, collectSystemCaPems, decodeOsStoreOutput, ensureRootCA, osStoreExecOptions, osStorePowerShellScript, parseOsStoreNdjson, pemFingerprint, rootCaPath, wrapDerPem } from "../src/ca.js";
import forge from "node-forge";
import { combinedCaPath, collectOsStorePems, collectSystemCaPems, decodeOsStoreOutput, ensureRootCA, isCaLike, osStoreExecOptions, osStorePowerShellScript, parseOsStoreNdjson, pemFingerprint, rootCaPath, wrapDerPem } from "../src/ca.js";
import { resolveCombinedCaPath } from "../src/launcher.js";
import { setLogCapture } from "../src/logger.ts";
import { rmrf } from "./tmp-rm.ts";
Expand Down Expand Up @@ -223,3 +225,98 @@ test("#5103: write-refused rebuild with NO usable bundle still fails fast", (t)
// leave the shared tmp home in a healthy state for any later test
ensureRootCA();
});

// #5802: security(1) find-certificate -a exports EVERY keychain entry (Apple
// system identities, app/content-signing and S-MIME leaves), and
// replacement-semantics clients (codex/rustls) parse every bundle entry
// strictly — one RFC-violating leaf (com.apple.kerberos.kdc, duplicate EKU)
// aborted codex's whole HTTP client. The combined bundle must hold anchors only.

function forgedPem(spec: { cn: string; basicConstraints?: boolean; keyCertSign?: boolean; digitalSignature?: boolean; keyEncipherment?: boolean; serverAuth?: boolean; kuCritical?: boolean }): string {
const keys = forge.pki.rsa.generateKeyPair({ bits: 512 });
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = "01";
const now = new Date();
cert.validity.notBefore = now;
cert.validity.notAfter = now;
cert.validity.notAfter.setFullYear(now.getFullYear() + 1);
cert.setSubject([{ name: "commonName", value: spec.cn }]);
cert.setIssuer(cert.subject.attributes);
const exts: Array<Record<string, unknown>> = [];
if (spec.basicConstraints !== undefined) exts.push({ name: "basicConstraints", cA: spec.basicConstraints, critical: true });
const ku: Record<string, boolean> = {};
if (spec.keyCertSign) ku.keyCertSign = true;
if (spec.digitalSignature) ku.digitalSignature = true;
if (spec.keyEncipherment) ku.keyEncipherment = true;
if (Object.keys(ku).length > 0) exts.push({ name: "keyUsage", ...ku, ...(spec.kuCritical ? { critical: true } : {}) });
if (spec.serverAuth) exts.push({ name: "extKeyUsage", names: ["serverAuth"] });
if (exts.length > 0) cert.setExtensions(exts);
cert.sign(keys.privateKey, forge.md.sha256.create());
return forge.pki.certificateToPem(cert);
}

test("#5802: isCaLike keeps anchors, drops serverAuth leaves and unparseable entries (both gate directions)", () => {
const realRoot = tls.rootCertificates[0];
assert.equal(new crypto.X509Certificate(realRoot).ca, true, "ground truth: public root IS a CA");
assert.ok(isCaLike(realRoot), "a real public root must pass");

const legacyAnchor = forgedPem({ cn: "legacy-anchor", keyCertSign: true });
assert.equal(new crypto.X509Certificate(legacyAnchor).ca, false, "ground truth: no basicConstraints extension");
assert.ok(isCaLike(legacyAnchor), "keyCertSign without basicConstraints still anchors chains (pre-BC CAs)");

// RFC 5280 recommends the critical marking for CA certs — the DER scan
// must skip the optional critical BOOLEAN between OID and extnValue.
const legacyAnchorCritical = forgedPem({ cn: "legacy-anchor-critical", keyCertSign: true, kuCritical: true });
assert.equal(new crypto.X509Certificate(legacyAnchorCritical).ca, false, "ground truth: no basicConstraints extension");
assert.ok(isCaLike(legacyAnchorCritical), "critical-marked keyCertSign still anchors chains (pre-BC CAs)");

// The com.apple.kerberos.kdc class: identity leaf with a serverAuth EKU, not an anchor.
const kdcLike = forgedPem({ cn: "com.example.kerberos.kdc", basicConstraints: false, digitalSignature: true, keyEncipherment: true, serverAuth: true });
assert.equal(new crypto.X509Certificate(kdcLike).ca, false, "ground truth: fixture leaf is NOT a CA");
assert.equal(isCaLike(kdcLike), false, "a serverAuth leaf must never enter the bundle");

// Non-RSA anchors: the predicate must judge ECDSA CAs correctly too
// (node-forge cannot parse them; a parser swap would silently drop these).
const ecRoots = tls.rootCertificates.filter((pem) => {
try { return new crypto.X509Certificate(pem).publicKey.asymmetricKeyType === "ec"; } catch { return false; }
});
assert.ok(ecRoots.length > 0, "fixture precondition: the Mozilla snapshot contains EC roots");
for (const pem of ecRoots.slice(0, 5)) assert.ok(isCaLike(pem), "an ECDSA public root must pass");

assert.equal(isCaLike("-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n"), false, "unparseable entries anchor nothing");
});

test("#5802: the written combined bundle contains ONLY parseable CAs and carries the marker", () => {
ensureRootCA();
const combined = fs.readFileSync(combinedCaPath(), "utf8");
assert.ok(combined.startsWith("# "), "rebuilt bundles carry the format marker line");
const blocks = combined.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----\s*/g) ?? [];
assert.ok(blocks.length > 10, `bundle should carry the public roots (found ${blocks.length})`);
for (const b of blocks) assert.ok(isCaLike(b), `bundle entry is not a CA:\n${b.slice(0, 120)}`);
assert.ok(combined.includes(fs.readFileSync(rootCaPath(), "utf8").trim()), "the MITM root survives the filter");
});

test("#5802: a pre-upgrade bundle (missing marker) is force-rebuilt once even while fresh", () => {
ensureRootCA();
const file = combinedCaPath();
const stripped = fs.readFileSync(file, "utf8").replace(/^# [^\n]*\n/, "");
assert.ok(!stripped.startsWith("# "), "fixture: marker line removed");
fs.writeFileSync(file, stripped);
const future = new Date(Date.now() + 3600e3);
fs.utimesSync(file, future, future);
ensureRootCA();
const after = fs.readFileSync(file, "utf8");
assert.ok(after.startsWith("# "), "missing marker forces the one-time convergence rebuild");
assert.ok(after.includes(fs.readFileSync(rootCaPath(), "utf8").trim()));
});

test("#5802: a fresh bundle WITH marker + root is not rewritten (steady state)", () => {
ensureRootCA();
const file = combinedCaPath();
const future = new Date(Date.now() + 3600e3);
fs.utimesSync(file, future, future);
const mtimeBefore = fs.statSync(file).mtimeMs;
ensureRootCA();
assert.equal(fs.statSync(file).mtimeMs, mtimeBefore, "marker + root + fresh = reused, not rebuilt");
});
Loading