Skip to content

Commit 37cb57e

Browse files
committed
merge ca-trust: coexist with another MITM via ca-trust.d (upstream PR cnighswonger#283 pending)
integrated = upstream/main + every one of our still-open upstream PRs. Rebuilt from upstream/main, not cherry-picked onto the old integrated, so the branch stays reproducible from its inputs. cnighswonger#261 (absolute-form request-targets) is NO LONGER merged here: upstream took it as 8b25dc9 on 2026-07-31, so it arrives through upstream/main and merging the branch again would only replay it.
2 parents 209f867 + a329f21 commit 37cb57e

5 files changed

Lines changed: 793 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## [Unreleased]
44

5+
### Fixed
6+
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).
8+
9+
### Documentation
10+
11+
- **`CACHE_FIX_DOWNLOAD_REWRITE=on` disables `claude update` entirely**, which the flag's name does not suggest. Rewriting a download URL requires MITM-ing `downloads.claude.ai`, whose release client pins public roots only, so the version check fails before anything downloads. It cannot be narrowed to the binary path (MITM is decided per host at `CONNECT`, and the version check shares the host) and no client-side override reaches that client. Documented with the measurement in the README.
12+
513
## [4.3.0] - 2026-07-17
614

715
Headline: **Remote Control works through the proxy.** Claude Code ≥ 2.1.196 disables Remote Control / mobile session visibility (and `/schedule`, claude.ai MCP connectors) whenever `ANTHROPIC_BASE_URL` is set — which is exactly how reverse-proxy mode routes the client. This release adds an opt-in **forward-proxy mode** that keeps the client first-party (`ANTHROPIC_BASE_URL` unset, `HTTPS_PROXY` set) so those features keep working while the proxy still sees and transforms `/v1/messages`. All changes are additive and backward-compatible; every new mode is opt-in and defaults are unchanged.

README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,97 @@ claude() {
9999
}
100100
```
101101

102+
#### Coexisting with another MITM on the same machine (`ca-trust.d`)
103+
104+
`NODE_EXTRA_CA_CERTS` takes exactly **one** file. If anything else on the host
105+
also MITMs `api.anthropic.com` and also sets that variable — a corporate agent,
106+
an account-switching pin proxy — the last writer wins and every other CA is
107+
silently untrusted. Measured 2026-07-30: two such components on one machine took
108+
turns breaking each other's TLS, with no error attributable to either.
109+
110+
So `--remote-control` does not simply assign the variable. It:
111+
112+
1. **Publishes** our CA to `<config>/ca-trust.d/ccf.pem` — our own filename only,
113+
never a sibling's, rewritten every launch (the proxy regenerates its CA
114+
whenever the CA dir is wiped, and a stale pem advertises a key nothing signs
115+
with), skipped when the bytes already match, and written via temp + `rename`
116+
so a reader never sees a half-written file.
117+
2. **Reads** `<config>/ca-trust.pem` — a merged bundle built by exactly one
118+
external writer from the ambient/corporate roots plus every published
119+
`ca-trust.d/*.pem` — and points `NODE_EXTRA_CA_CERTS` at it.
120+
121+
`<config>` is `CLAUDE_CONFIG_DIR` or `~/.claude`. **We never write the merged
122+
bundle**: merging requires finding the ambient corporate roots, which is
123+
environment-specific (a Linux host may keep them outside the bundle a shell
124+
points at; a Mac keeps them in the keychain), and two components both rebuilding
125+
it would race one output.
126+
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+
135+
Both paths are fixed names under `<config>`, deliberately with no env override
136+
of their own. They are two halves of one rendezvous: a knob on either half alone
137+
lets a participant publish where no builder looks, or read a file no builder
138+
writes, while still appearing to implement the contract. `CLAUDE_CONFIG_DIR`
139+
already relocates the pair, and it moves both halves together.
140+
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.
146+
147+
**This is a cooperative convention among same-user processes, not a trust
148+
boundary.** The check proves *parses, and carries us* — never *contains only
149+
approved writers*. Anyone who can write `<config>` can hand us a well-formed
150+
bundle holding our CA plus their own and it will be accepted, exactly as they
151+
could already have replaced `ca-trust.d/ccf.pem`, the CA dir, or this file. The
152+
contract defends against components accidentally untrusting each other, which is
153+
the failure that actually happens; it does not defend against a local attacker,
154+
who has simpler routes.
155+
156+
#### `CACHE_FIX_DOWNLOAD_REWRITE` breaks `claude update` — leave it off
157+
158+
`CACHE_FIX_DOWNLOAD_REWRITE=on` reads like a pure performance knob. It is not:
159+
turning it on **disables `claude update` entirely** on that host. Rewriting a
160+
download URL means reading it, which means MITM-ing `downloads.claude.ai` — and
161+
the release-channel client pins **public roots only** and rejects any private
162+
CA, so the version check dies before a byte is downloaded:
163+
164+
```
165+
Failed to fetch version from .../claude-code-releases/latest after 3 attempt(s):
166+
unable to verify the first certificate
167+
```
168+
169+
Measured with `openssl s_client -proxy 127.0.0.1:9901 -connect downloads.claude.ai:443
170+
-servername downloads.claude.ai`:
171+
172+
| `CACHE_FIX_DOWNLOAD_REWRITE` | leaf CN | verify |
173+
|---|---|---|
174+
| `on` | `api.anthropic.com` | code 21 |
175+
| `off` | `downloads.claude.ai` (WR3 / GTS Root R1) | code 0 |
176+
177+
Two things make this worse than it first looks:
178+
179+
- **It cannot be narrowed to the binary download.** MITM is decided per host at
180+
`CONNECT` time, and the version check shares `downloads.claude.ai` with the
181+
download itself. It is all-or-nothing per host.
182+
- **No client-side override reaches that client.** `HTTPS_PROXY` / `ALL_PROXY`,
183+
`/etc/hosts`, `/etc/resolv.conf`, and `NODE_EXTRA_CA_CERTS` were each
184+
disproved against a control on the identical path — a local resolver logged 0
185+
queries and a TCP forwarder logged 0 connects across a full `claude update`,
186+
while a plain `node https.get` through that same forwarder returned 200. So no
187+
amount of CA injection can make the rewrite work. Only not intercepting works.
188+
189+
Other hosts are unaffected: `github.com` through the same proxy returns its real
190+
certificate and verifies. The flag is off by default; keep it that way unless you
191+
are prepared to update Claude Code some other way.
192+
102193
### What the proxy does
103194

104195
On every `/v1/messages` request, the pipeline runs an ordered chain of extensions covering cache stability, observability, thinking-desync mitigation, image, microcompact, breakpoint, bootstrap-channel, and other surfaces. Several are gated behind env vars documented in their own sections below; bootstrap-channel handling defaults to `audit` mode. The headliners:

bin/claude-via-proxy.mjs

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { fork, spawn } from "node:child_process";
44
import { fileURLToPath } from "node:url";
55
import { dirname, resolve, join } from "node:path";
66
import { homedir } from "node:os";
7-
import { existsSync } from "node:fs";
7+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
8+
import { X509Certificate, randomUUID } from "node:crypto";
89
import http from "node:http";
910

1011
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -211,7 +212,153 @@ if (remoteControl) {
211212
delete claudeEnv.ANTHROPIC_BASE_URL;
212213
claudeEnv.HTTPS_PROXY = proxyUrl;
213214
claudeEnv.https_proxy = proxyUrl;
214-
claudeEnv.NODE_EXTRA_CA_CERTS = caPem;
215+
// Publish our MITM CA where other components can find it. NODE_EXTRA_CA_CERTS
216+
// takes ONE file, so whoever assigns it last wins and every other CA is
217+
// silently untrusted — measured 2026-07-30 against an account-switching pin
218+
// proxy, which
219+
// also MITMs api.anthropic.com and also set the var, breaking Remote Control
220+
// inbound on the work Mac. The fix is a directory each component publishes its
221+
// own file into, so a bundle can be built from all of them.
222+
//
223+
// We write EXACTLY ONE path, ca-trust.d/ccf.pem, and never a sibling's.
224+
// Rewritten every launch, not once: the proxy regenerates its CA whenever
225+
// caDir is wiped, and a stale pem would advertise a key nothing signs with.
226+
//
227+
// Fixed name, no env override, for the same reason the merged bundle below has
228+
// none: both halves are a rendezvous, and a knob on one half only lets this
229+
// launcher publish where no builder is looking while still consuming the
230+
// canonical bundle — dropping out of the contract while appearing to implement
231+
// it. Relocating the pair is what CLAUDE_CONFIG_DIR already does, and it moves
232+
// both sides together.
233+
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
234+
const caTrustDir = join(configDir, "ca-trust.d");
235+
try {
236+
mkdirSync(caTrustDir, { recursive: true });
237+
const ours = readFileSync(caPem);
238+
const dst = join(caTrustDir, "ccf.pem");
239+
// Byte-compare skip so a bundle builder keying on mtime is not woken by a
240+
// launch that changed nothing.
241+
let same = false;
242+
try { same = readFileSync(dst).equals(ours); } catch { /* absent => write */ }
243+
if (!same) {
244+
// Write a temp sibling and rename() over the target: rename is atomic on
245+
// POSIX, so a builder reading the directory sees either the old complete
246+
// file or the new one, never a half. A plain writeFileSync(dst) opens with
247+
// O_TRUNC and leaves a torn pem visible for the duration of the write —
248+
// and a torn pem does not merely lose OUR CA, it can void the ENTIRE merged
249+
// bundle: Node's PEM reader aborts the whole extras load on an unterminated
250+
// block. Measured on node v24 / openssl 3.5 with a leaf signed by this CA:
251+
// torn entry AFTER a good one warns "bad end line" but still verifies;
252+
// torn entry BEFORE it fails with UNABLE_TO_VERIFY_LEAF_SIGNATURE. The
253+
// builder concatenates sort(*.pem) and "ccf.pem" sorts first, so a torn
254+
// OURS lands in exactly the fatal position and takes every other component
255+
// CA and corporate root down with it. Temp must be in the SAME directory —
256+
// rename across filesystems is not atomic (and would EXDEV).
257+
// pid alone is not unique: two launches in separate PID namespaces sharing
258+
// a bind-mounted config dir can hold the same pid and collide on the temp
259+
// path, so one publishes the other's bytes. uuid removes that.
260+
const tmp = `${dst}.${process.pid}.${randomUUID()}`;
261+
writeFileSync(tmp, ours);
262+
renameSync(tmp, dst);
263+
}
264+
// Reap temps orphaned by a kill between the write and the rename. They do
265+
// not match a *.pem glob so a builder ignores them, but nothing else would
266+
// ever remove them.
267+
//
268+
// Age-gated, because a temp is indistinguishable from an orphan by name: a
269+
// CONCURRENT launcher has its own ccf.pem.<pid>.<uuid> on disk in the window
270+
// between its writeFileSync and its renameSync, and deleting that makes its
271+
// rename throw a publish failure we caused. The window is one small write to
272+
// the same directory, microseconds; a minute is four orders of magnitude of
273+
// headroom and still collects the orphan on the next launch. Deleting late
274+
// costs nothing — nothing reads these — while deleting early breaks a peer.
275+
const orphanAgeMs = 60_000;
276+
for (const f of readdirSync(caTrustDir)) {
277+
if (!f.startsWith("ccf.pem.")) continue;
278+
const p = join(caTrustDir, f);
279+
try { if (Date.now() - statSync(p).mtimeMs > orphanAgeMs) rmSync(p); }
280+
catch { /* raced: someone renamed or removed it first */ }
281+
}
282+
} catch (e) {
283+
// Non-fatal: publishing is how OTHERS trust us. This session only needs its
284+
// own CA, so a failure to publish must not stop it.
285+
process.stderr.write(`cache-fix: could not publish CA to ${caTrustDir}: ${e.message}\n`);
286+
}
287+
// Read the merged bundle if something built one, so a session trusts every
288+
// component's CA and not only ours.
289+
//
290+
// We deliberately do NOT build it. Merging must include the ambient/corporate
291+
// roots, and finding those is environment-specific (a Linux host may keep them
292+
// in /usr/local/share/ca-certificates/*.crt and NOT in the system bundle a
293+
// shell points at; a Mac keeps them in the keychain). That knowledge does not
294+
// belong in this repo. It also keeps the writer count at one: two launchers
295+
// both rebuilding the bundle would race the same output. We are write-own +
296+
// read-merged.
297+
//
298+
// No bundle => our own CA alone, byte for byte what this did before, so a host
299+
// with no other MITM and no bundle builder sees no change at all.
300+
// Fixed name, no env override: the builder writes this exact path (it resolves
301+
// the config dir the same way), so a knob here could only ever point the two
302+
// sides at different files.
303+
const caTrustBundle = join(configDir, "ca-trust.pem");
304+
let caForClaude = caPem;
305+
// Accept the bundle only if OUR CA is actually in it. A bundle that exists and
306+
// is non-empty but predates our publish (the normal state right after a CCF
307+
// upgrade, or on the very first launch on a host whose builder ran earlier) is
308+
// WORSE than no bundle: handing it to claude makes the client distrust the very
309+
// proxy it is being routed through, so every request fails TLS instead of
310+
// merely losing some other component's CA. Size alone cannot tell the two
311+
// apart. readFileSync throws when absent, which is the same "use our own CA"
312+
// answer as an empty, stale, or unreadable bundle — one catch covers them all.
313+
//
314+
// Both conditions are checked by PARSING, not by matching substrings. Node's
315+
// PEM reader aborts the whole extras load on one block it cannot decode, so a
316+
// damaged entry does not merely lose itself — it can void every other component
317+
// CA and corporate root in the file, our own included. Counting BEGIN/END says
318+
// nothing about whether a body decodes, and hard-coding the CERTIFICATE label
319+
// makes any other label a corporate bundle carries invisible to the count; both
320+
// gaps were measured accepting bundles that fail a real handshake. The shapes
321+
// and their handshake results are in test/proxy-forward-ca.test.mjs, which
322+
// asserts this decision agrees with an actual TLS verify on every one.
323+
//
324+
// Torn blocks have no END line, so the regex never yields them — that is why
325+
// the block count is compared against the BEGIN count rather than trusted
326+
// directly. A count mismatch means something in there is unterminated.
327+
//
328+
// Still only a pre-flight guard, not proof: it establishes the file parses and
329+
// carries us, never that Node will verify a given leaf with it. Only a
330+
// handshake shows that, and the launcher does not perform one.
331+
//
332+
// This block is a top-level script, so a test cannot import it — the same
333+
// decision is mirrored in test/proxy-forward-ca.test.mjs `bundleIsUsable`.
334+
// Change one, change both.
335+
try {
336+
const merged = readFileSync(caTrustBundle, "utf8").replace(/\r\n/g, "\n");
337+
const blocks = merged.match(/-----BEGIN [^-]*-----[\s\S]*?-----END [^-]*-----/g) || [];
338+
if (blocks.length !== (merged.match(/-----BEGIN /g) || []).length) throw new Error("torn block");
339+
// Throws on an empty or malformed ca.pem, which must fall through rather than
340+
// match everything — a zero-byte CA made the old substring test vacuously true.
341+
const oursDer = new X509Certificate(readFileSync(caPem)).raw;
342+
let carriesUs = false;
343+
for (const block of blocks) {
344+
// One unparseable block is enough to void the load, so refuse the file.
345+
if (new X509Certificate(block).raw.equals(oursDer)) carriesUs = true;
346+
}
347+
if (!carriesUs) throw new Error("bundle does not carry our CA");
348+
caForClaude = caTrustBundle;
349+
} catch (e) {
350+
// Absent is the normal case on a host with no builder — silent, and the same
351+
// answer as every other unusable state. But a bundle that EXISTS and was
352+
// refused means the one component allowed to write it produced something
353+
// broken, and in a multi-component contract that has to be visible: the
354+
// session still works (we fall back to our own CA) while every other
355+
// component's CA is silently gone, which is precisely the failure nobody
356+
// would otherwise notice.
357+
if (existsSync(caTrustBundle)) {
358+
process.stderr.write(`cache-fix: ignoring ${caTrustBundle} (${e.message}); using our own CA only\n`);
359+
}
360+
}
361+
claudeEnv.NODE_EXTRA_CA_CERTS = caForClaude;
215362
// Exclude localhost from the proxy. Without this, HTTPS_PROXY routes EVERY
216363
// connection claude makes — including to local services like HTTP/SSE-transport
217364
// MCP servers (e.g. an MCP on 127.0.0.1) — at the cache-fix proxy, which only

0 commit comments

Comments
 (0)