Skip to content

Commit 8f46b89

Browse files
codeslakeclaude
andcommitted
fix(launcher): match the ca-trust guard to what node's CA loader accepts
The guard shipped in cnighswonger#283 disagreed with a real handshake on 8 of 20 measured bundle shapes (node v24.11.1 / openssl 3.6.1). Seven were needless refusals of healthy bundles. Every PEM block was parsed as a certificate, so any non-certificate block a merged bundle legitimately carries — a CRL, a public key, key material — threw and voided the whole file; and the torn-block check counted raw occurrences of "-----BEGIN ", so a provenance comment that merely mentioned the marker made a healthy bundle look torn. Refusing is not the safe direction: the fallback drops every sibling and corporate CA for that session, which is the failure this contract exists to prevent. The eighth was the dangerous direction. Our own CA relabelled TRUSTED CERTIFICATE parses to byte-identical DER, so the guard reported "carries our CA" while node's loader skipped the block entirely, leaving the session trusting nothing and failing every request with UNABLE_TO_VERIFY_LEAF_SIGNATURE. The guard's own comment promised it was "allowed to be conservative, never permissive" — this was permissive. Markers are now anchored to line starts, non-CERTIFICATE blocks are skipped the way node skips them, and the DER match must land on a CERTIFICATE block. Where the guard cannot tell — a block damaged AFTER ours, whose truncated body may or may not still decode, since openssl's base64 reader treats the next '-' as end-of-data rather than an error — it refuses. The decision moved to bin/ca-trust.mjs so the tests drive the shipped code. It was inline in a top-level script with a hand-copied twin in the test file under a "change one, change both" comment; measured, mutating the real one left the entire suite green. The test file's oracle was also wrong: it verified through tls.connect({ca}), which ACCEPTS the relabelled bundle that NODE_EXTRA_CA_CERTS rejects, so it was certifying the guard against a mechanism the launcher does not use. Three related defects in the same block: - The orphan reaper shared the publish try, so rename() throwing skipped it. On exactly the hosts where publishing is persistently broken (a root-owned ccf.pem, a read-only mount, ENOSPC) each launch abandoned one full-CA temp and collected none. - Our own CA was parsed inside the bundle try, so an unparseable ca.pem was reported as `ignoring <ca-trust.pem> (...)` — naming a file that may be healthy — and then fell back to the file that had just failed to parse. - The spawned proxy's `export NODE_EXTRA_CA_CERTS=<our ca.pem>` recipe was relayed to the operator immediately after the launcher had wired claude via ca-trust.d, telling them to undo it. The launcher now drops those lines from the stderr it relays; standalone the recipe carries a same-host-MITM caveat. Every clause is covered in both directions: five mutations of the guard and one of each fix above were each caught by exactly one test. prod +58 code / +101 comment, tests +199 (3.4x), 1 new file, 0 new env vars. Full suite 1501 pass / 2 fail, both EMFILE from an fs.watch test that fails identically at the merge base (inotify max_user_instances=128 on this host, unrelated to this change). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 23346ac commit 8f46b89

7 files changed

Lines changed: 414 additions & 127 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@
44

55
### Fixed
66

7-
- **`--remote-control` no longer clobbers another component's `NODE_EXTRA_CA_CERTS`.** That variable takes exactly one file, so on a host where something else also MITMs `api.anthropic.com` (a corporate agent, an account-pinning proxy) the last writer won and every other CA was silently untrusted — measured breaking Remote Control inbound. The launcher now publishes its own CA to `${CLAUDE_CONFIG_DIR:-~/.claude}/ca-trust.d/ccf.pem` (own filename only, never a sibling's, rewritten every launch, atomically via temp + `rename`) and reads a merged `ca-trust.pem` if one exists. It never writes the merged bundle: merging needs ambient corporate-root discovery, which is environment-specific and belongs outside this repo. The bundle is used only when every PEM block in it parses **and** one of them is our own CA (compared by DER) — a bundle that is torn or predates our publish is worse than none, since it makes the client distrust the very proxy it is routed through. On a host with no other MITM and no bundle, behavior is byte-identical to before. Both paths are fixed names under the config dir with no env override: they are two halves of one rendezvous, so a knob on either half alone would let a participant drop out of the contract while appearing to implement it. See [Coexisting with another MITM](README.md#coexisting-with-another-mitm-on-the-same-machine-ca-trustd).
7+
- **`--remote-control` no longer clobbers another component's `NODE_EXTRA_CA_CERTS`.** That variable takes exactly one file, so on a host where something else also MITMs `api.anthropic.com` (a corporate agent, an account-pinning proxy) the last writer won and every other CA was silently untrusted — measured breaking Remote Control inbound. The launcher now publishes its own CA to `${CLAUDE_CONFIG_DIR:-~/.claude}/ca-trust.d/ccf.pem` (own filename only, never a sibling's, rewritten every launch, atomically via temp + `rename`) and reads a merged `ca-trust.pem` if one exists. It never writes the merged bundle: merging needs ambient corporate-root discovery, which is environment-specific and belongs outside this repo. The bundle is used only when every PEM block in it is terminated **and** one of its `CERTIFICATE` blocks is our own CA (compared by DER) — a bundle that is torn or predates our publish is worse than none, since it makes the client distrust the very proxy it is routed through. On a host with no other MITM and no bundle, behavior is byte-identical to before. Both paths are fixed names under the config dir with no env override: they are two halves of one rendezvous, so a knob on either half alone would let a participant drop out of the contract while appearing to implement it. See [Coexisting with another MITM](README.md#coexisting-with-another-mitm-on-the-same-machine-ca-trustd).
8+
9+
- **The `ca-trust.pem` guard now matches what Node's CA loader actually accepts.** As shipped it disagreed with a real handshake on 8 of 20 measured bundle shapes (node v24.11.1 / openssl 3.6.1). Seven were needless refusals of healthy bundles — any non-certificate block (a CRL, a public key, key material), or a provenance comment that merely mentioned `-----BEGIN `, voided the whole file — and refusing is not the safe direction: it silently drops every sibling and corporate CA for that session, which is the failure this contract exists to prevent. The eighth was the dangerous direction: our own CA relabelled `TRUSTED CERTIFICATE` parses to byte-identical DER, so the guard reported "carries our CA" while Node's loader skipped the block entirely and every request failed TLS. The guard now anchors markers to line starts, skips non-`CERTIFICATE` blocks the way Node does, and requires the DER match on a `CERTIFICATE` block. Where it cannot tell (a block damaged *after* ours, whose truncated body may still decode) it refuses — conservative, never permissive. The decision moved to `bin/ca-trust.mjs` so the tests drive the shipped code: it was previously inline in a top-level script with a hand-copied twin in the test file, and mutating the real one left the entire suite green.
10+
11+
- **Orphaned publish temps are reaped even when publishing fails.** The reaper shared the publish `try`, so `rename` throwing skipped it — meaning that on exactly the hosts where publishing is persistently broken (a root-owned `ccf.pem`, a read-only mount, `ENOSPC`) each launch abandoned one full-CA temp and collected none, growing without bound in the directory a bundle builder globs.
12+
13+
- **A corrupt `ca.pem` no longer blames the merged bundle.** Parsing our own CA sat inside the bundle `try`, so an unparseable `ca.pem` was reported as `ignoring <ca-trust.pem> (...)` — naming a file that may be perfectly healthy — and then fell back to the very file that had just failed to parse. It is now parsed in its own step and named in its own message.
14+
15+
- **The launcher no longer prints the wiring recipe that would undo its own coexistence.** The spawned proxy's `export NODE_EXTRA_CA_CERTS=<our ca.pem>` banner is correct standalone advice and exactly wrong under `--remote-control`, which relays that stderr: it appeared right after the launcher had published to `ca-trust.d` and adopted the merged bundle, telling the operator to pin the variable to our CA alone. The launcher now drops those lines from the stderr it relays (the mode line still prints, so forward-proxy mode stays visible), and standalone the recipe carries a same-host-MITM caveat. Filtered at the relay rather than suppressed at the source: the launcher is the side with the context, and a new env var to carry it would be permanent surface area for a presentation detail. The README's manual-wiring recipes carry the same caveat.
816

917
### Documentation
1018

README.md

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,14 @@ The generated systemd unit / launchd agent carries `CACHE_FIX_FORWARD_PROXY=on`,
7777
- `HTTPS_PROXY` — where the proxy listens: `http://127.0.0.1:<port>` (default port `9801`, or your `CACHE_FIX_PROXY_PORT`).
7878
- `NODE_EXTRA_CA_CERTS` — the CA the proxy generated on first start: `~/.claude/cache-fix-ca/ca.pem` (or `$CACHE_FIX_CA_DIR/ca.pem`).
7979

80-
Three ways to wire it, depending on how broadly you want the vars to apply:
80+
Three ways to wire it, depending on how broadly you want the vars to apply.
81+
82+
> **If anything else on this host also MITMs `api.anthropic.com`** — a corporate
83+
> TLS-inspecting agent, an account-switching pin proxy — do not use these
84+
> recipes. `NODE_EXTRA_CA_CERTS` takes one file, so pinning it to our CA alone
85+
> silently untrusts every other component. Use `--remote-control`, which
86+
> publishes into `ca-trust.d/` and consumes the merged bundle instead. See
87+
> [Coexisting with another MITM](#coexisting-with-another-mitm-on-the-same-machine-ca-trustd).
8188
8289
```bash
8390
# a) per-invocation — scoped to just this claude run
@@ -124,13 +131,35 @@ environment-specific (a Linux host may keep them outside the bundle a shell
124131
points at; a Mac keeps them in the keychain), and two components both rebuilding
125132
it would race one output.
126133

127-
The bundle is used only if it is intact (balanced `BEGIN`/`END` markers) **and**
128-
carries our own CA. A bundle failing either check is worse than no bundle — it
129-
would make the client distrust the very proxy it is being routed through, so
130-
every request fails TLS rather than merely losing some other component's CA. In
131-
that case, and when no bundle exists at all, the launcher falls back to our own
132-
CA and behaves exactly as it did before any of this existed. **A host with no
133-
other MITM and no bundle builder sees no change.**
134+
The bundle is used only if every PEM block in it is terminated **and** one of its
135+
`CERTIFICATE` blocks is our own CA. A bundle failing either check is worse than
136+
no bundle — it would make the client distrust the very proxy it is being routed
137+
through, so every request fails TLS rather than merely losing some other
138+
component's CA. In that case, and when no bundle exists at all, the launcher
139+
falls back to our own CA and behaves exactly as it did before any of this
140+
existed. **A host with no other MITM and no bundle builder sees no change.**
141+
142+
The check mirrors what Node's `NODE_EXTRA_CA_CERTS` loader does, which is
143+
narrower than "is this valid PEM" in one direction and wider in another, and both
144+
were measured against a real handshake rather than read off the spec:
145+
146+
- **Non-certificate blocks are ignored, not fatal.** A merged bundle legitimately
147+
carries CRLs, public keys and key material next to the roots; Node skips them
148+
and verifies fine. Parsing every block as a certificate rejected those bundles
149+
outright, and rejecting is not the safe direction here — it drops every sibling
150+
and corporate CA for the whole session.
151+
- **The `CERTIFICATE` label is load-bearing.** Our own CA relabelled
152+
`TRUSTED CERTIFICATE` parses to byte-identical DER, so a label-blind comparison
153+
reports "carries us" — while Node's loader skips the block entirely and the
154+
session then fails every request. A DER match on a non-`CERTIFICATE` block does
155+
not count.
156+
- **Markers are line-anchored.** A provenance header that merely mentions
157+
`-----BEGIN ` is prose, not a block.
158+
159+
Where the guard cannot tell (a block damaged *after* ours, whose truncated body
160+
may or may not still decode) it refuses. Refusing costs the other components'
161+
CAs for one session; accepting a bundle Node cannot load costs the session
162+
entirely, so the guard is allowed to be conservative and never permissive.
134163

135164
Both paths are fixed names under `<config>`, deliberately with no env override
136165
of their own. They are two halves of one rendezvous: a knob on either half alone

bin/ca-trust.mjs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { X509Certificate } from "node:crypto";
2+
3+
// Is this merged CA bundle safe to hand claude as NODE_EXTRA_CA_CERTS?
4+
//
5+
// Lives in its own module for one reason: the launcher is a top-level script,
6+
// so a test could not import this decision and the previous version kept a
7+
// hand-copied duplicate in test/proxy-forward-ca.test.mjs under a "change one,
8+
// change both" comment. Measured: mutating the launcher's copy left the whole
9+
// suite green, so the trust path had no regression cover at all. Two call sites
10+
// is below this repo's bar for a new abstraction; the justification here is not
11+
// reuse, it is that the test must drive the shipped code rather than an
12+
// adjacent copy of it.
13+
//
14+
// The rule this implements is node's, not openssl's-in-general, and every
15+
// clause below was measured against a real TLS handshake (node v24.11.1,
16+
// openssl 3.6.1) rather than reasoned from the spec. See
17+
// test/proxy-forward-ca.test.mjs, which re-runs that comparison on each shape.
18+
//
19+
// It stays a pre-flight guard, never proof: it establishes the file parses and
20+
// carries us, never that node will verify a given leaf with it.
21+
export function bundleCarriesOurCA(merged, ourCaPem) {
22+
const text = merged.replace(/\r\n/g, "\n");
23+
const ourDer = new X509Certificate(ourCaPem).raw;
24+
// Line-anchored. openssl only honours a marker that begins a line, so a
25+
// marker quoted inside prose is not a block. The previous count-based check
26+
// read the raw file and rejected any bundle whose provenance header happened
27+
// to name the marker — measured: `# see -----BEGIN CERTIFICATE-----` ahead of
28+
// a healthy CA was refused while node authorized the same bytes.
29+
const marker = /^-----BEGIN ([A-Z0-9 ]*)-----$/gm;
30+
let carriesUs = false;
31+
for (let m; (m = marker.exec(text)); ) {
32+
const label = m[1];
33+
const endMarker = `\n-----END ${label}-----`;
34+
const end = text.indexOf(endMarker, m.index);
35+
// Unterminated, or closed by a different label. Fatal whatever the label
36+
// is, and deliberately not analyzed further: openssl's base64 decoder
37+
// treats the next '-' as end-of-data instead of an error, so a torn block
38+
// yields a valid entry when its truncated body happens to be a complete
39+
// DER and garbage when it does not. Measured both outcomes from the same
40+
// tear position with only the body length changed. Since the result is not
41+
// knowable from out here, a damaged file is refused rather than guessed at.
42+
if (end === -1) return { ok: false, reason: `unterminated ${label} block` };
43+
// Everything else in the file is node's business, not ours. A merged bundle
44+
// legitimately carries CRLs, public keys and key material alongside the
45+
// roots; node's loader skips them and verifies fine. Parsing every block as
46+
// a certificate is what made those bundles unusable — and rejecting the
47+
// bundle does not fail safe, it drops every sibling and corporate CA for
48+
// the whole session, which is the failure this contract exists to prevent.
49+
if (label !== "CERTIFICATE") continue;
50+
const block = text.slice(m.index, end + endMarker.length);
51+
let der;
52+
try { der = new X509Certificate(block).raw; }
53+
catch { return { ok: false, reason: "undecodable CERTIFICATE block" }; }
54+
if (der.equals(ourDer)) carriesUs = true;
55+
}
56+
// A bundle that exists but predates our publish is WORSE than no bundle:
57+
// handing it to claude makes the client distrust the very proxy it is routed
58+
// through, so every request fails TLS rather than merely losing some other
59+
// component's CA.
60+
//
61+
// Matched on DER, and only on a CERTIFICATE block, because neither weaker
62+
// check is sound. Measured: relabelling our own CA to TRUSTED CERTIFICATE
63+
// leaves X509Certificate parsing it into byte-identical DER while node's CA
64+
// loader skips it entirely — the old guard accepted that bundle and the
65+
// handshake then failed with UNABLE_TO_VERIFY_LEAF_SIGNATURE. That is the
66+
// exact outcome this check exists to prevent, so the label is load-bearing.
67+
return carriesUs ? { ok: true } : { ok: false, reason: "bundle does not carry our CA" };
68+
}

0 commit comments

Comments
 (0)