feat(protocol)!: bind the ephemeral key into the ready signature - #222
Conversation
Closes the residual recorded in GHSA-j6jv-w774-77m6. BREAKING CHANGE: PROTOCOL_VERSION 2 → 3. `ReadyFrame.sessionSig` now signs `(mcpHelloNonce || extHelloNonce || extensionSessionPub)`. Every package and the browser extension must be updated together; a v2 peer is refused at the hello. 0.4.0's mutual auth proved the extension was the one whose hello arrived. It never committed to the key the session is derived FROM. So a relay could forward the genuine hellos and the genuine signature — every nonce and identity intact, the pair code unchanged — substitute an ephemeral public key it held the private half of, and derive the same shared secret as the MCP. Nothing either side checked said otherwise. The peer path made this trivial (it verified nothing at all until #208), but the host path had the same hole from 0.4.0 onward. The fix is one line of definition and three call sites: `readySignaturePayload()` in `@fetchproxy/protocol`, used by the extension that signs and by both server paths that verify, so they cannot drift apart the way the comments about them already had. No negotiated downgrade, deliberately. A version-gated variant would let the attacker pick the version — a relay that can rewrite frames can rewrite the one that advertises support — unless the negotiation itself were signed. The 0.4.0 precedent applies: hard break, all packages together, refuse the old version at the hello. One test changed sides rather than being edited: `KNOWN RESIDUAL: a relay can still swap the ephemeral key and share the session` carried a comment saying it should start failing the day the signature covered the ephemeral key. It did. It now asserts the refusal, under a name that says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @chrischall's task in 4m 1s —— View job Reviewing PR #222
SummaryVerified the core fix end-to-end: One 🟡 nit posted inline: six integration test files ( Could not run Full review posted as a PR comment and one inline comment. Verdict: warn |
| generateEd25519, | ||
| ed25519Sign, | ||
| readySignaturePayload, | ||
| concatBytes, |
There was a problem hiding this comment.
🟡 Nit: concatBytes is now an unused import here — the sig-payload construction it used to feed was replaced by readySignaturePayload(...) in this same diff, and nothing else in the file calls it. Same leftover in the other five integration tests touched by this PR: graphql-query.test.ts, read-cookies.test.ts, all-bootstrap-verbs.test.ts, two-mcps.test.ts, and reconnect.test.ts (all import concatBytes but no longer call it). Not caught by tsc -b since packages/server/tsconfig.json only includes src/**/*, and noUnusedLocals isn't set in tsconfig.base.json, but worth a cleanup pass.
Review: bind the ephemeral key into the ready signatureRead Core change checks out.
Byte content matches on both ends in every call site I checked, so the signature does what the PR claims: it commits to the key the ECDH actually uses.
Test coverage is exactly what a change like this needs. Docs ( One nit (inline on Not independently verified: I don't have No security or correctness issues found in the actual fix — it closes the gap described ( Verdict: warn |
|
🟡 Auto-review verdict: warn — The ready-signature fix (binding extensionSessionPub into the signed payload via readySignaturePayload) is correctly implemented and consistently used across the extension and both server verification paths, with matching test and doc updates; one minor unused-import nit found, no important findings. |
…rphaned (#224) The one nit from #223, on a fresh branch — #222 merged while I was fixing it. Six integration fixtures (`mutual-auth-idb`, `graphql-query`, `read-cookies`, `all-bootstrap-verbs`, `two-mcps`, `reconnect`) and `peer.ts` still imported `concatBytes` after their signature payloads moved to `readySignaturePayload()`. Checked each rather than trusting the list: all seven had zero remaining call sites. `host.ts` keeps its import — it still concatenates for the *peer hello* signature (`mcpId || sessionNonce`), which is a different payload and unaffected by #222. Worth removing beyond tidiness in one case: in `peer.ts` the import was the last trace of the payload shape that #222 established is a security bug, sitting a few lines from the code that no longer builds it. 1233 tests, typecheck clean across all six workspaces. Closes #223 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
🤖 I have created a release *beep* *boop* --- ## [2.0.0](v1.11.0...v2.0.0) (2026-08-06) ### ⚠ BREAKING CHANGES * **protocol:** bind the ephemeral key into the ready signature ([#222](#222)) ### Features * **protocol:** add write_cookies, the one verb that can repair a rotated session ([#211](#211)) ([b2557c2](b2557c2)) * **protocol:** bind the ephemeral key into the ready signature ([#222](#222)) ([c13aeed](c13aeed)) * **server:** let a request name the tab that relays it ([#207](#207)) ([c5d3f4d](c5d3f4d)) * **server:** pin the extension's identity, and verify it on the peer path ([#213](#213)) ([0eeced7](0eeced7)) ### Bug Fixes * **cli:** let a real filesystem error be itself, not "no extension pin" ([#221](#221)) ([c87a864](c87a864)), closes [#220](#220) * **cli:** validate --via-tab before connecting, like the request URL ([#210](#210)) ([959fcc5](959fcc5)) * **extension:** reattach the write_cookies doc block, and name the writable cookies as writable ([#215](#215)) ([2730c4a](2730c4a)) * **extension:** use the guarded caps local for the cookie heading ([#217](#217)) ([f95c832](f95c832)) * **server:** release only our own extension claim, and stop guessing scoped names ([#219](#219)) ([3d90a64](3d90a64)), closes [#218](#218) * **server:** type no-tab rejections so they stop reading as version mismatches ([#205](#205)) ([dc30bd9](dc30bd9)) ### Refactor * **server:** drop the concatBytes imports the signature change orphaned ([#224](#224)) ([4985ba7](4985ba7)), closes [#223](#223) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
fetchproxy 2.0.0 closes a MITM in the bridge handshake (advisory GHSA-j6jv-w774-77m6, fix in chrischall/fetchproxy#222): `ReadyFrame.sessionSig` now covers the extension's **ephemeral public key**, not just the two nonces. Under v2 a relay could forward the genuine hellos and the genuine signature, substitute an ephemeral key it held the private half of, derive the same shared secret, and read and rewrite the session — nothing either side checked committed to the key the ECDH actually used. That is a wire break (`PROTOCOL_VERSION` 2 → 3), and `^1.7.0` cannot take a 2.x. Left alone, this MCP would keep speaking v2 and be **refused at the hello** by a v3 extension — loudly, at the handshake, not silently. The fleet and the sideloaded extension move together. **No source change.** The break is inside the handshake; `FetchproxyServer`'s public API is unchanged. Verified by running this repo's own suite against 2.0.0. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fetchproxy 2.0.0 closes a MITM in the bridge handshake (advisory GHSA-j6jv-w774-77m6, fix in chrischall/fetchproxy#222): `ReadyFrame.sessionSig` now covers the extension's **ephemeral public key**, not just the two nonces. Under v2 a relay could forward the genuine hellos and the genuine signature, substitute an ephemeral key it held the private half of, derive the same shared secret, and read and rewrite the session — nothing either side checked committed to the key the ECDH actually used. That is a wire break (`PROTOCOL_VERSION` 2 → 3), and `^1.7.0` cannot take a 2.x. Left alone, this MCP would keep speaking v2 and be **refused at the hello** by a v3 extension — loudly, at the handshake, not silently. The fleet and the sideloaded extension move together. **No source change.** The break is inside the handshake; `FetchproxyServer`'s public API is unchanged. Verified by running this repo's own suite against 2.0.0. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fetchproxy 2.0.0 closes a MITM in the bridge handshake (advisory GHSA-j6jv-w774-77m6, fix in chrischall/fetchproxy#222): `ReadyFrame.sessionSig` now covers the extension's **ephemeral public key**, not just the two nonces. Under v2 a relay could forward the genuine hellos and the genuine signature, substitute an ephemeral key it held the private half of, derive the same shared secret, and read and rewrite the session — nothing either side checked committed to the key the ECDH actually used. That is a wire break (`PROTOCOL_VERSION` 2 → 3), and `^1.7.0` cannot take a 2.x. Left alone, this MCP would keep speaking v2 and be **refused at the hello** by a v3 extension — loudly, at the handshake, not silently. The fleet and the sideloaded extension move together. **No source change.** The break is inside the handshake; `FetchproxyServer`'s public API is unchanged. Verified by running this repo's own suite against 2.0.0. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fetchproxy 2.0.0 closes a MITM in the bridge handshake (advisory GHSA-j6jv-w774-77m6, fix in chrischall/fetchproxy#222): `ReadyFrame.sessionSig` now covers the extension's **ephemeral public key**, not just the two nonces. Under v2 a relay could forward the genuine hellos and the genuine signature, substitute an ephemeral key it held the private half of, derive the same shared secret, and read and rewrite the session — nothing either side checked committed to the key the ECDH actually used. That is a wire break (`PROTOCOL_VERSION` 2 → 3), and `^1.7.0` cannot take a 2.x. Left alone, this MCP would keep speaking v2 and be **refused at the hello** by a v3 extension — loudly, at the handshake, not silently. The fleet and the sideloaded extension move together. **No source change.** The break is inside the handshake; `FetchproxyServer`'s public API is unchanged. Verified by running this repo's own suite against 2.0.0. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fetchproxy 2.0.0 closes a MITM in the bridge handshake (advisory GHSA-j6jv-w774-77m6, fix in chrischall/fetchproxy#222): `ReadyFrame.sessionSig` now covers the extension's **ephemeral public key**, not just the two nonces. Under v2 a relay could forward the genuine hellos and the genuine signature, substitute an ephemeral key it held the private half of, derive the same shared secret, and read and rewrite the session — nothing either side checked committed to the key the ECDH actually used. That is a wire break (`PROTOCOL_VERSION` 2 → 3), and `^1.7.0` cannot take a 2.x. Left alone, this MCP would keep speaking v2 and be **refused at the hello** by a v3 extension — loudly, at the handshake, not silently. The fleet and the sideloaded extension move together. **No source change.** The break is inside the handshake; `FetchproxyServer`'s public API is unchanged. Verified by running this repo's own suite against 2.0.0. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fetchproxy 2.0.0 closes a MITM in the bridge handshake (advisory GHSA-j6jv-w774-77m6, fix in chrischall/fetchproxy#222): `ReadyFrame.sessionSig` now covers the extension's **ephemeral public key**, not just the two nonces. Under v2 a relay could forward the genuine hellos and the genuine signature, substitute an ephemeral key it held the private half of, derive the same shared secret, and read and rewrite the session — nothing either side checked committed to the key the ECDH actually used. That is a wire break (`PROTOCOL_VERSION` 2 → 3), and `^1.7.0` cannot take a 2.x. Left alone, this MCP would keep speaking v2 and be **refused at the hello** by a v3 extension — loudly, at the handshake, not silently. The fleet and the sideloaded extension move together. **No source change.** The break is inside the handshake; `FetchproxyServer`'s public API is unchanged. Verified by running this repo's own suite against 2.0.0. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fetchproxy 2.0.0 closes a MITM in the bridge handshake (advisory GHSA-j6jv-w774-77m6, fix in chrischall/fetchproxy#222): `ReadyFrame.sessionSig` now covers the extension's **ephemeral public key**, not just the two nonces. Under v2 a relay could forward the genuine hellos and the genuine signature, substitute an ephemeral key it held the private half of, derive the same shared secret, and read and rewrite the session — nothing either side checked committed to the key the ECDH actually used. That is a wire break (`PROTOCOL_VERSION` 2 → 3), and `^1.7.0` cannot take a 2.x. Left alone, this MCP would keep speaking v2 and be **refused at the hello** by a v3 extension — loudly, at the handshake, not silently. The fleet and the sideloaded extension move together. **No source change.** The break is inside the handshake; `FetchproxyServer`'s public API is unchanged. Verified by running this repo's own suite against 2.0.0. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fetchproxy 2.0.0 closes a MITM in the bridge handshake (advisory GHSA-j6jv-w774-77m6, fix in chrischall/fetchproxy#222): `ReadyFrame.sessionSig` now covers the extension's **ephemeral public key**, not just the two nonces. Under v2 a relay could forward the genuine hellos and the genuine signature, substitute an ephemeral key it held the private half of, derive the same shared secret, and read and rewrite the session — nothing either side checked committed to the key the ECDH actually used. That is a wire break (`PROTOCOL_VERSION` 2 → 3), and `^1.7.0` cannot take a 2.x. Left alone, this MCP would keep speaking v2 and be **refused at the hello** by a v3 extension — loudly, at the handshake, not silently. The fleet and the sideloaded extension move together. **No source change.** The break is inside the handshake; `FetchproxyServer`'s public API is unchanged. Verified by running this repo's own suite against 2.0.0. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fetchproxy 2.0.0 closes a MITM in the bridge handshake (advisory GHSA-j6jv-w774-77m6, fix in chrischall/fetchproxy#222): `ReadyFrame.sessionSig` now covers the extension's **ephemeral public key**, not just the two nonces. Under v2 a relay could forward the genuine hellos and the genuine signature, substitute an ephemeral key it held the private half of, derive the same shared secret, and read and rewrite the session — nothing either side checked committed to the key the ECDH actually used. That is a wire break (`PROTOCOL_VERSION` 2 → 3), and `^1.7.0` cannot take a 2.x. Left alone, this MCP would keep speaking v2 and be **refused at the hello** by a v3 extension — loudly, at the handshake, not silently. The fleet and the sideloaded extension move together. **No source change.** The break is inside the handshake; `FetchproxyServer`'s public API is unchanged. Verified by running this repo's own suite against 2.0.0. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fetchproxy 2.0.0 closes a MITM in the bridge handshake (advisory GHSA-j6jv-w774-77m6, fix in chrischall/fetchproxy#222): `ReadyFrame.sessionSig` now covers the extension's **ephemeral public key**, not just the two nonces. Under v2 a relay could forward the genuine hellos and the genuine signature, substitute an ephemeral key it held the private half of, derive the same shared secret, and read and rewrite the session — nothing either side checked committed to the key the ECDH actually used. That is a wire break (`PROTOCOL_VERSION` 2 → 3), and `^1.7.0` cannot take a 2.x. Left alone, this MCP would keep speaking v2 and be **refused at the hello** by a v3 extension — loudly, at the handshake, not silently. The fleet and the sideloaded extension move together. **No source change.** The break is inside the handshake; `FetchproxyServer`'s public API is unchanged. Verified by running this repo's own suite against 2.0.0. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
fetchproxy 2.0.0 closes a MITM in the bridge handshake (advisory GHSA-j6jv-w774-77m6, fix in chrischall/fetchproxy#222): `ReadyFrame.sessionSig` now covers the extension's **ephemeral public key**, not just the two nonces. Under v2 a relay could forward the genuine hellos and the genuine signature, substitute an ephemeral key it held the private half of, derive the same shared secret, and read and rewrite the session — nothing either side checked committed to the key the ECDH actually used. That is a wire break (`PROTOCOL_VERSION` 2 → 3), and `^1.7.0` cannot take a 2.x. Left alone, this MCP would keep speaking v2 and be **refused at the hello** by a v3 extension — loudly, at the handshake, not silently. The fleet and the sideloaded extension move together. **No source change.** The break is inside the handshake; `FetchproxyServer`'s public API is unchanged. Verified by running this repo's own suite against 2.0.0. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes the residual recorded in the private advisory GHSA-j6jv-w774-77m6, found while building the mcp-host browser bridge (chrischall/mcp-host#162) and left open by #213 because the fix breaks the wire.
The hole
0.4.0's mutual auth proved the extension was the one whose hello arrived. It never committed to the key the session is derived from:
So a relay could forward the genuine hellos and the genuine signature — every nonce and identity intact, the pair code unchanged — substitute an
extensionSessionPubit held the private half of, and compute the same shared secret as the MCP. Nothing either side checked said otherwise. The peer path made it trivial (it verified nothing at all until #213), but the host path had the same hole from 0.4.0 onward.The fix
One definition —
readySignaturePayload()in@fetchproxy/protocol— used by the extension that signs it and by both server paths that verify it, so they cannot drift apart the way the comments about them already had. A relay would now have to sign its own ephemeral key with the extension's Ed25519 private key.Why no negotiated version
A version-gated variant avoids the break, and hands the attacker the choice: a relay that can rewrite frames can rewrite the field advertising v3 support, and both ends would then agree on the weaker payload. Closing that needs the negotiation itself signed, which is the same wire change with more moving parts. The 0.4.0 precedent applies — hard break, all packages together, old version refused at the hello.
One test changed sides
KNOWN RESIDUAL: a relay can still swap the ephemeral key and share the sessionwas added in #213 with a comment saying it should start failing the day the signature covered the ephemeral key. It did, on the first run. It now performs the same substitution and asserts the refusal, under a name that says so — the residual's own tripwire, doing its job.Testing
1233 tests, three consecutive clean runs,
tsc -bacross all six workspaces,npm run buildclean. The 128 tests that initially failed were fixtures hand-building v2 hellos and signatures; each now goes throughreadySignaturePayload, andvalidate.test.tsasserts v2 is refused rather than downgraded.After merge: the advisory can be published, and mcp-host's gate 1 (chrischall/mcp-host#162) is satisfiable — its whole premise is a relay that cannot read the traffic, which until now was narrowed rather than true.