Skip to content

Commit 0cec5e6

Browse files
committed
merge ca-trust-guard (upstream PR cnighswonger#296)
2 parents f8d1710 + 3f8a9be commit 0cec5e6

7 files changed

Lines changed: 646 additions & 150 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 (tolerating trailing whitespace, which openssl still reacts to), bounds each block's `END` search at the next `BEGIN` so a torn entry cannot borrow a later block's terminator, requires every block to decode — X509 for `CERTIFICATE`, valid base64 armor for everything else, since Node aborts the whole load on any block it cannot read regardless of label — and requires the DER match to land 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, and is no longer handed to the client.** 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 `NODE_EXTRA_CA_CERTS` was pointed at the very file that had just failed to parse. It is now parsed in its own step and named in its own message, and when it does not parse the variable is left unset: node falls back to its built-in store, which is the honest state, rather than to a file we vouch for and cannot read.
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 server now prints the recipe only when the operator is the one wiring: `process.channel` is set exactly when our launcher `fork()`ed it, and the launcher is the only `fork()` site (the `server` subcommand uses `spawn`, and a service manager runs it bare). The mode line still prints either way, so forward-proxy mode stays visible. Standalone, the recipe carries a same-host-MITM caveat, as do the README's manual-wiring recipes.
816

917
### Documentation
1018

README.md

Lines changed: 58 additions & 13 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,25 +131,63 @@ 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+
- **Well-formed non-certificate blocks are ignored, not fatal.** A merged bundle
147+
legitimately carries CRLs, public keys and key material next to the roots; Node
148+
skips them and verifies fine. Parsing every block as a certificate rejected
149+
those bundles outright, and rejecting is not the safe direction here — it drops
150+
every sibling and corporate CA for the whole session. A *damaged* one is a
151+
different matter: Node aborts the whole load on any block it cannot decode,
152+
whatever the label, so every block must decode even though only certificates
153+
are compared.
154+
- **The `CERTIFICATE` label is load-bearing.** Our own CA relabelled
155+
`TRUSTED CERTIFICATE` parses to byte-identical DER, so a label-blind comparison
156+
reports "carries us" — while Node's loader skips the block entirely and the
157+
session then fails every request. A DER match on a non-`CERTIFICATE` block does
158+
not count.
159+
- **Markers are line-anchored, but tolerate trailing whitespace.** A provenance
160+
header that merely mentions `-----BEGIN ` is prose, not a block. A real marker
161+
wearing a trailing space still is one — openssl reacts to it, so a strict
162+
end-of-line anchor would hide that block from the guard while Node still tried
163+
to load it.
164+
- **Each block ends at its own `END`.** The terminator is searched for only up to
165+
the next `BEGIN`, so an unterminated entry cannot borrow the `END` line of a
166+
later one and pass as intact.
167+
168+
Where the guard cannot tell (a block damaged *after* ours, whose truncated body
169+
may or may not still decode) it refuses. Refusing costs the other components'
170+
CAs for one session; accepting a bundle Node cannot load costs the session
171+
entirely, so the guard is allowed to be conservative and never permissive.
134172

135173
Both paths are fixed names under `<config>`, deliberately with no env override
136174
of their own. They are two halves of one rendezvous: a knob on either half alone
137175
lets a participant publish where no builder looks, or read a file no builder
138176
writes, while still appearing to implement the contract. `CLAUDE_CONFIG_DIR`
139177
already relocates the pair, and it moves both halves together.
140178

141-
Note the limit of what a consumer can check: intact, and carries my CA. Whether
142-
the bundle is *complete* — that no corporate root went missing — is the
143-
builder's guarantee, not something a reader can verify, because a reader has no
144-
previous state to compare against and a legitimately small bundle is
145-
indistinguishable from a narrowed one.
179+
Note the limit of what a consumer checks: intact, and carries my CA. Whether the
180+
bundle is *complete* — that no corporate root went missing — is the builder's
181+
guarantee, and a consumer must not act on it even where it could.
182+
183+
That is a design choice, not a missing capability, and the distinction matters
184+
because the other reading is an invitation: someone adds the previous bundle as
185+
state, believes the limitation is lifted, and adds a floor. It would still be
186+
wrong. A shrink is *legitimate* whenever a root is retired or a component is
187+
uninstalled, and only the builder knows which happened — so a reader holding
188+
both bundles still cannot tell a regression from a fact. Measured: a legitimate
189+
bundle is 5 certs on one machine here and 168 on another, so any floor that
190+
catches narrowing on one host rejects a healthy bundle on the next.
146191

147192
**This is a cooperative convention among same-user processes, not a trust
148193
boundary.** The check proves *parses, and carries us* — never *contains only

0 commit comments

Comments
 (0)