Skip to content

Commit 3d51b01

Browse files
committed
merge ca-trust-guard (upstream PR cnighswonger#296)
2 parents 6a7fd28 + 8ed796a commit 3d51b01

7 files changed

Lines changed: 748 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
@@ -160,7 +160,14 @@ The generated systemd unit / launchd agent carries `CACHE_FIX_FORWARD_PROXY=on`,
160160
- `HTTPS_PROXY` — where the proxy listens: `http://127.0.0.1:<port>` (default port `9801`, or your `CACHE_FIX_PROXY_PORT`).
161161
- `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`).
162162

163-
Three ways to wire it, depending on how broadly you want the vars to apply:
163+
Three ways to wire it, depending on how broadly you want the vars to apply.
164+
165+
> **If anything else on this host also MITMs `api.anthropic.com`** — a corporate
166+
> TLS-inspecting agent, an account-switching pin proxy — do not use these
167+
> recipes. `NODE_EXTRA_CA_CERTS` takes one file, so pinning it to our CA alone
168+
> silently untrusts every other component. Use `--remote-control`, which
169+
> publishes into `ca-trust.d/` and consumes the merged bundle instead. See
170+
> [Coexisting with another MITM](#coexisting-with-another-mitm-on-the-same-machine-ca-trustd).
164171
165172
```bash
166173
# a) per-invocation — scoped to just this claude run
@@ -207,25 +214,63 @@ environment-specific (a Linux host may keep them outside the bundle a shell
207214
points at; a Mac keeps them in the keychain), and two components both rebuilding
208215
it would race one output.
209216

210-
The bundle is used only if it is intact (balanced `BEGIN`/`END` markers) **and**
211-
carries our own CA. A bundle failing either check is worse than no bundle — it
212-
would make the client distrust the very proxy it is being routed through, so
213-
every request fails TLS rather than merely losing some other component's CA. In
214-
that case, and when no bundle exists at all, the launcher falls back to our own
215-
CA and behaves exactly as it did before any of this existed. **A host with no
216-
other MITM and no bundle builder sees no change.**
217+
The bundle is used only if every PEM block in it is terminated **and** one of its
218+
`CERTIFICATE` blocks is our own CA. A bundle failing either check is worse than
219+
no bundle — it would make the client distrust the very proxy it is being routed
220+
through, so every request fails TLS rather than merely losing some other
221+
component's CA. In that case, and when no bundle exists at all, the launcher
222+
falls back to our own CA and behaves exactly as it did before any of this
223+
existed. **A host with no other MITM and no bundle builder sees no change.**
224+
225+
The check mirrors what Node's `NODE_EXTRA_CA_CERTS` loader does, which is
226+
narrower than "is this valid PEM" in one direction and wider in another, and both
227+
were measured against a real handshake rather than read off the spec:
228+
229+
- **Well-formed non-certificate blocks are ignored, not fatal.** A merged bundle
230+
legitimately carries CRLs, public keys and key material next to the roots; Node
231+
skips them and verifies fine. Parsing every block as a certificate rejected
232+
those bundles outright, and rejecting is not the safe direction here — it drops
233+
every sibling and corporate CA for the whole session. A *damaged* one is a
234+
different matter: Node aborts the whole load on any block it cannot decode,
235+
whatever the label, so every block must decode even though only certificates
236+
are compared.
237+
- **The `CERTIFICATE` label is load-bearing.** Our own CA relabelled
238+
`TRUSTED CERTIFICATE` parses to byte-identical DER, so a label-blind comparison
239+
reports "carries us" — while Node's loader skips the block entirely and the
240+
session then fails every request. A DER match on a non-`CERTIFICATE` block does
241+
not count.
242+
- **Markers are line-anchored, but tolerate trailing whitespace.** A provenance
243+
header that merely mentions `-----BEGIN ` is prose, not a block. A real marker
244+
wearing a trailing space still is one — openssl reacts to it, so a strict
245+
end-of-line anchor would hide that block from the guard while Node still tried
246+
to load it.
247+
- **Each block ends at its own `END`.** The terminator is searched for only up to
248+
the next `BEGIN`, so an unterminated entry cannot borrow the `END` line of a
249+
later one and pass as intact.
250+
251+
Where the guard cannot tell (a block damaged *after* ours, whose truncated body
252+
may or may not still decode) it refuses. Refusing costs the other components'
253+
CAs for one session; accepting a bundle Node cannot load costs the session
254+
entirely, so the guard is allowed to be conservative and never permissive.
217255

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

224-
Note the limit of what a consumer can check: intact, and carries my CA. Whether
225-
the bundle is *complete* — that no corporate root went missing — is the
226-
builder's guarantee, not something a reader can verify, because a reader has no
227-
previous state to compare against and a legitimately small bundle is
228-
indistinguishable from a narrowed one.
262+
Note the limit of what a consumer checks: intact, and carries my CA. Whether the
263+
bundle is *complete* — that no corporate root went missing — is the builder's
264+
guarantee, and a consumer must not act on it even where it could.
265+
266+
That is a design choice, not a missing capability, and the distinction matters
267+
because the other reading is an invitation: someone adds the previous bundle as
268+
state, believes the limitation is lifted, and adds a floor. It would still be
269+
wrong. A shrink is *legitimate* whenever a root is retired or a component is
270+
uninstalled, and only the builder knows which happened — so a reader holding
271+
both bundles still cannot tell a regression from a fact. Measured: a legitimate
272+
bundle is 5 certs on one machine here and 168 on another, so any floor that
273+
catches narrowing on one host rejects a healthy bundle on the next.
229274

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

0 commit comments

Comments
 (0)