diff --git a/docs/configuration.md b/docs/configuration.md index 780706a255..4991d9c693 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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). diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index da99ac3aff..f1d6452666 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -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 信任。 diff --git a/src/ca.ts b/src/ca.ts index a84418783c..b7e9ef287c 100644 --- a/src/ca.ts +++ b/src/ca.ts @@ -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. @@ -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 ); 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(); const add = (pem: string): void => { @@ -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) { diff --git a/tests/ca-combined.test.ts b/tests/ca-combined.test.ts index ade99d898c..3c629f6a57 100644 --- a/tests/ca-combined.test.ts +++ b/tests/ca-combined.test.ts @@ -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"; @@ -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> = []; + if (spec.basicConstraints !== undefined) exts.push({ name: "basicConstraints", cA: spec.basicConstraints, critical: true }); + const ku: Record = {}; + 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"); +});