From bede941fa6384dfb382f4f93fcab36ddb0bca3da Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Fri, 11 Sep 2026 07:42:08 -0400 Subject: [PATCH 1/4] =?UTF-8?q?docs(plans):=20the=20protocol=20v4=20wire?= =?UTF-8?q?=20break=20=E2=80=94=20forward=20secrecy,=20AAD=20over=20the=20?= =?UTF-8?q?frame's=20identity,=20and=20the=20cohort=20choreography?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR H2 of the mcp-host single-tier plan, written before anyone starts because it is a wire break and needs its own release choreography. Sources: docs/beta-review-2026-09-10/fetchproxy-bridge.md H1 and M2 in mcp-host. v4 gives the MCP a per-session ephemeral X25519 covered by its hello signature (identities authenticate only, so no identity holder decrypts a past session), puts `mcpId || seq || direction` in the AAD, salts the session key with a transcript hash, and refuses v3 at the hello with no negotiated downgrade — the #222 argument, unchanged. The 2.0.0 break IS recorded (c13aeed, GHSA-j6jv-w774-77m6) and is the template: advisory first, all packages together, old version refused at the hello, fixtures moved onto one shared payload function rather than patched. What is different is that the failure now lands on a hosted relay in front of ~20 bridged registrations, so v4 needs a refusal PATH and not only a refusal — and both directions can be made clean by changing only the v4 side, which is what makes the outage window one extension reload instead of an upgrade ordering problem. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Tar4Eh59YtFuxQy4BpRBQ --- .../2026-09-11-fetchproxy-protocol-v4.md | 830 ++++++++++++++++++ 1 file changed, 830 insertions(+) create mode 100644 docs/plans/2026-09-11-fetchproxy-protocol-v4.md diff --git a/docs/plans/2026-09-11-fetchproxy-protocol-v4.md b/docs/plans/2026-09-11-fetchproxy-protocol-v4.md new file mode 100644 index 0000000..46ecf46 --- /dev/null +++ b/docs/plans/2026-09-11-fetchproxy-protocol-v4.md @@ -0,0 +1,830 @@ +# fetchproxy protocol v4 — forward secrecy, AAD over the frame's identity, and the cohort choreography + +> **For the executing session.** Self-contained: every task names its files, +> the test it adds first, and the commit it ends in. Read +> `/Users/chris/git/mcp-host/docs/beta-review-2026-09-10/fetchproxy-bridge.md` +> findings **H1** and **M2** once — they are the source for this plan and carry +> the `file:line` evidence — then only the files a task points at. Execution per +> `~/.claude/CLAUDE.md`: Workflow orchestration, one fresh agent per task with +> the full task text, sequential (nothing here is parallel-safe — every task +> after Task 1 compiles against Task 1's types), reviewer-gated, then +> `superpowers:finishing-a-development-branch` per PR. +> +> Line numbers were true on 2026-09-11 at fetchproxy `ad6b673` (2.11.3, +> PROTOCOL_VERSION 3) and mcp-host `main` at 0.62.0; re-locate by symbol if +> they have moved. +> +> This is **PR H2** of +> `/Users/chris/git/mcp-host/docs/plans/2026-09-11-single-tier-autoscale-and-backlog.md`, +> which defers to this document precisely because a wire break is not one PR — +> it is one PR plus a fleet operation plus a hand on the extension. Group 4 +> below is the part that is easy to get wrong. +> +> **Filed in `docs/plans/`, not `docs/superpowers/plans/`**, because the seven +> documents there are superpowers plans with `- [ ]` checkbox tracking and a +> REQUIRED SUB-SKILL header; this one is executed by the Workflow +> orchestration `~/.claude/CLAUDE.md` mandates instead, and the mcp-host plan +> that calls for it names this path. + +--- + +## What v4 is, in one paragraph + +Two things are wrong with the v3 wire and one of them is the reason to break +it. **Forward secrecy:** the per-session AES key is +`HKDF(X25519(mcpIdentityPriv, extEphemeralPub), salt = mcpHelloNonce)` — +the MCP contributes its **long-term** X25519 key and only the browser +contributes an ephemeral (`host.ts:501`, `peer.ts:385`, `hello.ts:303`, +`approval.ts:135`), so anyone who holds an MCP's identity plus a recording of +its frames decrypts them afterwards, passively, retroactively, with nothing to +notice. **AAD:** `sealInnerFrame` (`seal.ts:140`) calls `aesGcmSeal` with no +`additionalData`, so `mcpId` and `seq` ride outside the authenticated envelope +and a party in the path can replay a recorded frame under a bumped counter, or +reflect one back. v4 gives the MCP a per-session ephemeral X25519 covered by +its hello signature, derives the session key ephemeral×ephemeral with +identities authenticating only, and puts `mcpId ‖ seq ‖ direction` in the AAD. +`PROTOCOL_VERSION` 3 → 4; v3 is refused at the hello, with no negotiated +downgrade, for the reason #222 already wrote down. + +**What this does NOT buy, and must not be described as buying.** mcp-host's +PR F2 seals the provisioned `bridge_identities` row to the account's runner +key, so the gateway holds the identity plaintext for one request instead of at +rest. That narrows **who** holds the identity. It does nothing about **what an +identity holder can do with a transcript**, which is the whole of H1. The two +changes are independent and both are wanted: F2 shrinks the set, v4 empties +the capability. Neither is a substitute for the other, and the doc rewrites in +F2 must not claim otherwise. + +--- + +## Prior art: how the 2.0.0 break was choreographed — found, and it is the template + +**Yes, the repo records it.** `git show c13aeed` +(`feat(protocol)!: bind the ephemeral key into the ready signature (#222)`, +2026-08-05, released as 2.0.0 on 2026-08-06) carries the choreography in the +commit body, and `packages/protocol/src/frames.ts:28-38` carries the standing +paragraph. `CLAUDE.md` §Security model summary item 3b carries the one-sentence +version. The relevant parts, verbatim in substance: + +- **The advisory came first, the fix second.** GHSA-j6jv-w774-77m6 was filed + privately, the fix merged, and the advisory published only after merge. Same + order here: the hole v4 closes is written down in an mcp-host review document + that is not public, and H1's disclosure obligation (fix step 1: state the + true residual in `docs/SECURITY.md` and tell beta tenants) belongs to + mcp-host and precedes this work rather than waiting on it. +- **No negotiated version, and the reason.** "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." That argument is unchanged and applies verbatim to + v4. It also forbids the transitional dual-stack extension that looks like the + obvious way to shrink the outage window in Group 4 — a v4 extension that + still accepts v3 IS the downgrade path. +- **"All packages together, old version refused at the hello."** 19 files, + 6 workspaces, one release, and the 0.4.0 precedent cited for the same shape. +- **What it said to the human.** "Reload the unpacked extension when this + ships, or the bridge stops working — loudly, at the handshake, not silently." +- **The test discipline.** 128 tests failed on the first run because fixtures + hand-built v2 hellos; each was moved onto the single shared payload function + rather than patched, and `validate.test.ts` gained an assertion that v2 is + *refused* rather than downgraded. One test (`KNOWN RESIDUAL: a relay can + still swap the ephemeral key…`) changed sides and kept its name's meaning. + +**What is different this time, and it is the whole of Group 4.** In August +2026 the only consumers were the operator's own laptop MCPs and a sideloaded +extension he reloaded himself; "loudly, at the handshake" was a message he read +in his own terminal. Today the failure lands on a hosted relay, on ~20 bridged +registrations whose npm pins move on mcp-host's nightly `follow` cron, and the +message has to survive a Durable Object that currently **drops it** +(`mcp-host/packages/core/src/bridge-frames.ts:32-38` — `readEnvelope` knows +five frame kinds and `hello-rejected` is not one of them; bridge report L10). +So v4 needs a refusal path, not only a refusal. + +--- + +## Decisions required before Task 1 + +Each has a default; take the default unless Chris says otherwise. + +1. **Package major.** v4 ships as `@fetchproxy/*` **3.0.0**. The off-by-one + between package major and protocol version (package 2.x ↔ protocol 3; + package 3.x ↔ protocol 4) persists and is a real footgun for anyone reading + a version string as a protocol number. **Default: accept it and say so in + `frames.ts`'s header paragraph and `CLAUDE.md`** rather than renumbering + either; the alternative (jump the package to 4.0.0) desynchronises the + changelog from release-please's own arithmetic for one cosmetic gain. +2. **Does the pair code change in the same break?** Bridge report **L5** — + 6 digits, `SHA256(mcpPub‖extPub)`, both inputs public and long-term, so a + MITM grinds a target code in ~10⁶ keygens. v4 introduces a transcript hash + that already contains both fresh nonces and both ephemerals; deriving the + pair code from it and lengthening to 8 digits is a dozen lines and removes + the grind entirely. It is a wire-visible change and this is the only break + scheduled. **Default: YES, fold it in** (Task 1 mints the transcript hash; + Task 8 re-derives the code from it), because a second break to fix L5 later + costs the whole of Group 4 again. If Chris says no, cut Task 8 and move L5 + to "what this does not fix". +3. **Does mcp-host raise a protocol floor for bridged spawns?** Today + `assertSandboxableConcentrator` + (`mcp-host/packages/runner-node/src/browser-bridge/concentrator-version.ts`, + `SANDBOX_CONCENTRATOR_MIN = '2.2.0'`) reads the installed + `@fetchproxy/server` version off the tree **before** the spawn and refuses, + naming both numbers — but only on the sandboxed path, and only for the + `FETCHPROXY_WS_HOST` reason. **Default: YES** — Group 4 step 6 raises a + separate `BRIDGE_PROTOCOL_MIN` floor applied to **every** bridged spawn, so + a straggler registration is refused at spawn with an actionable message + rather than discovered by a hanging tool call. This is a guard for the + stragglers, not for the window. +4. **Beta timing.** See Group 4 §"The one-installed-copy property", below. The + short version: **ship v4 before the extension has an install base outside + the operator's machines.** If the beta has already handed Transporter to + strangers on a sideloaded zip with no auto-update, this plan's Group 4 needs + a Chrome Web Store submission in front of it. That is Chris's call and it + gates the whole plan, not a task in it. + +--- + +## The v4 wire, precisely + +Everything below lives in `@fetchproxy/protocol` as **one exported function +per fact**, which is `readySignaturePayload`'s own rule +(`frames.ts:50-57`: "in one place so the extension that produces it and the two +server paths that verify it cannot drift apart"). Three producers and four +verifiers is exactly the population that drifted before. + +### 1. The server hello gains a per-session ephemeral + +``` +HelloFrameFromServer { + ... + identityX25519Pub // unchanged — long-term, and still the TRUST KEY + identityEd25519Pub // unchanged — long-term, authenticates + sessionNonce // unchanged shape; now minted per CONNECTION (below) + sessionPub // NEW: base64 raw 32B, ephemeral X25519 public key + sessionSig // WIDENED: see 2 +} +``` + +`identityX25519Pub` stays the trust key. The extension pins +`sha256(identityX25519Pub)` (`hello.ts:238`) and the pair code commits to both +identities; nothing about pinning, trust records or re-pair prompts changes. +`sessionPub` is a new field beside the identity, not a replacement for it — +which is what keeps v4 from re-pairing the fleet the way L11 describes. + +**Per CONNECTION, not per process.** Today `ownHello` and its nonce are built +once in `listen()` (`host.ts:197`, `peer.ts:153`) and the same bytes are sent +to every extension connection for the life of the process (bridge report I1: +"the MCP hello nonce is per PROCESS … so freshness rests on the extension's +ephemeral"). A per-process MCP ephemeral would bound forward secrecy at the +process lifetime, which on mcp-host is up to ten idle minutes of real traffic — +worth having, not worth claiming as forward secrecy. **Mint a fresh +`{sessionNonce, sessionPub, sessionPriv}` each time an extension hello arrives, +then build and send the server hello from it.** The host already sends +`ownHello` on extension connect (`host.ts:371`), so the structural change is +that the hello is *built* there rather than at `listen()`, and the private half +is held beside `ownSession` and discarded with it. + +**The private half must die with the session.** Forward secrecy is the property +that an identity holder cannot open a *past* session; it is false if the +process keeps every ephemeral private key it ever minted. The session-teardown +path (`host.ts:511-528`, the close handler that nulls `ownSession`) zeroes and +drops `sessionPriv` in the same statement. Assert it. + +### 2. Both signatures cover both ephemerals + +``` +helloSignaturePayload(mcpId, sessionNonce, sessionPub) + = utf8(mcpId) ‖ sessionNonce ‖ sessionPub + signed with the MCP's long-term Ed25519 key (v3: mcpId ‖ sessionNonce) + +readySignaturePayload(mcpNonce, extNonce, extSessionPub, mcpSessionPub) + = mcpNonce ‖ extNonce ‖ extSessionPub ‖ mcpSessionPub + signed with the extension's long-term Ed25519 key (v3: without mcpSessionPub) +``` + +The hello payload is the exact mirror of what #222 did for the ready: without +it a relay substitutes `sessionPub` for one it holds the private half of, the +extension derives against the relay's key, and forward secrecy is fiction. The +extension already verifies the hello signature over `mcpId ‖ sessionNonce` +(`hello.ts:226-234`, `{kind:'reject', reason:'sessionSig invalid'}`), so +widening the payload is a change of argument at one call site on each side — +which is exactly why it must be a shared function and not two concatenations. + +The ready payload gains `mcpSessionPub` so the transcript is bound +symmetrically: after v4 neither side's contribution to the ECDH can be +substituted without a signature from a long-term key the relay does not hold. + +### 3. The session key is derived from a transcript, ephemeral × ephemeral + +``` +shared = X25519(ownEphemeralPriv, peerEphemeralPub) +transcript = SHA256(mcpNonce ‖ extNonce ‖ mcpSessionPub ‖ extSessionPub) +sessionKey = HKDF-SHA256(ikm = shared, salt = transcript, + info = 'fetchproxy/4.0.0/session', 32) +``` + +`HKDF_SESSION_INFO` moves from `'fetchproxy/1.0.0/session'` (`frames.ts:47`) to +`'fetchproxy/4.0.0/session'`, so even a hypothetical key-confusion between +versions yields different bytes. The transcript salt also closes **I1**: under +v3 a replayed old `ready` re-establishes an old key with a reset counter +(harmless only by the `id` matching downstream); with the salt covering both +nonces and both ephemerals, a replayed `ready` derives a key nothing else +holds. + +`transcriptHash()` is exported because decision 2 wants it for the pair code. + +### 4. AAD over the frame's identity + +``` +frameAad(mcpId, seq, direction) + = utf8('fetchproxy/4/frame' ‖ NUL ‖ mcpId ‖ NUL ‖ decimal(seq) ‖ NUL ‖ direction) + +type Direction = 's2e' | 'e2s' // server→extension, extension→server +``` + +NUL-separated because `mcpId`'s charset (`MCP_ID_RE`) excludes NUL and `seq` is +decimal, so the encoding is unambiguous — the `scope\0accountId\0name` +convention mcp-host's `seal.ts` already uses for the same reason. The domain +label in front means an AAD can never be mistaken for any other signed or +authenticated string in this protocol. + +`sealInnerFrame` and both `openEncryptedFrame*` take `direction` as a +**required** parameter, so no call site can omit it and no default can be wrong. +The direction byte is what makes reflection structural rather than incidental +(**I2**: today a reflected frame is harmless only because the two dispatchers +ignore each other's inner types). + +**The wire size does not change.** GCM's additional data is authenticated, not +transmitted. `sealedFrameWireBytes` (`seal.ts`) and the 42 MiB +`MAX_FRAME_BYTES` derivation are untouched, and Task 2 asserts that so nobody +"fixes" the constant later. + +### 5. v3 is refused at the hello — and the refusal is heard + +`validateHello` (`validate.ts:517-519`) throws +`hello.protocolVersion: must be 3` on anything else, which is correct and is +also, today, the end of the story: the extension's `onMessage` catches that +throw and **drops the frame with a `console.warn` in a service worker nobody +has open** (`socket.ts:246-248`). The MCP then waits out +`SESSION_READY_TIMEOUT_MS` = 30 s (`session-ready.ts:9`) and reports `not-ready` +with a hint that blames being signed out or a changed scope. That is precisely +the silent hang v4 must not ship. + +**Both directions can be made clean by changing only the v4 side.** That is the +load-bearing insight of this plan, and it is what makes the outage window a +matter of minutes rather than of upgrade ordering: + +| Pair | What happens | Whose code must change | +|---|---|---| +| **v3 MCP ↔ v4 extension** | The v4 extension recovers `protocolVersion`, `mcpId` and `accepts` from the refused hello and answers `hello-rejected` with a reason naming both versions. The v3 MCP at ≥ 2.6.0 already parses that frame (`host.ts:569`, `peer.ts:417`) and fails its pending session immediately. | extension only | +| **v4 MCP ↔ v3 extension** | The extension sends its hello **first** on every connection (`socket.ts:197-208`, in the socket's `open` handler), before the server sends its own. So the v4 host reads `protocolVersion: 3` off the extension's hello, fails the pending session immediately, and closes with a reason naming both versions. | server only | + +The second row is a *refinement* of what the host does today, not a reversal: +`host.ts:277-286` already closes `1002 'protocol error'` when `validateFrame` +throws, so the socket does not linger. What it does not do is say **why** — the +close reason is the same three words for a version mismatch as for a mangled +field — and, the part that actually costs thirty seconds, it does not reject +`ownSessionReady`. So the MCP's next `request()` waits out +`SESSION_READY_TIMEOUT_MS` while the extension reconnects on its backoff and +is closed again, forever. The v4 change is the reason text and the immediate +rejection; the close itself is already there. + +The `hello-rejected` frame is the right vehicle for the first case because it +**predates the break**: it landed in 2.6.0, the whole cohort declares +`accepts: ['hello-rejected']` (`host.ts:180`, `peer.ts:200`), the extension +already gates on that declaration (`server-hello.ts:81`), and the frame's own +doc records that it "carries no authority and grants nothing. A forged one can +make a session fail, which a silent peer could do anyway by never answering." + +**The narrow reader.** Reading fields out of a frame you have just refused is +the kind of thing that goes wrong, so it gets its own function with its own +contract: `peekHelloVersion(raw: unknown)` returns +`{ protocolVersion, mcpId, accepts } | null`, rebuilt member by member (never a +cast of the parsed object), `mcpId` regex-validated or null, `accepts` filtered +to strings, and **it grants nothing** — no caller may start a session, bind an +`mcpId` slot, write a trust record or move a counter from its output. Its only +two consumers are the two refusal paths. The `readEnvelope` / rebuild-member-by- +member pattern, and Task 6's test asserts the "grants nothing" half by +construction. + +--- + +## File map + +| Action | Path | Why | +|---|---|---| +| Modify | `packages/protocol/src/frames.ts` | `PROTOCOL_VERSION` 4, `HKDF_SESSION_INFO`, `sessionPub` on the server hello, `helloSignaturePayload`, widened `readySignaturePayload`, `transcriptHash`, `frameAad`, `Direction`, the standing v4 paragraph in the header | +| Modify | `packages/protocol/src/validate.ts` | require `sessionPub`; `peekHelloVersion` | +| Modify | `packages/protocol/src/crypto.ts` | `aesGcmSeal`/`aesGcmOpen` take `aad` | +| Modify | `packages/protocol/src/seal.ts` | `direction` required on seal/open; AAD threaded; `sealedFrameWireBytes` unchanged | +| Modify | `packages/protocol/src/pair-code.ts` | decision 2 only — derive from the transcript hash, 8 digits | +| Modify | `packages/server/src/build-server-hello.ts` | mint/accept the ephemeral, sign `helloSignaturePayload` | +| Modify | `packages/server/src/host.ts` | per-connection hello, ephemeral derivation, widened ready verify, AAD, v3 extension refusal, zeroing on teardown | +| Modify | `packages/server/src/peer.ts` | the same on the peer path | +| Modify | `packages/server/src/frame-size.ts` | seal/measure signature follow-through | +| Modify | `packages/extension-core/src/background/hello.ts` | verify the widened hello signature; derive against `sessionPub` | +| Modify | `packages/extension-core/src/background/approval.ts` | the same on the approval path | +| Modify | `packages/extension-core/src/background/server-hello.ts` | sign the widened ready payload | +| Modify | `packages/extension-core/src/background/socket.ts` | AAD on open; the v3-server refusal path | +| Modify | `packages/extension-core/src/background/send-inner.ts` | AAD on seal | +| Modify | `packages/extension-core/src/popup/popup.ts` | a protocol-mismatch line on the link | +| Modify | `packages/cli/src/bridge-errors.ts` | map the mismatch reason to a remedy | +| Modify | `packages/test-helpers/src/index.ts` | mock signature follow-through | +| Create | `packages/server/tests/cross-version/` | frozen v3 fixtures + the refusal suite (Task 7) | +| Modify | `docs/PROTOCOL.md`, `docs/SECURITY.md`, `CLAUDE.md`, `README.md` | the v4 record | + +`packages/bootstrap` has no protocol surface of its own (it composes +`FetchproxyServer`); it needs a release and a smoke test, not an edit. Confirm +with `grep -rn "@fetchproxy/protocol" packages/bootstrap/src` before assuming. + +--- + +## Group 1 — the protocol + +> One PR: branch `feat/protocol-v4`, title +> `feat(protocol)!: per-session ephemerals on both sides, AAD over the frame's identity, and v3 refused at the hello`. +> The `!` is correct here and is the only place in this plan it is: this +> genuinely is the breaking change. **Beware the single-commit squash rule** — +> if this PR ends up as one commit, GitHub squashes on the *commit* subject, so +> the commit subject must carry the `!` and the same text as the title. + +**Task 1.1 — the transcript.** Where: `packages/protocol/src/frames.ts:40` +(`PROTOCOL_VERSION`), `:47` (`HKDF_SESSION_INFO`), `:58` (`readySignaturePayload`), +the `HelloFrameFromServer` interface, `validate.ts:517`. +Test (`packages/protocol/tests/frames.test.ts`, first): `PROTOCOL_VERSION === 4`; +`helloSignaturePayload` is the exact concatenation and changes with each of its +three inputs; `readySignaturePayload` takes four arguments and changes with each; +`transcriptHash` changes with each of its four; `HKDF_SESSION_INFO` is +`'fetchproxy/4.0.0/session'`. In `validate.test.ts`: a hello with +`protocolVersion: 3` is **refused**, not downgraded (the #222 assertion, moved +one version along); a server hello missing `sessionPub` is refused; a +`sessionPub` that is not 32 raw base64 bytes is refused. +Do: implement. Keep each payload function a single concatenation with a doc +comment saying which version widened it, in the style `frames.ts:50-57` already +uses. + +**Task 1.2 — the AAD.** Where: `packages/protocol/src/crypto.ts` (`aesGcmSeal`, +`aesGcmOpen`), `seal.ts:140` (`sealInnerFrame`) and both `openEncryptedFrame*`. +Test (`packages/protocol/tests/seal.test.ts`, first): a frame sealed at +`(mcpId, seq, 's2e')` opens under the same triple and **fails to open** under +`seq + 1`, under another `mcpId`, and under `'e2s'` — three separate +assertions, each failing at the `decrypt-failed` stage (not +`validation-failed`, which would mean the tag passed); `frameAad` is the exact +NUL-separated encoding; `sealedFrameWireBytes` returns the same number it +returned before this task for the same inner frame, and a sealed frame's +`JSON.stringify` length still equals it. +Do: `aad` is a required parameter on the crypto wrappers and `direction` a +required parameter on seal/open — no defaults anywhere, so every call site is a +compile error until it answers. + +**Task 1.3 — `peekHelloVersion`.** Where: `packages/protocol/src/validate.ts`. +Test (`validate.test.ts`, first): a v3 server hello yields +`{protocolVersion: 3, mcpId, accepts}`; a hello with a malformed `mcpId` yields +`mcpId: null` rather than the string; `accepts` holding a non-string drops that +entry; a non-object, a non-`hello` type and a missing `protocolVersion` all +yield `null`; the returned object has no other keys (assert +`Object.keys(...)`, so a field added to the hello later is not echoed out of a +refused frame). +Do: implement by rebuilding member by member. Doc comment states the contract: +it grants nothing, and its only callers are the two refusal paths. + +Commit: `feat(protocol)!: protocol v4 — per-session ephemerals, a transcript-salted session key, and mcpId‖seq‖direction in the AAD`. + +--- + +## Group 2 — the server + +**Task 2.1 — the host.** Where: `packages/server/src/host.ts` (hello built at +`:197` moves to the extension-connect path at `:371`; derivation `:501`; ready +verify `:458`; teardown `:511-528`), `build-server-hello.ts:79`. +Test (`packages/server/tests/host.test.ts`, first): two successive extension +connections to one host receive hellos with **different** `sessionPub` and +`sessionNonce`; the session key derived by a mock extension against the hello's +`sessionPub` matches the host's; a `ready` whose `sessionSig` omits +`mcpSessionPub` from its payload is refused; after the extension socket closes, +the host holds no readable copy of the previous `sessionPriv` (assert through +the exported surface or an injected zeroing hook — do **not** add an accessor +that exists only for the test). +Do: mint `{nonce, pub, priv}` per extension connection; build and send the +hello from it; derive `X25519(sessionPriv, extSessionPub)`; salt with +`transcriptHash`; pass `direction: 's2e'` on seal and `'e2s'` on open; zero and +drop `sessionPriv` in the same statement that nulls `ownSession`. + +**Task 2.2 — the peer.** Where: `packages/server/src/peer.ts:153`, `:275`, +`:385`, `frame-size.ts`. +Test (`packages/server/tests/peer-hello-auth.test.ts` and the integration +suites under `tests/integration/`, first): the peer path derives the same key +the host path does against the same mock extension; a peer whose hello omits +`sessionPub` is refused by the host at registration. +Do: the same five changes. The peer's `requireExtensionIdentity` / +`warnedUnverifiable` branch (`peer.ts:240-271`) is about a pre-1.12.0 *host* +and is orthogonal — leave it, and check its wording still reads right beside a +v4 refusal. + +Commit: `feat(server)!: mint a session ephemeral per extension connection and authenticate every frame against its own identity`. + +--- + +## Group 3 — the extension + +**Task 3.1 — derivation and signature.** Where: +`packages/extension-core/src/background/hello.ts:226-234` (verify) and `:303` +(ephemeral), `approval.ts:135`, `server-hello.ts:136`. +Test (`packages/extension-core/tests/hello.test.ts`, first): a server hello +whose `sessionSig` does not cover `sessionPub` is rejected with a reason naming +the signature; the auto-trust and the approval paths derive the **same** key +for the same hello (they are two code paths deriving one thing and have drifted +before); the `ready` the extension emits signs all four fields. +Do: verify `helloSignaturePayload`; derive against `hello.sessionPub` rather +than `identityX25519Pub`; salt with `transcriptHash`; sign the widened ready +payload. **Trust matching is untouched** — the record is still keyed on +`sha256(identityX25519Pub)` (`hello.ts:238`), so no registration re-pairs +because of v4. Assert that: a trust record written under v3 still auto-trusts +under v4 for the same identity, serverName and domain set. + +**Task 3.2 — AAD on both directions.** Where: `send-inner.ts`, `socket.ts` +(`onEncryptedFrame`, around `:258-300`). +Test (`packages/extension-core/tests/socket.test.ts`, first): a frame the +extension sealed opens on the server side and not under a bumped `seq`; a +server→extension frame replayed under `seq + 1` fails at `decrypt-failed` and +— this is the part that matters — **releases** the claimed seq rather than +committing it, so the next genuine frame is still accepted (the `claimInboundSeq` +/ `releaseInboundSeq` contract `socket.ts` already documents). +Do: `'e2s'` on seal, `'s2e'` on open. + +Commit: `feat(extension)!: derive the session key against the MCP's ephemeral and bind every frame to its id, ordinal and direction`. + +--- + +## Group 4 — the refusal (both directions), and the surfaces that say so + +This is the group that makes the release choreography survivable. Do not let it +be cut for time. + +**Task 4.1 — a v4 extension refuses a v3 MCP, out loud.** Where: +`packages/extension-core/src/background/socket.ts:246-248` (the drop), +`server-hello.ts:81-85` (the existing `hello-rejected` sender). +Test (`packages/extension-core/tests/socket.test.ts`, first): a server hello +with `protocolVersion: 3` and `accepts: ['hello-rejected']` produces exactly one +`hello-rejected` on that link, addressed to that `mcpId`, whose `reason` +contains both numbers and the package version to upgrade to; **no** session is +created, no trust record is read or written, no `mcpId` slot is bound; a v3 +hello **without** `accepts` produces no frame at all (and a warn); a hello that +is malformed for any other reason still takes the existing silent-drop path — +the version mismatch is the only case that answers. +Do: on the `validateFrame` catch, call `peekHelloVersion`; answer only when the +version differs from `PROTOCOL_VERSION`, `mcpId` is non-null and `accepts` +includes `hello-rejected`. Reason text, fixed and asserted: +`protocol version mismatch: this browser extension speaks fetchproxy protocol 4, this MCP speaks 3 — upgrade @fetchproxy/server to >= 3.0.0`. + +**Task 4.2 — a v4 MCP refuses a v3 extension, out loud.** Where: +`packages/server/src/host.ts:277-286` (the `validateFrame` catch that closes +`1002 'protocol error'` today), the extension-hello branch below it, and the +same two places in `peer.ts`. +Test (`packages/server/tests/host.test.ts`, first): a socket that sends an +extension hello with `protocolVersion: 3` is closed within one tick **with a +reason naming both versions** (assert the reason string, not only the code); +the pending `ownSessionReady` rejects immediately with an error whose message +names both versions and the extension version to install — **not** after +`SESSION_READY_TIMEOUT_MS` (assert against a fake clock); a `request()` issued +afterwards fails fast with the same message; a frame that is malformed for any +*other* reason still closes with today's generic `1002 'protocol error'` and +leaves the pending session alone, so the mismatch is the only case that gets +the new treatment. +Do: `peekHelloVersion` in that catch, the same shape as 4.1. Message text, +fixed and asserted: +`protocol version mismatch: this MCP speaks fetchproxy protocol 4, the attached browser extension speaks 3 — update Transporter (the fetchproxy extension) to 3.0.0 or later`. +Name the *extension* by its user-facing name, because the person reading this +in a claude.ai tool error has a browser, not a package. + +**Task 4.3 — the popup says it.** Where: +`packages/extension-core/src/popup/popup.ts` (the per-link status rendering). +Test (`packages/extension-core/tests/popup.test.ts`, first): a link whose last +event was a version-mismatch refusal renders a line naming the MCP's +`serverName` and both versions; the line clears when a v4 hello succeeds on +that link. +Do: the popup's per-link dot is the only signal today (bridge report **I22**), +and the popup is the one surface the *browser* user has. Keep the text to one +line and do not invent a remediation the user cannot perform — the remedy for +this case is on the MCP side. + +**Task 4.4 — the CLI maps it.** Where: `packages/cli/src/bridge-errors.ts`. +Test (`packages/cli/tests/bridge-errors.test.ts`, first): the mismatch reason +maps to a remedy sentence rather than to the generic bridge-unavailable hint. +Do: one entry. `fpx` is how the operator will debug a straggler. + +Commit: `feat(server,extension)!: a version mismatch fails at the hello, naming both versions, instead of hanging for thirty seconds`. + +--- + +## Group 5 — cross-version proof + +**Task 5.1 — the frozen v3 fixtures.** Create +`packages/server/tests/cross-version/v3-fixtures.ts`: byte-literal v3 hello, +ready and frame objects, captured once from 2.11.3 and **hard-coded**, with a +comment saying they are frozen bytes and must never be regenerated from the +current source. This is the whole point: a fixture built from today's +`PROTOCOL_VERSION` is not a v3 fixture, it is a tautology, and #222's own +report records that 128 tests failed precisely because fixtures hand-built the +old version. Freezing them means a future break has a v3 corpus to test +against too. + +**Task 5.2 — the cross-version suite.** Create +`packages/server/tests/cross-version/refusal.test.ts`. Four cases, all +asserting a *clean* outcome within a bounded time and never a timeout: + +1. **v3 mock extension → v4 host.** Host closes 1008, reason names both + versions, `ownSessionReady` rejects immediately, elapsed time is far below + `SESSION_READY_TIMEOUT_MS` (assert against a fake clock, not a wall-clock + threshold). +2. **v3 mock server → v4 extension** (`extension-core` test harness). Exactly + one `hello-rejected`, correct `mcpId`, reason names both versions, no + session, no trust write. +3. **v4 host ↔ v4 extension.** The control: the same rig completes a handshake, + derives matching keys, and round-trips a sealed frame. Without the control + the first two prove only that nothing works. +4. **v3 frame replayed at a v4 session.** A v3-shaped frame (no AAD) offered to + a v4 session fails at `decrypt-failed`, and a v4 frame replayed under + `seq + 1` does too — the M2 regression pinned where a future refactor of + `seal.ts` would trip it. + +**Task 5.3 — the mutation check.** Per +`~/.claude/projects/.../mutation-testing-needs-a-rebuild.md`: cross-package +tests run the built `dist/`, so a mutation without `npm run build` always +survives. Build first, then mutate each of the four v4 facts in turn — drop +`sessionPub` from the hello payload, drop `mcpSessionPub` from the ready +payload, drop `direction` from the AAD, leave `HKDF_SESSION_INFO` at +`1.0.0` — and confirm a test fails for each. Record the four results in the PR +body. A fact with no failing test is a fact the next refactor removes. + +Commit: `test(server,extension): prove a v3 peer meets a v4 host with a clean refusal, against frozen v3 bytes`. + +--- + +## Group 6 — the record + +**Task 6.1 — `packages/protocol/src/frames.ts` header.** Add the v4 paragraph +in the exact style of the 0.4.0 and 2.0.0 ones already at `:1-38`: what moved, +why it is a hard break, why there is no negotiated downgrade, and — new for +this one — that the AAD does not change the wire size so `MAX_FRAME_BYTES` is +unmoved. State the package-major off-by-one from decision 1 here, once. + +**Task 6.2 — `docs/PROTOCOL.md`.** The four wire facts above with their exact +encodings, the two refusal paths and their message texts, and a table of +`PROTOCOL_VERSION` → package major → what changed, so the off-by-one is +readable rather than inferred. + +**Task 6.3 — `docs/SECURITY.md`.** Retract what v3 could not support and state +the new residual precisely. Retract: `:236` "Hosting an MCP does not give the +host the user's cookies, requests or responses" was **true only of a host that +does not also hold the identity** — under v4 it is true of an identity holder +too, and say what changed and when. `:197`/`:326` on replay: v4's AAD closes +it; say so and stop overstating it as already closed. Add the new residuals +(Group 7's list) rather than letting them be discovered. + +**Task 6.4 — `CLAUDE.md`.** §Security model summary items 2 and 3b get the v4 +sentences; the "Current line" paragraph moves to 3.x. Two or three sentences, +in the voice of the existing 2.0.0 note. Do not restate the release +choreography here — it lives in this plan and in the PR body, and a third copy +drifts. + +**Task 6.5 — `README.md` and `packages/extension-chrome/README.md`.** The +install line says which extension version pairs with which package major, and +the "reload after pulling" note is upgraded from advice to a requirement with +the failure it prevents named. + +Commit: `docs(protocol,server): record the v4 break, retract the confidentiality claim v3 could not support, and name what v4 still does not fix`. + +--- + +## Group 7 — the release choreography + +The hard part, and the reason this plan exists. Nothing below is a code task; +all of it is a sequence with a hand on it. + +### What must move together + +| Population | Count (measured 2026-09-11) | How it moves | Latency | +|---|---|---|---| +| `@fetchproxy/*` packages | 6 (4 published, 2 private) | release-please, one combined PR, one `v3.0.0` tag, one publish job | minutes after the release PR merges | +| Cohort npm consumers | **31 `*-mcp` repos** pinned at `^2.10.0` (moving to `^2.11.3` in PR H1), plus `@chrischall/mcp-utils`, whose declaration is a `*` **peer** and needs no range edit | 31 PRs, each `fix(deps):`, each its own release-please cycle, each its own npm publish | hours, and unattended it is days | +| Bridged registrations on mcp-host | **~20** on the shared tier | a source PUT (or `mcp-host update-all`) per registration → new `configHash` → new install slot → builder artifact → restart | minutes if driven; **a night** if left to the `follow` cron | +| The browser extension | effectively **one installed copy** today (see below) | rebuild `dist/`, reload at `chrome://extensions`, or install the GitHub-release `.zip` | seconds | + +The exact cohort, so the executing session does not have to rediscover it: +`alltrails angi artsonia booli canvas-parent compass creditkarma easytable etix +eventbrite evite groupon hemnet homes honeybook infinitecampus jobber musescore +myatriumhealth onehome opentable redfin remind resy setlist signupgenius tock +tripadvisor workday zillow zola` (each `-mcp`), plus `mcp-utils`. Re-derive with +`grep -l '"@fetchproxy/server"' ~/git/*/package.json` before starting; the list +was 32 files on 2026-09-11 and it grows. + +### The ordering, and why + +**Cohort first, extension last.** Both orderings have a broken window — that is +what a hard break means — and the question is only which window the operator +controls. + +- *Extension first* breaks every bridged registration at once and keeps them + broken until 31 npm releases and 20 re-pins have landed. Days. +- *Cohort first* breaks every bridged registration only for as long as it takes + to reload one extension on one machine. Seconds, if the build is already + sitting in `dist/`. + +There is no third option. A transitional extension that accepts both v3 and v4 +is **the downgrade path #222 refused**: a rewriting relay rewrites +`protocolVersion` to 3 and strips `sessionPub`, and the extension does static +DH with a smile. Do not build it, and do not let a reviewer ask for it without +this paragraph in the answer. + +### The sequence + +1. **Gate.** Decisions 1–4 answered. mcp-host PR H1 (the cohort bump to 2.11.3) + is *merged and landed on the fleet*, so this operation moves one range, not + two. mcp-host's `readEnvelope` change (Group 7 §prerequisite, below) is + **live in the gateway**, not merely merged. +2. **fetchproxy 3.0.0.** Groups 1–6 merge; the release PR merges; the `v3.0.0` + tag cuts; the publish job runs. Then, per the fleet rule that a green tag is + not a green publish: `npm view @fetchproxy/protocol version` and the same + for `server`, `bootstrap`, `test-helpers` — all four must read `3.0.0`. If + any does not, re-run `release-please.yml` by `workflow_dispatch` with + `republish_tag: v3.0.0`; the publish step is idempotent. +3. **Build the extension now, before touching the cohort.** `npm run build + --workspace=@fetchproxy/extension-chrome`, confirm + `packages/extension-chrome/dist/manifest.json` reads `3.0.0`, and leave it + there. Per + `~/.claude/projects/.../fetchproxy-extension-needs-rebuild.md`: merged + extension code is not running code until `dist/` is rebuilt and Chrome has + reloaded it. Doing this in step 3 rather than step 6 is what makes step 6 a + single keystroke. +4. **The cohort: 31 PRs.** For each repo, bump `@fetchproxy/server` to + `^3.0.0`, update the lockfile, and open a PR titled + `fix(deps): @fetchproxy/server 3.0.0 — protocol v4 (forward secrecy, AAD over the frame)`. + Four fleet rules bite here and each has drawn blood before: + - **`fix(deps):`, label `bug`** — a first-party bump is not a chore. + `chore:`/`build(deps):`/`dependencies` ships nothing. + - **These are one-commit PRs, so GitHub squashes on the COMMIT subject, not + the PR title.** Make the commit subject identical to the title. This is + the encore-ios #39 shape exactly. + - **No `!`** on any cohort PR. The break is in fetchproxy; a consumer + picking up a new dependency is a `fix`, and an `!` here cuts 31 unwanted + majors. + - **Never add the arming label and never merge.** Each repo's auto-review + pipeline arms its own merge; watch the verdicts and the + `auto-review-followup` issues. + Then confirm publication per repo (`npm view version`), because 31 + publish jobs is 31 chances for the ofw-mcp silent-publish-failure shape. +5. **The fleet, in one window.** Do **not** wait for the nightly `follow` cron: + it re-pins each registration independently, so the fleet would spend a night + half on v3 and half on v4, with every bridged registration broken the whole + time regardless. Drive it: for each of the ~20 bridged registrations, a + scoped `mcp-host update-all` or a per-registration source PUT, then confirm + the installed version on each — the runner logs it, and + `GET /api/v1/registrations/{id}/status` reports `lastInstallOutcome` + (`'artifact'` means the builder's tree, which is what you want to see). + Budget for the builder: each new pin is a new `(pin, lock hash, build)` + triple and therefore a new artifact. +6. **Reload the extension.** `chrome://extensions` → Transporter → reload (or + install the release `.zip`). The outage window closes here. +7. **Verify end to end, not by inspection.** Pick three bridged registrations + that have round-tripped real traffic before — `alltrails`, `tock`, `etix` + are the three mcp-host's CLAUDE.md records as having done it on + 2026-09-09 — and make a real tool call on each. `session_state: linked` and + `extension_connected: true` are necessary, not sufficient: make the call. +8. **Raise the floor** (decision 3). Land mcp-host's `BRIDGE_PROTOCOL_MIN` so a + straggler registration is refused at spawn naming both versions. Do this + *after* step 7, never before: a floor raised while the fleet is mid-move + refuses exactly the registrations you are in the middle of moving. + +### The prerequisite in mcp-host, which is not optional + +`packages/core/src/bridge-frames.ts:32-38` lists the five frame kinds +`readEnvelope` will forward, and `hello-rejected` is not among them +(bridge report **L10**). Over the hosted relay, therefore, Task 4.1's clean +refusal is **discarded by the Durable Object** and the child hangs for +`SESSION_READY_TIMEOUT_MS` exactly as it does today — which means every +mid-window straggler in step 5 looks like a dead connector rather than a +version mismatch, and the message this whole plan exists to deliver never +reaches anyone. + +mcp-host's PR A1 already carries this change (`fix(bridge): forward the +extension's rejection and disconnect frames to the child instead of dropping +them`). **It must be merged and deployed before step 2**, and step 1 checks it +against the live gateway rather than against `main`. If A1 has slipped, this +plan waits. + +### The one-installed-copy property, and the window it is closing + +The extension is distributed today as an unpacked sideload and a GitHub-release +`.zip`. The Chrome Web Store listing is *prepared* — `docs/store-assets/`, +`docs/PRIVACY.md`, the Transporter rebrand, the whole of +`docs/superpowers/plans/2026-05-26-transporter-cws-launch.md` — but not +submitted: `README.md:62` still links +`chromewebstore.google.com/detail/transporter/EXTENSION_ID_PLACEHOLDER`. So +there is no auto-update channel, and step 6 above is a single reload because +there is essentially a single install. + +**That is a property with an expiry date, and it is the argument for shipping +v4 now rather than after the beta.** The moment mcp-host's beta hands +Transporter to people who are not Chris, a wire break stops being one reload +and becomes a support operation against an install base that cannot be pushed +to: every one of those users' bridged MCPs breaks when their registration +re-pins on the nightly cron, and the only thing that tells them why is Task +4.2's tool error — which is exactly why Group 4 is not optional, and exactly +why it is still a worse outcome than shipping first. + +Concretely: **either v4 lands before the beta distributes the extension, or the +CWS submission lands before v4 does.** There is no third arrangement in which +the next wire break is cheap. Chris's call (decision 4); this plan assumes the +first. + +### Rollback + +There isn't one that preserves the fleet — a wire break is symmetric, so +reverting the extension to v3 while the cohort is on v4 is the same outage in +the other direction. What there is: + +- **Keep the v3 extension build.** Before step 3, copy + `packages/extension-chrome/dist/` aside (or keep the v2.11.3 release `.zip` + to hand). Restoring it is a reload — seconds — and it is the only fast half of + a rollback. +- **Keep the previous pin per registration.** Note each bridged registration's + pre-step-5 source pin. Rolling one back is one source PUT each, and the pin + is exact, so nothing has to be resolved again. +- **The cohort is not rolled back.** A published npm version is published; the + way back is a registration pinned to the older one, which the point above + already gives you. + +State in the PR body that a rollback is a *fleet* operation of about the same +size as the rollout, so that nobody plans the rollout on the assumption that +backing out is cheap. + +--- + +## What this does NOT fix, stated plainly + +v4 closes H1 and M2. Everything below stays true the day it ships, and the +`docs/SECURITY.md` rewrite in Task 6.3 must say so rather than let a reader +infer that a version bump fixed the bridge. + +1. **The pair code an MCP shows its user is still the relay's number** + (bridge report **M1**). `ws-server.ts`'s `pairingErrorMessage` (`:1728`, + reached from `:1665`, `:1703`, `:1760`, `:3649`) builds the tool-error text + from the `pair-pending` frame's `pairCode` — a plaintext, unauthenticated + field — while the self-derived code (`host.ts:358`, + `derivePairCodeFromIds`) reaches only the optional `onPairCode` hook, and on + the peer path even that is wired to `onPendingPair` + (`ws-server.ts:1708-1710`). On mcp-host that displayed code **is** the + comparison channel, since a hosted MCP has no terminal, so the SAS compares + the relay's number against the relay's number. This is a server-side display + change (surface only the self-derived code; treat a disagreeing + `pair-pending` as an alarm) and needs **no wire break** — it can and should + ship separately, before or after v4. +2. **The pair code's own strength**, if decision 2 goes the other way. 6 digits, + `SHA256(mcpPub‖extPub)`, both inputs public and long-term, so a MITM grinds + a target in ~10⁶ keygens (**L5**). If it is not folded into this break it + needs its own. +3. **The hosted relay still sees the metadata** (**I19**): serverName, version, + declared domains, capabilities, cookie and storage key *names*, capture + header names, GraphQL operation names, `mcpId`s, frame timing, frame sizes + and per-registration byte counts. v4 protects the contents of frames. It + protects nothing about the shape of the conversation, and the server hello + is plaintext by construction because the extension must render it in a pair + prompt. +4. **A compromised endpoint reads everything.** Forward secrecy is a statement + about *past* sessions and about a party holding an identity. A gateway that + is compromised *while a session is live* and can reach the child's memory — + or an operator who runs the runner — reads the plaintext. mcp-host's + `docs/SECURITY.md` says this about the operator already; v4 does not change + it and must not be read as changing it. +5. **The extension is still trusted on first use over the relay** (**H2**). + `FETCHPROXY_TRUST_DIR` (#352, shipped in 2.11.3) gives the pin a writable + home, but whether a given hosted registration *has* one is an mcp-host + deployment question, not a protocol one. +6. **`Origin: null` and `http://localhost:*` are still admitted** at the + concentrator (**L3**, `host.ts:41` `PUBLIC_ORIGIN_RE`, applied at `:165` + only when an `Origin` is present), and declared domains still have no + public-suffix check (**L7**). Both are in mcp-host's PR F4, not here. +7. **Cloudflare's 1 MiB WebSocket message limit is still below fetchproxy's + 5 MiB body cap** (**L9**), so a large bridged response still tears down the + browser link. That is a chunking change at the protocol layer and would be a + *second* wire change — deliberately not folded in, because it is additive + work with a real design of its own and bundling it would put this break's + schedule behind it. +8. **`Ed25519` key confusion in the extension's trust match** (**L6**): + `hello.ts:261-272` matches on the X25519 hash, serverName, domains and the + extension's own identity, never `record.identityEd25519Pub === + hello.identityEd25519Pub`. No confidentiality loss, and it is a one-line + comparison — but it is not in this plan, so it is not in this release. +9. **mcp-host's F2 is still wanted.** v4 makes an identity holder unable to + decrypt a transcript; F2 makes fewer parties identity holders. Landing v4 is + not a reason to drop F2, and the two doc rewrites must not each claim the + other's ground. + +--- + +## After every PR + +`superpowers:finishing-a-development-branch`; watch the auto-review verdict and +its `auto-review-followup` issue; address findings **on the open PR**; never add +the arming label. When a PR merges, verify with `git diff main..` what +actually landed, not what it intended to. After the release, `npm view` all four +published packages before believing the tag. From 1d1f8e9a05c5f5e41b50560dd4d92683a4910eea Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Fri, 11 Sep 2026 09:23:00 -0400 Subject: [PATCH 2/4] docs(plans): make v4 prove possession of the identity it stops deriving from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four corrections from the review of #361. Proof of possession. Under v3 the extension derives against `identityX25519Pub` — the key its trust record is keyed on — so completing a session IS proof the far end holds the pinned private key, and `hello.ts` never comparing `record.identityEd25519Pub` (L6) is harmless because a party that swapped the signing key still could not compute the session key. v4 inverts that: the key comes from `sessionPub`, bound to an identity only by a signature under the half nothing checks, so an attacker holding nothing but public values presents a trusted `identityX25519Pub` with an Ed25519 key and ephemeral of their own, passes a self-consistent signature check, hits the genuine record on `sha256(identityX25519Pub)`, auto-trusts with no pair prompt, and owns the session. L6 therefore moves off "what this does not fix" and becomes Task 3.3, with a test that fails when the comparison is deleted and a fifth mutation in Task 5.3. The MCP side needs nothing and the plan now says why: it never had the implicit proof, so `decideExtensionTrust` already compares both pinned keys — its own comment is the sentence the extension's match is missing. The peer's ephemeral. "The same five changes" left it per-process, because the peer's only socket goes to the host and outlives every extension session (evictions arrive as fresh `ready` frames, which peer.ts:392 already calls a renegotiation). Task 2.2 now mints on the relayed extension hello and names where the lifetime ends, plus the two host consequences that make it work — the cached peer hello replayed to each new extension (host.ts:374-376) is stale by construction under v4 and the replay goes — and retires the `warnedUnverifiable` branch, which cannot proceed once the transcript salt needs an `extNonce` only that hello carries. The package count. 7 packages, 5 published: `@fetchproxy/cli` publishes and ships a v4 change of its own in Task 4.4. Corrected in the table, in step 2's `npm view` check and in the closing line, with a note that CLAUDE.md's workspace table is where the wrong count came from. The close code. 1002 in both places — the code already there, and the one this file spends on protocol errors rather than the 1008 it spends on identity refusals. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Tar4Eh59YtFuxQy4BpRBQ --- .../2026-09-11-fetchproxy-protocol-v4.md | 227 +++++++++++++++--- 1 file changed, 193 insertions(+), 34 deletions(-) diff --git a/docs/plans/2026-09-11-fetchproxy-protocol-v4.md b/docs/plans/2026-09-11-fetchproxy-protocol-v4.md index 46ecf46..e966954 100644 --- a/docs/plans/2026-09-11-fetchproxy-protocol-v4.md +++ b/docs/plans/2026-09-11-fetchproxy-protocol-v4.md @@ -43,6 +43,12 @@ and a party in the path can replay a recorded frame under a bumped counter, or reflect one back. v4 gives the MCP a per-session ephemeral X25519 covered by its hello signature, derives the session key ephemeral×ephemeral with identities authenticating only, and puts `mcpId ‖ seq ‖ direction` in the AAD. +And because the identities then authenticate *only* — nothing else proves +possession of them any more — v4 has to close the extension's half-pinned trust +match in the same break (**L6**): the hello signature is verified against an +Ed25519 key the trust record must now agree with, where under v3 it need not, +because under v3 the ECDH itself was the proof. Skipping that half would make +v4 an upgrade in confidentiality and a downgrade in authentication. `PROTOCOL_VERSION` 3 → 4; v3 is refused at the hello, with no negotiated downgrade, for the reason #222 already wrote down. @@ -212,6 +218,52 @@ The ready payload gains `mcpSessionPub` so the transcript is bound symmetrically: after v4 neither side's contribution to the ECDH can be substituted without a signature from a long-term key the relay does not hold. +**But a signature is worth only what its verifier pins, and today the extension +pins the wrong half.** Under v3 the extension derives against +`identityX25519Pub` — the very key its trust record is keyed on +(`sha256(identityX25519Pub)`, `hello.ts:237-238`) — so *completing* a session is +itself a proof that the far end holds the pinned private key. The hello +signature is belt-and-braces on top of that, which is why `hello.ts:261-272` +can get away +with never comparing `record.identityEd25519Pub` against +`hello.identityEd25519Pub`: a party that swapped the signing key still could +not compute the session key. That is **L6**, and under v3 it is the harmless +finding an earlier draft of this plan filed it as. + +**v4 inverts it.** The session key now comes from `sessionPub`, and the only +thing binding `sessionPub` to a trusted identity is a signature under +`identityEd25519Pub` — the half nothing checks. So under v4, with L6 still +open, an attacker holding nothing but **public** values impersonates any +trusted MCP: + +1. copy `identityX25519Pub` out of any recorded hello — it is plaintext on the + wire by construction, and on mcp-host the hosted relay sees every one; +2. present it alongside an `identityEd25519Pub` and a `sessionPub` of their own; +3. sign `helloSignaturePayload(mcpId, sessionNonce, sessionPub)` with their own + Ed25519 key — the extension verifies the signature against the key carried + in the *same frame*, so it is self-consistent and passes; +4. the trust lookup on `sha256(identityX25519Pub)` hits the genuine record and + **auto-trusts**, with no pair prompt and no re-pair diff; +5. the extension derives the session key against the attacker's ephemeral. + +The implicit proof of possession that v3 got for free from the ECDH has to be +bought back explicitly, or **v4 is an upgrade in confidentiality and a +downgrade in authentication** — a strictly worse trade than not shipping it. So +L6 is not deferred to a later release: it is **Task 3.3**, and it ships inside +this break because this break is what makes it exploitable. + +The other direction needs nothing, and the asymmetry is worth stating so no +reviewer asks for a symmetric change that already exists. The MCP has never had +implicit proof of possession of the *extension's* identity — the extension's +contribution has been an ephemeral since 0.4.0 — so it already buys the proof +explicitly, and `decideExtensionTrust` (`server/src/extension-trust.ts`) +already compares **both** pinned keys, with the reason in its own comment: +"Both keys, not either: a rotation of one is a different extension, and +accepting a half-match would let an attacker keep the ECDH key it needs while +swapping the signing key it doesn't hold, or the reverse." That is exactly the +sentence the extension's trust match is missing. v4 makes the MCP's rule true +of both ends. + ### 3. The session key is derived from a transcript, ephemeral × ephemeral ``` @@ -320,7 +372,7 @@ construction. | Modify | `packages/server/src/host.ts` | per-connection hello, ephemeral derivation, widened ready verify, AAD, v3 extension refusal, zeroing on teardown | | Modify | `packages/server/src/peer.ts` | the same on the peer path | | Modify | `packages/server/src/frame-size.ts` | seal/measure signature follow-through | -| Modify | `packages/extension-core/src/background/hello.ts` | verify the widened hello signature; derive against `sessionPub` | +| Modify | `packages/extension-core/src/background/hello.ts` | verify the widened hello signature; derive against `sessionPub`; match the pinned `identityEd25519Pub` too (L6, Task 3.3) | | Modify | `packages/extension-core/src/background/approval.ts` | the same on the approval path | | Modify | `packages/extension-core/src/background/server-hello.ts` | sign the widened ready payload | | Modify | `packages/extension-core/src/background/socket.ts` | AAD on open; the v3-server refusal path | @@ -408,16 +460,70 @@ hello from it; derive `X25519(sessionPriv, extSessionPub)`; salt with `transcriptHash`; pass `direction: 's2e'` on seal and `'e2s'` on open; zero and drop `sessionPriv` in the same statement that nulls `ownSession`. -**Task 2.2 — the peer.** Where: `packages/server/src/peer.ts:153`, `:275`, -`:385`, `frame-size.ts`. +**Task 2.2 — the peer, whose "connection" is not a socket.** Where: +`packages/server/src/peer.ts:181` (the hello), `:202` (the nonce), `:275`, +`:350` (the relayed extension hello), `:357` (`extension-disconnected`), `:385` +(derivation), `:392` (the renegotiation it already names), `frame-size.ts`; and +`host.ts:370`, `:374-376`, `:411-420`, `:424`. + +**Do not copy Task 2.1 literally — on this path "per extension connection" has +no socket to hang on.** The host gets one WebSocket per extension and can mint +on its `open`. The peer's single socket goes to the *host*, and it outlives +every extension session: the extension's MV3 evictions arrive as fresh `ready` +frames on that same socket, which `peer.ts:392` already calls a renegotiation. +A peer that mints in `startPeer` therefore holds a per-PROCESS ephemeral reused +across every extension connection for the life of the MCP — on mcp-host up to +ten idle minutes of real traffic per boot, on a laptop days. That is not an +ephemeral, and it is the same "worth having, not worth claiming as forward +secrecy" the hello section refuses for the host. + +**Lifetime, precisely.** It *begins* when the relayed extension hello arrives +(`peer.ts:350`) — the only event on the peer path that marks a new extension +session. Mint `{sessionNonce, sessionPub, sessionPriv}` there and send a +**fresh server hello** to the host in the same handler. It *ends* at whichever +comes first: `extension-disconnected` (`:357`), the next extension hello +superseding it, or the socket to the host closing — zeroing and dropping +`sessionPriv` in the same statement that clears `session`, exactly as Task 2.1 +does for the host. + +**Two consequences in the host, both required for that to work.** + +- The host caches each peer's hello (`peers.set(mcpId, {ws, helloFrame})`, + `:424`) and **replays it to every newly connected extension** (`:374-376`). + Under v4 the cached frame is stale by construction — the private half it + names is gone — so the extension would derive against a key nobody holds. + Drop the replay: the relay the host already performs at `:370` (extension + hello → every peer) is what prompts each peer to hello afresh, and the host + forwards those as they arrive. The cost is one round trip before a peer's + session opens, which the peer already pays waiting for `ready`. +- A same-socket, same-identity re-hello must REPLACE the slot rather than be + refused. It already does — the squat guard at `:411-420` fires only when + `existing.ws !== ws` — but under v4 that is load-bearing rather than + incidental, so assert it. + +**The `warnedUnverifiable` branch dies, and must not be left looking alive.** +`peer.ts:240-271` lets a peer proceed with a warning when the host never +relayed an extension hello (a pre-1.12.0 host). Under v4 it *cannot*: the +transcript salt is `SHA256(mcpNonce ‖ extNonce ‖ …)` and `extNonce` comes only +from that hello, so a peer without it has nothing to derive from. Make the +branch a hard refusal naming the reason, and retire +`requireExtensionIdentity`'s "unless" — a v4 peer behind a v3 host is already +refused at the hello by Task 4.2. (An earlier draft of this plan said to leave +that branch alone and check its wording; that was written before the transcript +salt made it uncomputable.) + Test (`packages/server/tests/peer-hello-auth.test.ts` and the integration -suites under `tests/integration/`, first): the peer path derives the same key -the host path does against the same mock extension; a peer whose hello omits -`sessionPub` is refused by the host at registration. -Do: the same five changes. The peer's `requireExtensionIdentity` / -`warnedUnverifiable` branch (`peer.ts:240-271`) is about a pre-1.12.0 *host* -and is orthogonal — leave it, and check its wording still reads right beside a -v4 refusal. +suites under `tests/integration/`, first): two successive extension hellos +relayed to one peer produce two hellos to the host with **different** +`sessionPub` and `sessionNonce`; the peer derives the same key the host path +does against the same mock extension; after `extension-disconnected` the peer +holds no readable copy of the previous `sessionPriv` (through the exported +surface or an injected zeroing hook, not an accessor that exists only for the +test); a newly connected extension is **not** sent a cached peer hello; a peer +whose hello omits `sessionPub` is refused by the host at registration; a peer +that never receives an extension hello refuses rather than warning. +Do: the five changes of Task 2.1, mounted on the mint point above rather than +on `startPeer`. Commit: `feat(server)!: mint a session ephemeral per extension connection and authenticate every frame against its own identity`. @@ -450,7 +556,34 @@ committing it, so the next genuine frame is still accepted (the `claimInboundSeq / `releaseInboundSeq` contract `socket.ts` already documents). Do: `'e2s'` on seal, `'s2e'` on open. -Commit: `feat(extension)!: derive the session key against the MCP's ephemeral and bind every frame to its id, ordinal and direction`. +**Task 3.3 — the identity that signs is the identity that is pinned (L6, which +v4 makes load-bearing).** Where: +`packages/extension-core/src/background/hello.ts:261-272`. +Test (`packages/extension-core/tests/hello.test.ts`, first): a hello carrying a +trusted record's `identityX25519Pub` but a **different** `identityEd25519Pub`, +with a `sessionSig` that verifies under that different key, does **not** +auto-trust — it falls through to needs-pair, returns no session key and writes +no trust record. That is the impersonation set out in §2 above, and without +this test nothing in the suite fails when the comparison is deleted, which is +how it stayed absent for two majors. Also: the matching pair still auto-trusts, +so no existing registration re-pairs because of this task (the same assertion +Task 3.1 makes, for the same reason); and a legacy record whose +`identityEd25519Pub` is absent or empty falls through to needs-pair rather than +being read as a match. +Do: add `record.identityEd25519Pub !== hello.identityEd25519Pub` to the +`scopeIdentityChanged` disjunction at `:261-272` — one clause in the branch +that is already there, so a mismatch takes the needs-pair path the user can +answer rather than a `reject` the popup cannot show. Do **not** normalise an +absent stored value with `?? hello.identityEd25519Pub`, which turns the check +into a tautology; an absent one must mismatch. Nothing has to be migrated — +`TrustRecord.identityEd25519Pub` is required (`trust-store.ts:115`) and written +unconditionally (`:214`), and a record old enough to lack it is a 0.3.0 +leftover that `extensionIdentityX25519Pub ?? ''` already forces to re-pair, so +its outcome is unchanged. Comment it with the `decideExtensionTrust` sentence +quoted in §2 and a pointer to that function, because after this task the two +halves of one rule live in two packages and only a comment says so. + +Commit: `feat(extension)!: derive the session key against the MCP's ephemeral, pin both halves of its identity, and bind every frame to its id, ordinal and direction`. --- @@ -480,8 +613,9 @@ includes `hello-rejected`. Reason text, fixed and asserted: `1002 'protocol error'` today), the extension-hello branch below it, and the same two places in `peer.ts`. Test (`packages/server/tests/host.test.ts`, first): a socket that sends an -extension hello with `protocolVersion: 3` is closed within one tick **with a -reason naming both versions** (assert the reason string, not only the code); +extension hello with `protocolVersion: 3` is closed within one tick with close +code **1002** and **a reason naming both versions** (assert the reason string as +well as the code); the pending `ownSessionReady` rejects immediately with an error whose message names both versions and the extension version to install — **not** after `SESSION_READY_TIMEOUT_MS` (assert against a fake clock); a `request()` issued @@ -489,8 +623,14 @@ afterwards fails fast with the same message; a frame that is malformed for any *other* reason still closes with today's generic `1002 'protocol error'` and leaves the pending session alone, so the mismatch is the only case that gets the new treatment. -Do: `peekHelloVersion` in that catch, the same shape as 4.1. Message text, -fixed and asserted: +Do: `peekHelloVersion` in that catch, the same shape as 4.1. **The close code +stays `1002`** — the one already there (`host.ts:284`), and the right one: +RFC 6455's 1002 is a protocol error, which a version mismatch exactly is, while +this file spends 1008 on identity and authorization refusals (`:291`, `:314`, +`:327`, `:403`, `:418`, `:474`). What changes is the reason and the immediate +rejection, never the code — which is the same thing this task's paragraph above +says when it calls the close "already there". Task 5.2 case 1 asserts the same +`1002`. Message text, fixed and asserted: `protocol version mismatch: this MCP speaks fetchproxy protocol 4, the attached browser extension speaks 3 — update Transporter (the fetchproxy extension) to 3.0.0 or later`. Name the *extension* by its user-facing name, because the person reading this in a claude.ai tool error has a browser, not a package. @@ -531,7 +671,7 @@ against too. `packages/server/tests/cross-version/refusal.test.ts`. Four cases, all asserting a *clean* outcome within a bounded time and never a timeout: -1. **v3 mock extension → v4 host.** Host closes 1008, reason names both +1. **v3 mock extension → v4 host.** Host closes **1002**, reason names both versions, `ownSessionReady` rejects immediately, elapsed time is far below `SESSION_READY_TIMEOUT_MS` (assert against a fake clock, not a wall-clock threshold). @@ -549,11 +689,13 @@ asserting a *clean* outcome within a bounded time and never a timeout: **Task 5.3 — the mutation check.** Per `~/.claude/projects/.../mutation-testing-needs-a-rebuild.md`: cross-package tests run the built `dist/`, so a mutation without `npm run build` always -survives. Build first, then mutate each of the four v4 facts in turn — drop +survives. Build first, then mutate each of the five v4 facts in turn — drop `sessionPub` from the hello payload, drop `mcpSessionPub` from the ready payload, drop `direction` from the AAD, leave `HKDF_SESSION_INFO` at -`1.0.0` — and confirm a test fails for each. Record the four results in the PR -body. A fact with no failing test is a fact the next refactor removes. +`1.0.0`, and delete the `identityEd25519Pub` comparison Task 3.3 adds — and +confirm a test fails for each. Record the five results in the PR body. A fact +with no failing test is a fact the next refactor removes, which is the whole +history of the fifth one. Commit: `test(server,extension): prove a v3 peer meets a v4 host with a clean refusal, against frozen v3 bytes`. @@ -568,7 +710,10 @@ this one — that the AAD does not change the wire size so `MAX_FRAME_BYTES` is unmoved. State the package-major off-by-one from decision 1 here, once. **Task 6.2 — `docs/PROTOCOL.md`.** The four wire facts above with their exact -encodings, the two refusal paths and their message texts, and a table of +encodings, the verification rule that goes with them (a hello is trusted only +when **both** long-term keys match the pinned record — Task 3.3 — stated beside +the signature it makes meaningful, not in a footnote), the two refusal paths and +their message texts, and a table of `PROTOCOL_VERSION` → package major → what changed, so the off-by-one is readable rather than inferred. @@ -577,7 +722,10 @@ the new residual precisely. Retract: `:236` "Hosting an MCP does not give the host the user's cookies, requests or responses" was **true only of a host that does not also hold the identity** — under v4 it is true of an identity holder too, and say what changed and when. `:197`/`:326` on replay: v4's AAD closes -it; say so and stop overstating it as already closed. Add the new residuals +it; say so and stop overstating it as already closed. Record Task 3.3: under v3 +the extension's trust match could omit the Ed25519 half because the ECDH proved +possession of the pinned key, and under v4 it cannot — so the doc must not carry +the old sentence into the new derivation. Add the new residuals (Group 7's list) rather than letting them be discovered. **Task 6.4 — `CLAUDE.md`.** §Security model summary items 2 and 3b get the v4 @@ -604,11 +752,23 @@ all of it is a sequence with a hand on it. | Population | Count (measured 2026-09-11) | How it moves | Latency | |---|---|---|---| -| `@fetchproxy/*` packages | 6 (4 published, 2 private) | release-please, one combined PR, one `v3.0.0` tag, one publish job | minutes after the release PR merges | +| `@fetchproxy/*` packages | **7 (5 published, 2 private)** | release-please, one combined PR, one `v3.0.0` tag, one publish job | minutes after the release PR merges | | Cohort npm consumers | **31 `*-mcp` repos** pinned at `^2.10.0` (moving to `^2.11.3` in PR H1), plus `@chrischall/mcp-utils`, whose declaration is a `*` **peer** and needs no range edit | 31 PRs, each `fix(deps):`, each its own release-please cycle, each its own npm publish | hours, and unattended it is days | | Bridged registrations on mcp-host | **~20** on the shared tier | a source PUT (or `mcp-host update-all`) per registration → new `configHash` → new install slot → builder artifact → restart | minutes if driven; **a night** if left to the `follow` cron | | The browser extension | effectively **one installed copy** today (see below) | rebuild `dist/`, reload at `chrome://extensions`, or install the GitHub-release `.zip` | seconds | +The seven, counted from `packages/` on 2026-09-11 rather than from a doc: +**published** — `@fetchproxy/protocol`, `@fetchproxy/server`, +`@fetchproxy/bootstrap`, `@fetchproxy/test-helpers`, `@fetchproxy/cli`; +**private** — `@fetchproxy/extension-core`, `@fetchproxy/extension-chrome`. +`cli` is the one that gets missed: `CLAUDE.md`'s workspace table predates it +and still lists six packages and "the other four publish to npm", which is +where an earlier draft of this table got its numbers. It is not a spare part — +Task 4.4 edits `packages/cli/src/bridge-errors.ts`, so `cli` ships a v4 change +and `fpx` is how the operator debugs a straggler in step 5. Correct that table +in Task 6.4 while you are in the file, and verify the count with +`ls packages` before trusting either document. + The exact cohort, so the executing session does not have to rediscover it: `alltrails angi artsonia booli canvas-parent compass creditkarma easytable etix eventbrite evite groupon hemnet homes honeybook infinitecampus jobber musescore @@ -644,7 +804,8 @@ this paragraph in the answer. 2. **fetchproxy 3.0.0.** Groups 1–6 merge; the release PR merges; the `v3.0.0` tag cuts; the publish job runs. Then, per the fleet rule that a green tag is not a green publish: `npm view @fetchproxy/protocol version` and the same - for `server`, `bootstrap`, `test-helpers` — all four must read `3.0.0`. If + for `server`, `bootstrap`, `test-helpers` and `cli` — all **five** must read + `3.0.0`. If any does not, re-run `release-please.yml` by `workflow_dispatch` with `republish_tag: v3.0.0`; the publish step is idempotent. 3. **Build the extension now, before touching the cohort.** `npm run build @@ -761,9 +922,11 @@ backing out is cheap. ## What this does NOT fix, stated plainly -v4 closes H1 and M2. Everything below stays true the day it ships, and the -`docs/SECURITY.md` rewrite in Task 6.3 must say so rather than let a reader -infer that a version bump fixed the bridge. +v4 closes H1, M2 and — because this break is precisely what would have made it +exploitable — **L6**, which an earlier draft of this document deferred to the +list below and which is now Task 3.3. Everything that remains stays true the day v4 +ships, and the `docs/SECURITY.md` rewrite in Task 6.3 must say so rather than +let a reader infer that a version bump fixed the bridge. 1. **The pair code an MCP shows its user is still the relay's number** (bridge report **M1**). `ws-server.ts`'s `pairingErrorMessage` (`:1728`, @@ -809,12 +972,7 @@ infer that a version bump fixed the bridge. *second* wire change — deliberately not folded in, because it is additive work with a real design of its own and bundling it would put this break's schedule behind it. -8. **`Ed25519` key confusion in the extension's trust match** (**L6**): - `hello.ts:261-272` matches on the X25519 hash, serverName, domains and the - extension's own identity, never `record.identityEd25519Pub === - hello.identityEd25519Pub`. No confidentiality loss, and it is a one-line - comparison — but it is not in this plan, so it is not in this release. -9. **mcp-host's F2 is still wanted.** v4 makes an identity holder unable to +8. **mcp-host's F2 is still wanted.** v4 makes an identity holder unable to decrypt a transcript; F2 makes fewer parties identity holders. Landing v4 is not a reason to drop F2, and the two doc rewrites must not each claim the other's ground. @@ -826,5 +984,6 @@ infer that a version bump fixed the bridge. `superpowers:finishing-a-development-branch`; watch the auto-review verdict and its `auto-review-followup` issue; address findings **on the open PR**; never add the arming label. When a PR merges, verify with `git diff main..` what -actually landed, not what it intended to. After the release, `npm view` all four -published packages before believing the tag. +actually landed, not what it intended to. After the release, `npm view` all five +published packages before believing the tag — `cli` included, which is the one a +four-package habit drops. From 0300e8d78c17f71ee4eeef73f9c41a211527bc6e Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Sat, 12 Sep 2026 08:25:01 -0400 Subject: [PATCH 3/4] docs(plans): gate the peer hello on what the frame answers, not on what the slot last saw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third and fourth review rounds on #361. Every finding was a design error, not prose. The per-connection ephemeral rule was right for the host's own hello and wrong for the two other ways a server hello reaches the extension. `host.ts:425` forwards a peer's dial hello to an already-connected extension the instant it arrives, and `:428` then hands that peer the cached extension hello — its mint trigger — so the extension would have answered `ready` for a `sessionPub` the peer had just zeroed; and "mint per connection rather than on `startPeer`" denied the registration hello the `sessionPub` Task 1.1 makes required and `host.ts:387-404` verifies the signature over, which is also the hello that puts the peer in the map the extension relay iterates (`:370`, `:424`) — so without it a peer is never told an extension exists and never reaches the other mint at all. So the rule is stated once, over every path, in §1a: a hello the extension can act on always carries a `sessionPub` whose private half is live until that extension session ends, and a hello that would not satisfy it is never forwarded — plus, new this round, a `ready` naming a `sessionPub` the process no longer holds is DISCARDED rather than refused. The gate for that is now on the FRAME, and the previous round's mark on the slot is retracted with its two failure modes written down. `slot.toldOfExtension === extensionHello` is read after the `await ed25519Verify` at `host.ts:395`, and `:370` re-points every peer's mark inside that window: peer told of E1, re-hellos, handler suspends at `:395`, E1 closes, E2 connects and `:370` sets the mark to E2's hello, handler resumes and the stale hello is forwarded — a 1008 + `rejectFirstReady` on an ordinary MV3 reconnect under the widened ready payload. Capturing the mark synchronously fixes that case and still leaks, because it stands in for "which extension session this hello was minted against" and `peer.ts`'s `onMessage` is async and unserialised, so two mints can be in flight and the later-arriving stale hello passes a captured gate too. So the echo goes on the wire: `answersExtNonce` on the server hello (always 32 bytes, 32 zero bytes for a registration hello — a value, not an absence, so the signed payload keeps a fixed shape), inside a `helloSignaturePayload` widened to four inputs, and the host forwards only when it equals `extensionHello.sessionNonce`. No per-slot state, nothing to clear on a reconnect, nothing to carry across a slot overwrite — `PeerSlot` keeps the two fields it has. Rule C enforces the same fact at both ends: `onServerHello` refuses a hello answering a nonce it did not send, before the mcpId binding (`server-hello.ts:89-94`), and the `ready` carries `mcpSessionPub` on the wire so both server paths can tell a stale answer from a forged one BEFORE verifying the signature — stale discards and changes nothing, current-but-unsigned keeps today's 1008. Task 2.2 gains the interleaving test the un-interleaved dial sequence could not reach, and Task 5.3's mutation list goes from seven facts to ten (the echo; Rule C's comparison, whose mutation must fail both outcomes or neither assertion pins what it claims; and Rule D's commit-point re-check). Two justifications in §1a's table were false and are corrected rather than reworded. The peer's socket close (`peer.ts:507-513`) ends this process's part in an extension session, not the session — the extension's link to the concentrator is untouched — so it is a teardown obligation and off the invariant's list. And `approval.ts:127-130`'s guard is `linkForMcp`, i.e. `mcpLink.get(mcpId)` (`links.ts:98-99`), the concentrator socket shared by the host's mcpId and every peer's, so for a peer it passes while the peer is gone: the extension derives against a dead ephemeral, the host drops the `ready` at `:516-517`, and the extension banks an unusable key until that mcpId hellos again. Stated as a residual with its reason, in place of the claim that "a live link" and "a live MCP ephemeral" are one condition. Task 1.4 was leaving the v3 derivation exported: `derivePairCodeFromIds` (`pair-code.ts:42-47`) is a two-line wrapper over `derivePairCode` (`:17-29`), which IS the v3 reduction and reaches the published API through `protocol/src/index.ts:12`, so both are named for deletion. Its test list now accounts for all eleven `it` blocks in `pair-code.test.ts` — including the three known-answer vectors that hard-code the reduction being replaced and the `>>> 0` high-bit regression whose subject the BigInt read removes — and a table assigns every doc surface that states "6-digit" to an owning task: `protocol/README.md:26`, `frames.ts:455-457` and `:472`, `CLAUDE.md:79` (item 3, which Task 6.4 had omitted), `docs/SECURITY.md:65` (also stale since 0.4.0), `docs/PROTOCOL.md:418-419`, `README.md:44`, `cli/README.md:37`, `extension-chrome/README.md:79`, `docs/PRIVACY.md:63` and the prepared CWS listing at `docs/store-assets/listing-description.md:32` and `:68`. `docs/superpowers/**` is left alone, deliberately. Finally, five "Test (X, first)" targets named files that do not exist, where the plan had run exactly that check on a sixth. Group 3 now opens with the resolutions, each from `grep -rln packages/extension-core/tests`: `hello.test.ts` → `background.test.ts` (Tasks 3.1 and 3.3, the same `handleServerHello` decision), `socket.test.ts` → `session-keys.test.ts` and `replay-counter-after-auth.test.ts` (Task 3.2) and `multi-link.test.ts` (Task 4.1, where `hello-rejected` lives). `protocol/tests/frames.test.ts` stays new and says why. And three citations that did not land: the never-null comment is `peer.ts:236-238`, not `:209-215` (which is the call-time-read comment the same task quotes for the replay repair); the readies are sent at `server-hello.ts:148` and `approval.ts:163`, not at the `type: 'ready'` lines above them; the approval ephemeral is minted at `approval.ts:135`, not `:134`. Fifth round: the mint is an interval, not a step, so §1a's invariant did not hold for a mint that is in flight when its own extension session ends — and Rule C turns that from a 1008 into a silent hang. §1a proved the interleaving itself (`peer.ts`'s `onMessage` is `async` at `:342` and registered as `ws.on('message', onMessage)` at `:501`, whose promise nothing awaits; the v4 mint awaits `generateX25519` and `ed25519Sign`) and then spent it only on the per-slot mark, saying nothing about what a mint owes the variable it assigns. On the host: E1's handler suspends inside the mint, E1's close handler runs to completion (`host.ts:612-641` — `:617` nulls `extensionWs`, `:618` `extensionHello`, `:622` `ownSession`, and the new zeroing takes whatever was installed BEFORE mint 1), the `:290` guard is open so E2 connects and its mint commits, and mint 1 then resumes and overwrites `{nonce, pub, priv}` with E1's. Rule C compares E2's legitimate `ready` against pub1, discards it, and nothing re-mints — the only trigger is an extension hello and E2 has already sent its. E1's private half is also never zeroed again for the life of the process, which is the forward secrecy the group exists to buy. On the peer the same shape ends in silence, where today's `peer.ts:374-381` at least closes 1008 and rejects. So the invariant is stated over COMMITTED rather than minted, with the displacement ordering in it, and Rule D produces it: all crypto into locals, then one synchronous commit point that re-reads the authoritative variable naming the session the mint was for — `extensionWs === ws` on the host (the idiom is already at `host.ts:508`, guarding the v3 derivation against the same close), `extensionHello === frame` on the peer, free because the handler holds its triggering frame. A mint that fails its own check zeroes the half it just produced, installs nothing and sends nothing. §1a's table row 1 loses "same tick", row 3 loses "or on a superseding extension hello", and Task 2.2's table loses "at the next session mint superseding it" — all three presumed mints land in the order they started. Both task test lists gain the case, driven in-process with a held keypair generator and asserting POSITIVELY that the live session opens, because the existing race test is driven at the host with a withheld hello and cannot see two mints interleaved inside one process; Task 5.3 gains the tenth mutation, whose note says why deleting Rule D produces no error at all. The Group 7 prerequisite's evidence had gone stale against mcp-host, and it is the sentence that gates step 2. `:104` and `:1425` cited `packages/core/src/bridge-frames.ts:32-38` for a five-member `BridgeFrameType` without `hello-rejected` — exactly true at the declared `0.62.0` baseline, and not on `main`, where #756 (`a2133a5`, not an ancestor of the release commit) made both diagnosis frames members (`:63-70`) and routed them (`:127-136`). Re-anchored, credited to #756, and the prerequisite reduced to what is actually left: the deploy check that is already step 1, keeping "if it is not live in the gateway, this plan waits". Two citation nits of the class this round already corrected three of: the trust lookup is `hello.ts:237-238`, as §2 already had it, not `:238`; and `trust-store.ts:115`/`:214` are qualified to `packages/extension-core/src/trust-store.ts`, one directory up from Task 3.3's `background/`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Tar4Eh59YtFuxQy4BpRBQ --- .../2026-09-11-fetchproxy-protocol-v4.md | 1049 ++++++++++++++--- 1 file changed, 899 insertions(+), 150 deletions(-) diff --git a/docs/plans/2026-09-11-fetchproxy-protocol-v4.md b/docs/plans/2026-09-11-fetchproxy-protocol-v4.md index e966954..85748b5 100644 --- a/docs/plans/2026-09-11-fetchproxy-protocol-v4.md +++ b/docs/plans/2026-09-11-fetchproxy-protocol-v4.md @@ -7,7 +7,7 @@ > the `file:line` evidence — then only the files a task points at. Execution per > `~/.claude/CLAUDE.md`: Workflow orchestration, one fresh agent per task with > the full task text, sequential (nothing here is parallel-safe — every task -> after Task 1 compiles against Task 1's types), reviewer-gated, then +> after Group 1 compiles against Group 1's types), reviewer-gated, then > `superpowers:finishing-a-development-branch` per PR. > > Line numbers were true on 2026-09-11 at fetchproxy `ad6b673` (2.11.3, @@ -17,7 +17,7 @@ > This is **PR H2** of > `/Users/chris/git/mcp-host/docs/plans/2026-09-11-single-tier-autoscale-and-backlog.md`, > which defers to this document precisely because a wire break is not one PR — -> it is one PR plus a fleet operation plus a hand on the extension. Group 4 +> it is one PR plus a fleet operation plus a hand on the extension. Group 7 > below is the part that is easy to get wrong. > > **Filed in `docs/plans/`, not `docs/superpowers/plans/`**, because the seven @@ -83,7 +83,7 @@ version. The relevant parts, verbatim in substance: can rewrite the field advertising v3 support, and both ends would then agree on the weaker payload." That argument is unchanged and applies verbatim to v4. It also forbids the transitional dual-stack extension that looks like the - obvious way to shrink the outage window in Group 4 — a v4 extension that + obvious way to shrink the outage window in Group 7 — a v4 extension that still accepts v3 IS the downgrade path. - **"All packages together, old version refused at the hello."** 19 files, 6 workspaces, one release, and the 0.4.0 precedent cited for the same shape. @@ -95,19 +95,22 @@ version. The relevant parts, verbatim in substance: *refused* rather than downgraded. One test (`KNOWN RESIDUAL: a relay can still swap the ephemeral key…`) changed sides and kept its name's meaning. -**What is different this time, and it is the whole of Group 4.** In August +**What is different this time, and it is the whole of Group 7.** In August 2026 the only consumers were the operator's own laptop MCPs and a sideloaded extension he reloaded himself; "loudly, at the handshake" was a message he read in his own terminal. Today the failure lands on a hosted relay, on ~20 bridged registrations whose npm pins move on mcp-host's nightly `follow` cron, and the -message has to survive a Durable Object that currently **drops it** -(`mcp-host/packages/core/src/bridge-frames.ts:32-38` — `readEnvelope` knows -five frame kinds and `hello-rejected` is not one of them; bridge report L10). +message has to survive a Durable Object that dropped it until three days ago +(bridge report L10; `readEnvelope` knew five frame kinds and `hello-rejected` +was not one of them — `mcp-host/packages/core/src/bridge-frames.ts:32-38` at the +0.62.0 release commit). The relay code now carries it +(`bridge-frames.ts:63-70`, routed at `:127-136`), which moves the prerequisite +from "write it" to "deploy it" and no further: see §prerequisite in Group 7. So v4 needs a refusal path, not only a refusal. --- -## Decisions required before Task 1 +## Decisions required before Task 1.1 Each has a default; take the default unless Chris says otherwise. @@ -119,30 +122,36 @@ Each has a default; take the default unless Chris says otherwise. either; the alternative (jump the package to 4.0.0) desynchronises the changelog from release-please's own arithmetic for one cosmetic gain. 2. **Does the pair code change in the same break?** Bridge report **L5** — - 6 digits, `SHA256(mcpPub‖extPub)`, both inputs public and long-term, so a - MITM grinds a target code in ~10⁶ keygens. v4 introduces a transcript hash - that already contains both fresh nonces and both ephemerals; deriving the - pair code from it and lengthening to 8 digits is a dozen lines and removes - the grind entirely. It is a wire-visible change and this is the only break - scheduled. **Default: YES, fold it in** (Task 1 mints the transcript hash; - Task 8 re-derives the code from it), because a second break to fix L5 later - costs the whole of Group 4 again. If Chris says no, cut Task 8 and move L5 - to "what this does not fix". + 6 digits, `SHA256(mcpPub‖extPub)`, both inputs public and long-term, so one + OFFLINE grind of ~10⁶ keygens produces a code that stays usable against that + MCP identity forever. v4 mints fresh per-session values on both sides, so the + code can commit to a transcript instead: that makes each grind online and + per-pairing, and 8 digits raises it to ~10⁸. It does not abolish the grind — + a party posing as the extension chooses its own side of the inputs — and + Task 1.4 says so rather than claiming otherwise. It is a wire-visible change + and this is the only break scheduled. **Default: YES, fold it in** — + **Task 1.4**, which is a SECOND transcript over the values both ends hold at + the pair prompt and not `transcriptHash` itself (that one contains the + extension's ephemeral, which does not exist yet when the code is shown; the + task sets out why) — because a second break to fix L5 later costs the whole + of Group 7 again. If Chris says no, cut Task 1.4, drop the `pair-code.ts` row + from the file map and its entry from Task 5.3's mutation list, and leave L5 + where §"What this does NOT fix" item 2 already has it. 3. **Does mcp-host raise a protocol floor for bridged spawns?** Today `assertSandboxableConcentrator` (`mcp-host/packages/runner-node/src/browser-bridge/concentrator-version.ts`, `SANDBOX_CONCENTRATOR_MIN = '2.2.0'`) reads the installed `@fetchproxy/server` version off the tree **before** the spawn and refuses, naming both numbers — but only on the sandboxed path, and only for the - `FETCHPROXY_WS_HOST` reason. **Default: YES** — Group 4 step 6 raises a + `FETCHPROXY_WS_HOST` reason. **Default: YES** — Group 7 step 8 raises a separate `BRIDGE_PROTOCOL_MIN` floor applied to **every** bridged spawn, so a straggler registration is refused at spawn with an actionable message rather than discovered by a hanging tool call. This is a guard for the stragglers, not for the window. -4. **Beta timing.** See Group 4 §"The one-installed-copy property", below. The +4. **Beta timing.** See Group 7 §"The one-installed-copy property", below. The short version: **ship v4 before the extension has an install base outside the operator's machines.** If the beta has already handed Transporter to - strangers on a sideloaded zip with no auto-update, this plan's Group 4 needs + strangers on a sideloaded zip with no auto-update, this plan's Group 7 needs a Chrome Web Store submission in front of it. That is Chris's call and it gates the whole plan, not a task in it. @@ -165,6 +174,10 @@ HelloFrameFromServer { identityEd25519Pub // unchanged — long-term, authenticates sessionNonce // unchanged shape; now minted per CONNECTION (below) sessionPub // NEW: base64 raw 32B, ephemeral X25519 public key + answersExtNonce // NEW: base64 raw 32B — the `sessionNonce` of the + // EXTENSION hello this hello was minted against, + // or 32 zero bytes for a hello that answers none + // (a peer's registration hello). §1a Rule B. sessionSig // WIDENED: see 2 } ``` @@ -175,30 +188,286 @@ identities; nothing about pinning, trust records or re-pair prompts changes. `sessionPub` is a new field beside the identity, not a replacement for it — which is what keeps v4 from re-pairing the fleet the way L11 describes. -**Per CONNECTION, not per process.** Today `ownHello` and its nonce are built -once in `listen()` (`host.ts:197`, `peer.ts:153`) and the same bytes are sent -to every extension connection for the life of the process (bridge report I1: -"the MCP hello nonce is per PROCESS … so freshness rests on the extension's -ephemeral"). A per-process MCP ephemeral would bound forward secrecy at the -process lifetime, which on mcp-host is up to ten idle minutes of real traffic — -worth having, not worth claiming as forward secrecy. **Mint a fresh -`{sessionNonce, sessionPub, sessionPriv}` each time an extension hello arrives, -then build and send the server hello from it.** The host already sends -`ownHello` on extension connect (`host.ts:371`), so the structural change is -that the hello is *built* there rather than at `listen()`, and the private half -is held beside `ownSession` and discarded with it. +**Per EXTENSION SESSION, not per process.** Today `ownHello` and its nonce are +built once at startup (`host.ts:177-197`, `peer.ts:181-203`) and the same bytes +are sent to every extension connection for the life of the process (bridge +report I1: "the MCP hello nonce is per PROCESS … so freshness rests on the +extension's ephemeral"). A per-process MCP ephemeral would bound forward +secrecy at the process lifetime, which on mcp-host is up to ten idle minutes of +real traffic — worth having, not worth claiming as forward secrecy. **Mint a +fresh `{sessionNonce, sessionPub, sessionPriv}` each time an extension hello +arrives, then build and send the server hello from it.** The host already sends +`ownHello` on extension connect (`host.ts:372`), so the structural change is +that the hello is *built* in that handler rather than at startup, and the +private half is held beside `ownSession` and discarded with it. **The private half must die with the session.** Forward secrecy is the property that an identity holder cannot open a *past* session; it is false if the -process keeps every ephemeral private key it ever minted. The session-teardown -path (`host.ts:511-528`, the close handler that nulls `ownSession`) zeroes and -drops `sessionPriv` in the same statement. Assert it. +process keeps every ephemeral private key it ever minted. On the host the +teardown is the extension socket's close handler (`host.ts:612-641`, which +nulls `ownSession` at `:622`); it zeroes and drops `sessionPriv` in the same +statement. Assert it. The peer's teardown is three places rather than one and +is set out in Task 2.2 — `session` there is deliberately never returned to null +(`peer.ts:236-238`, the 2.5.0 `extensionGone` comment: "`session` itself is left +in place — `sendInner` relies on it never returning to null once set"), so "the +same statement that clears `session`" is not available on that path and the +zeroing hangs off the events instead. That is a different comment from +`peer.ts:209-215`, which says `sendInner` reads `session` at call time rather +than at handshake time; Task 2.2 quotes that one for the replay repair, and the +two must not be cited at one range. + +### 1a. The ephemeral's lifetime, as one invariant over every hello + +The per-connection rule above is correct for the host's own hello and says +nothing about the other two ways a server hello reaches the extension — the +host's replay of a cached peer hello (`host.ts:374-376`) and its live forward of +a peer's dial hello (`:425`) — both of which break under a naive reading of it. +Two further paths are not hellos at all but `ready` frames that arrive after +the hello they answer has been superseded: one minutes later, when the user +answers a pair prompt (`approval.ts:111-165`), and one milliseconds later, when +an MV3 reconnect lands an extension hello while a peer's re-hello is still in +flight. The first needs the invariant to hold for longer; the second needs the +invariant to say what happens when it cannot. So the rule this plan holds both +ends to is stated once, over every path, and it is meant to be checked against +the code rather than believed: + +> **Invariant.** Every server hello the extension can act on carries a +> `sessionPub` whose private half is held, by exactly one MCP process, from the +> moment that hello was **committed** until the extension session it was minted +> for ends — which is also the moment after which that `sessionPub` can no +> longer produce a usable `ready`. While it is held it is displaced only by a +> mint for a **later** extension session, never by one for an earlier session +> whose crypto resolved late. A hello that would not satisfy it is never +> forwarded to the extension in the first place — and, when the process learns +> before the send that it minted for a session that has since ended, never +> sent; and a `ready` that names a `sessionPub` this process no longer holds is +> **discarded**, never refused, because the hello that superseded it is already +> on its way. + +Four rules produce it, and each is a change to existing code rather than a +restatement. **Committed**, rather than *minted*, is the load-bearing word, and +Rule D is where it is cashed: the v4 mint is an interval and not a step, so +what a process does across that interval is the difference between this +invariant and a plausible-sounding claim about it. + +- **Rule A — mint on an extension hello, never otherwise.** The host mints in + the extension-hello handler (`host.ts:289`) and sends at `:372`. The peer + mints when the *relayed* extension hello arrives (`peer.ts:349-351`) and + sends a fresh server hello in the same handler. Nothing else mints a session + ephemeral, and nothing reuses one across two extension sessions. +- **Rule B — the host forwards a server hello to the extension only when the + hello NAMES the current extension session.** The gate is + `extensionHello !== null && frame.answersExtNonce === extensionHello.sessionNonce` + — a fixed 32-byte comparison, with no branch for an absent field: a hello + that answers no extension session carries 32 zero bytes, and a nonce minted + from a CSPRNG is not that value. Two sends fail it and both are wrong under + v4 for the same reason: `:374-376` replays each peer's *cached* hello to a newly connected + extension, and `:425` forwards a peer's *registration* hello straight to an + already-connected extension. In both cases the `sessionPub` on the wire is + one Rule A is about to supersede. + + **The gate is on the FRAME, never on the slot, and that is the whole of it.** + An earlier draft of this section gated `:425` on a mark recorded per peer — + `slot.toldOfExtension === extensionHello` — and that is unsound twice over, + both times because the mark is the slot's *latest* state rather than the + frame's provenance: + + 1. **It is read after the await it would have to survive.** The peer-hello + branch awaits `ed25519Verify` at `host.ts:395` before it reaches + `peers.set` at `:424` and the forward at `:425`; the extension-hello + branch awaits `extensionTrust.read()` at `:308`, installs the new + `extensionWs`/`extensionHello` at `:350-351`, and then at `:370` + re-points the mark for **every** peer already in the map. So: peer P is + told of extension session E1 and re-hellos; the host's handler for that + hello suspends at `:395`; E1's socket closes (`:618` nulls + `extensionHello`, `:635-639` sends `extension-disconnected`, which zeroes + P's ephemeral at `peer.ts:356-363`); E2 connects and its handler runs to + completion, so `:370` sets P's mark to E2's hello; the suspended handler + resumes, `:424` writes the slot, and the mark now matches at `:425` — so + the hello minted for E1 is forwarded to E2. All four steps are ordinary — the code treats MV3 + reconnects as routine — and the frame gate cannot be fooled by the + interleaving because it compares the frame's own echo, not a mark + something else moved. + 2. **A captured mark is only a proxy, and the proxy leaks.** Capturing the + mark synchronously at frame arrival (the `host.ts:294-300` pattern) fixes + case 1 but still stands in for "which extension session this hello was + minted against". That proxy holds only while a peer never has two mints + in flight, and it can: `peer.ts`'s `onMessage` is `async` and + unserialised, and the v4 mint awaits `generateX25519` and `ed25519Sign`, + so E1's relayed hello can suspend mid-mint, E2's arrive and complete, and + E1's hello be sent *after* E2's. Both then arrive at a host whose mark is + E2, and the stale one passes a captured gate too. + + That second fact is spent twice in this section, and the halves are not the + same repair. Here it says the gate must read the frame. It also says the late + mint **overwrites the minting process's own current ephemeral** — which no + gate at the host can touch, because by then the damage is a variable in + another process rather than a frame on a wire. Rule D below is that half. + + So the echo goes on the wire (§1 and §2): each server hello carries the + `sessionNonce` of the extension hello it was minted against, inside the + signed payload. The gate needs no per-slot state, nothing to clear on a + reconnect, and nothing to carry across a slot overwrite — a reconnecting + extension has a new nonce, so every hello minted against the old one stops + matching by arithmetic. +- **Rule C — the extension refuses a hello that answers a nonce it did not + send, and the MCP discards a `ready` for an ephemeral it no longer holds.** + Rule B is the host's gate, and a gate that fails open must not be the only + thing standing. So the rule is enforced at both ends, by the party each + failure lands on: + + - **Extension side.** `onServerHello` holds the link and its nonce already + (`server-hello.ts:89-90`, which returns early on `!link.sessionNonce`), so + it refuses a hello whose `answersExtNonce` is not `link.sessionNonce` + **before** `bindMcpToLink` at `:94` — no binding, no trust read, no pair + prompt, and the existing `tellServerWhy` path carries the reason. A hello + that answers 32 zero bytes is refused here too: on the wire that is a + registration hello, and it has no business reaching the extension. + - **MCP side.** The `ready` now carries `mcpSessionPub` explicitly (§2 + already signs it), and both server paths compare it to the ephemeral they + currently hold **before** verifying the signature. Equal → verify as + today, and an invalid signature keeps today's refusal (`host.ts:470-476` + closes 1008; `peer.ts:374-381` closes 1008 and calls `rejectFirstReady`). + Not equal → **discard**: log it, change nothing, close nothing, reject + nothing. That distinction is the point — today a stale `ready` and a + forged one are the same 1008, so an ordinary extension reconnect that + raced a re-hello would strand a bridged MCP on `rejectFirstReady`. The + mismatch branch is reached before any signature has been checked, so + anything that can put a frame on the socket can reach it; it must + therefore cost nothing and change nothing. A relay that rewrites a genuine + echo turns that `ready` into a discarded one, which is a capability it + already has by dropping the frame. +- **Rule D — a mint is installed only if it is still the current one.** Rules A + to C all say "the ephemeral this process currently holds" as though minting + and installing were one step. They are not. The v4 mint awaits + `generateX25519` and `ed25519Sign`, which is the interleaving case 2 above + already proves — seen now from inside the process that mints rather than from + the host that forwards. So a mint does all of its crypto into **locals** and + then **commits** — installs `{nonce, pub, priv}` and sends the hello — at one + **synchronous** point, which first re-reads the single authoritative variable + naming the extension session the mint was for: + + - **Host** (the extension-hello handler, `host.ts:289`): `extensionWs === ws`. + The idiom is already in this file for this exact window — `host.ts:508`, + `if (extensionWs !== ws) return;`, placed after the ECDH and HKDF awaits so + a close during derivation cannot resolve the session promise with a stale + key. Rule D is that guard moved one handshake earlier, to the mint. + - **Peer** (the relayed-extension-hello handler, `peer.ts:349-351`): + `extensionHello === frame`, the triggering frame the handler is already + holding. Free — nothing to record, nothing to look up, and nothing to clear. + + A mint that fails its own check **zeroes the private half it just minted, + installs nothing and sends nothing.** It loses to the newer mint rather than + displacing it; without the check the two are ordered by whichever crypto call + returned first, and each path then fails as follows. + + - **Host.** E1's handler suspends inside the mint. E1's socket closes, and its + close handler runs to completion (`host.ts:612-641`: `:615` releases + `extensionClaim`, `:617` nulls `extensionWs`, `:618` nulls `extensionHello`, + `:620` rejects the pending promise, `:622` nulls `ownSession` and `:630` + resets the promise — and Task 2.1's zeroing takes whatever was installed + **before** mint 1, which on a first connection is nothing at all, because + mint 1 has not landed). The `:290` "extension + already connected" guard is now open, so E2 connects and its own mint + commits. Mint 1 then resumes, overwrites `{nonce, pub, priv}` with E1's and + sends a hello down a closed socket. The extension derived against pub2 and + its `ready` names pub2; Rule C compares against pub1 and **discards** it. + The `ownSessionReady` that `:630` just created (`host.ts:213`, reset at + `:215-222`) stays pending, nothing closes 1008, and nothing re-mints: + the only trigger is an extension hello and E2 has already sent its. Also, + E1's private half is never zeroed again for the life of the process — the + forward secrecy this group exists to buy, lost on the ordinary path. + - **Peer.** The same shape, one relay further out. Handler 1 (E1's relayed + hello) suspends in the mint; `extension-disconnected` arrives and zeroes the + previous half (`peer.ts:356-363`); E2's relayed hello arrives, handler 2 + commits kp2 and sends the hello Rule B forwards; handler 1 resumes and + installs kp1 over kp2. Rule C then discards the extension's legitimate + `ready` for pub2, and nothing re-triggers a mint — this peer's only triggers + are a relayed extension hello (`peer.ts:349`) and none is coming, since the + extension is attached and has already answered. Today `peer.ts:374-381` at + least closes 1008 and rejects `rejectFirstReady`; without Rule D, v4 is + **silence** until the next extension flap. Two handlers can be in flight at + all because `onMessage` is `async` (`peer.ts:342`) and is registered as + `ws.on('message', onMessage)` (`:501`), whose returned promise nothing + awaits. + + Rule D is therefore what lets Rule C be a discard without being a trap: a + discard is right exactly when a superseding hello is already on its way, and + Rule D is what makes "superseding" mean *the mint that committed last* rather + than *the crypto call that returned last*. Deleting Rule D does not reopen a + 1008; it converts a loud failure into a hang, which is why Task 5.3 mutates + it and why both server task test lists carry the interleaving as a case of + their own. + +**The bootstrap mint, which Rule A does not cover and the wire requires.** A +peer cannot wait for an extension hello before it hellos: the host's +`hello/server` dispatch is what *registers* the peer +(`peers.set(frame.mcpId, …)`, `host.ts:424`), and the host relays the extension +hello only to peers already in that map (`:370`) — so a peer that sends no +hello at dial is never told an extension exists and never gets a Rule A +trigger. That registration hello must therefore carry a `sessionPub`, because +Task 1.1 makes the field required and because the host verifies the hello's +signature over +`helloSignaturePayload(mcpId, sessionNonce, sessionPub, answersExtNonce)` +before it will map the slot (`host.ts:387-404`). So `startPeer` mints a +**bootstrap** keypair for it, and sets `answersExtNonce` to 32 zero bytes: at +dial the peer has been told of no extension session, and saying so is what +makes the frame self-describing rather than merely un-forwarded by convention. +The bootstrap keypair is a registration credential and never a session +ephemeral: no key is ever derived from it, and what makes that a fact rather +than an intention is Rule B — the frame says it answers nothing, so the host's +gate refuses to forward it and the extension's own check would refuse it if the +gate ever failed open, and therefore no `ready` can ever name it. Its private +half is zeroed at the first Rule A mint **that commits**, or at the peer +socket's close if none ever does — a mint that loses Rule D's check zeroes only +its own half, so the bootstrap survives it and dies to the mint that won. + +**Every hello the extension can receive, and which ephemeral the `ready` it +sends back corresponds to.** This table is the reviewable form of the +invariant; a path added later owes a row. + +| Hello the extension acts on | Minted | `ready` sent from | Corresponds to | Live at that moment because | +|---|---|---|---|---| +| the host's own hello (`host.ts:372`) | the extension-hello handler (Rule A), committed under Rule D while `extensionWs === ws` — **not** same-tick: the mint awaits | `server-hello.ts:148`, immediately (auto-trust) | that mint | the commit is what makes it current, and from there it is zeroed only by the extension socket's close handler (`host.ts:612-641`), which *is* the end of this extension session. A mint for an EARLIER session resolving late cannot displace it: Rule D makes it zero its own half instead | +| the host's own hello, needs-pair → the user clicks approve minutes later | as above | `approval.ts:163` | that same mint | the approval path skips an mcpId with no live link (`approval.ts:127-130`), and for the HOST's own mcpId that link IS the extension's socket to this host — so the thing the guard reads dies exactly when the mint is zeroed. Not true of a peer's mcpId; see the residual below | +| a peer's Rule A hello, forwarded by the host (`:425`, the hello answering the current extension nonce) | the relayed-extension-hello handler (`peer.ts:349-351`), committed under Rule D while `extensionHello` is still that frame | `server-hello.ts:148` or `approval.ts:163` | that mint | it is zeroed on `extension-disconnected` (`peer.ts:356-363`), or displaced by a LATER session mint at its commit point — never by an earlier one resolving late, which Rule D makes zero its own half. Those are the events that end this extension session; a `ready` arriving after one of them names a superseded `sessionPub` and is discarded by Rule C, not refused | +| a peer's **registration** hello | `startPeer` (bootstrap) | — | nothing | it says so on the wire (`answersExtNonce` = 32 zero bytes), so Rule B will not forward it and Rule C's extension-side check would refuse it anyway; no `ready` can name it | +| a peer's cached hello, replayed to a newly connected extension (`:374-376`) | — | — | — | **path removed** by Task 2.2 | + +Zeroing at the peer's own socket close (`peer.ts:507-513`) is on that list in +Task 2.2 and deliberately **not** in this table's last column. It is a teardown +obligation — this process is leaving and must not keep a private half it can no +longer use — and not one of the moments that ends an extension session: the +socket it closes is the peer's link to the *host*, and the extension's link to +the concentrator is untouched by it. Saying otherwise was the table's own +justification being wrong on a path the table exists to make checkable. + +**The one residual, stated because the guard the approval row leans on cannot +see it.** `approval.ts:127-130` reads `linkForMcp(mcpId)`, which is +`mcpLink.get(mcpId)` (`links.ts:98-99`) — the link the mcpId hello'd on, i.e. +the concentrator socket, shared by the host's own mcpId and every peer's. For a +peer's mcpId that guard therefore **passes while the peer is gone**: the user +approves minutes later, the extension derives against an ephemeral whose +private half died with the peer, sends a `ready`, and the host drops it at +`host.ts:516-517` (`peers.get(mcpId)` is empty). The extension is left holding +a session key in `state.sessions` for a dead MCP — unusable, since nobody holds +the other half, and overwritten by that mcpId's next successful handshake +(`approval.ts:143`). Accepted, with the reason: the peer that would have to +repair it does not exist any more. What Rule C repairs is the neighbouring case +— the peer is alive but has re-minted since the prompt — which was the same +1008 as a forgery and is now a discard. + +The cost is one round trip before a peer's session opens — the peer hellos +again once it hears about the extension — which it already pays waiting for +`ready`. What it buys is that the sequence has exactly one shape on every path: +extension hello → server hello → `ready`, with no cached frame and no +superseded key anywhere in it. ### 2. Both signatures cover both ephemerals ``` -helloSignaturePayload(mcpId, sessionNonce, sessionPub) - = utf8(mcpId) ‖ sessionNonce ‖ sessionPub +helloSignaturePayload(mcpId, sessionNonce, sessionPub, answersExtNonce) + = utf8(mcpId) ‖ sessionNonce ‖ sessionPub ‖ answersExtNonce signed with the MCP's long-term Ed25519 key (v3: mcpId ‖ sessionNonce) readySignaturePayload(mcpNonce, extNonce, extSessionPub, mcpSessionPub) @@ -206,6 +475,15 @@ readySignaturePayload(mcpNonce, extNonce, extSessionPub, mcpSessionPub) signed with the extension's long-term Ed25519 key (v3: without mcpSessionPub) ``` +`answersExtNonce` is **always 32 bytes** — the extension hello's nonce, or 32 +zero bytes when the hello answers no extension session. Fixed-shape rather than +optional, so "answers nothing" is a *value* in the signed payload and not an +absence of bytes: with `sessionNonce` and `sessionPub` fixed at 32 each and +`mcpId` variable at the front, an omitted trailing field would make two +different (mcpId, answers) pairs concatenate to the same message. It is signed +because Rule B's gate reads it: an unsigned echo is one a relay re-points at +whichever extension session it wants the hello delivered to. + The hello payload is the exact mirror of what #222 did for the ready: without it a relay substitutes `sessionPub` for one it holds the private half of, the extension derives against the relay's key, and forward secrecy is fiction. The @@ -239,9 +517,13 @@ trusted MCP: 1. copy `identityX25519Pub` out of any recorded hello — it is plaintext on the wire by construction, and on mcp-host the hosted relay sees every one; 2. present it alongside an `identityEd25519Pub` and a `sessionPub` of their own; -3. sign `helloSignaturePayload(mcpId, sessionNonce, sessionPub)` with their own +3. sign `helloSignaturePayload(mcpId, sessionNonce, sessionPub, answersExtNonce)` + with their own Ed25519 key — the extension verifies the signature against the key carried - in the *same frame*, so it is self-consistent and passes; + in the *same frame*, so it is self-consistent and passes (and it can echo + the extension's own nonce, which is on the wire in front of it, so Rule C's + extension-side check does not stand in the way either — that check is about + staleness, never about identity); 4. the trust lookup on `sha256(identityX25519Pub)` hits the genuine record and **auto-trusts**, with no pair prompt and no re-pair diff; 5. the extension derives the session key against the attacker's ephemeral. @@ -281,7 +563,11 @@ v3 a replayed old `ready` re-establishes an old key with a reset counter nonces and both ephemerals, a replayed `ready` derives a key nothing else holds. -`transcriptHash()` is exported because decision 2 wants it for the pair code. +`transcriptHash()` is exported because the host, the peer and both extension +derivation paths compute it — four call sites for one fact, which is this +section's opening rule. It is **not** what the pair code is derived from: that +needs values both ends hold before any `ready` exists, and Task 1.4 defines a +separate `pairTranscript` for it. ### 4. AAD over the frame's identity @@ -306,7 +592,7 @@ ignore each other's inner types). **The wire size does not change.** GCM's additional data is authenticated, not transmitted. `sealedFrameWireBytes` (`seal.ts`) and the 42 MiB -`MAX_FRAME_BYTES` derivation are untouched, and Task 2 asserts that so nobody +`MAX_FRAME_BYTES` derivation are untouched, and Task 1.2 asserts that so nobody "fixes" the constant later. ### 5. v3 is refused at the hello — and the refusal is heard @@ -353,9 +639,9 @@ contract: `peekHelloVersion(raw: unknown)` returns cast of the parsed object), `mcpId` regex-validated or null, `accepts` filtered to strings, and **it grants nothing** — no caller may start a session, bind an `mcpId` slot, write a trust record or move a counter from its output. Its only -two consumers are the two refusal paths. The `readEnvelope` / rebuild-member-by- -member pattern, and Task 6's test asserts the "grants nothing" half by -construction. +two consumers are the two refusal paths. It is the `readEnvelope` +rebuild-member-by-member pattern, and Task 1.3's test asserts the "grants +nothing" half by construction. --- @@ -363,24 +649,25 @@ construction. | Action | Path | Why | |---|---|---| -| Modify | `packages/protocol/src/frames.ts` | `PROTOCOL_VERSION` 4, `HKDF_SESSION_INFO`, `sessionPub` on the server hello, `helloSignaturePayload`, widened `readySignaturePayload`, `transcriptHash`, `frameAad`, `Direction`, the standing v4 paragraph in the header | -| Modify | `packages/protocol/src/validate.ts` | require `sessionPub`; `peekHelloVersion` | +| Modify | `packages/protocol/src/frames.ts` | `PROTOCOL_VERSION` 4, `HKDF_SESSION_INFO`, `sessionPub` + `answersExtNonce` on the server hello, `mcpSessionPub` on the ready, `helloSignaturePayload`, widened `readySignaturePayload`, `transcriptHash`, `frameAad`, `Direction`, the standing v4 paragraph in the header | +| Modify | `packages/protocol/src/validate.ts` | require `sessionPub`, `answersExtNonce` and the ready's `mcpSessionPub`; `peekHelloVersion` | | Modify | `packages/protocol/src/crypto.ts` | `aesGcmSeal`/`aesGcmOpen` take `aad` | | Modify | `packages/protocol/src/seal.ts` | `direction` required on seal/open; AAD threaded; `sealedFrameWireBytes` unchanged | -| Modify | `packages/protocol/src/pair-code.ts` | decision 2 only — derive from the transcript hash, 8 digits | +| Modify | `packages/protocol/src/pair-code.ts` | decision 2 / Task 1.4 only — one exported `pairTranscript`-based derivation, 8 digits, `derivePairCodeFromIds` deleted | | Modify | `packages/server/src/build-server-hello.ts` | mint/accept the ephemeral, sign `helloSignaturePayload` | | Modify | `packages/server/src/host.ts` | per-connection hello, ephemeral derivation, widened ready verify, AAD, v3 extension refusal, zeroing on teardown | | Modify | `packages/server/src/peer.ts` | the same on the peer path | | Modify | `packages/server/src/frame-size.ts` | seal/measure signature follow-through | | Modify | `packages/extension-core/src/background/hello.ts` | verify the widened hello signature; derive against `sessionPub`; match the pinned `identityEd25519Pub` too (L6, Task 3.3) | -| Modify | `packages/extension-core/src/background/approval.ts` | the same on the approval path | -| Modify | `packages/extension-core/src/background/server-hello.ts` | sign the widened ready payload | +| Modify | `packages/extension-core/src/background/approval.ts` | the same on the approval path, from the stored `sessionPubs` (Task 3.1) | +| Modify | `packages/extension-core/src/background/pending-records.ts` | `sessionPubs` on `PendingPairRecord`, refreshed per `mcpId` like `sessionNonces` | +| Modify | `packages/extension-core/src/background/server-hello.ts` | sign the widened ready payload; carry `mcpSessionPub` on it; refuse a hello whose `answersExtNonce` is not this link's nonce, before the mcpId binding (§1a Rule C) | | Modify | `packages/extension-core/src/background/socket.ts` | AAD on open; the v3-server refusal path | | Modify | `packages/extension-core/src/background/send-inner.ts` | AAD on seal | | Modify | `packages/extension-core/src/popup/popup.ts` | a protocol-mismatch line on the link | | Modify | `packages/cli/src/bridge-errors.ts` | map the mismatch reason to a remedy | | Modify | `packages/test-helpers/src/index.ts` | mock signature follow-through | -| Create | `packages/server/tests/cross-version/` | frozen v3 fixtures + the refusal suite (Task 7) | +| Create | `packages/server/tests/cross-version/` | frozen v3 fixtures + the refusal suite (Tasks 5.1 and 5.2) | | Modify | `docs/PROTOCOL.md`, `docs/SECURITY.md`, `CLAUDE.md`, `README.md` | the v4 record | `packages/bootstrap` has no protocol surface of its own (it composes @@ -401,17 +688,25 @@ with `grep -rn "@fetchproxy/protocol" packages/bootstrap/src` before assuming. **Task 1.1 — the transcript.** Where: `packages/protocol/src/frames.ts:40` (`PROTOCOL_VERSION`), `:47` (`HKDF_SESSION_INFO`), `:58` (`readySignaturePayload`), the `HelloFrameFromServer` interface, `validate.ts:517`. -Test (`packages/protocol/tests/frames.test.ts`, first): `PROTOCOL_VERSION === 4`; +Test — `packages/protocol/tests/frames.test.ts`, a NEW file and deliberately so: +the protocol package has no home for payload-function facts today (`grep -rln +readySignaturePayload packages/protocol/tests` finds none), and the existing +suites are named for what they cover (`seal`, `validate`, `crypto`, +`pair-code`). Write it first: `PROTOCOL_VERSION === 4`; `helloSignaturePayload` is the exact concatenation and changes with each of its -three inputs; `readySignaturePayload` takes four arguments and changes with each; -`transcriptHash` changes with each of its four; `HKDF_SESSION_INFO` is +**four** inputs; `readySignaturePayload` takes four arguments and changes with +each; `transcriptHash` changes with each of its four; `HKDF_SESSION_INFO` is `'fetchproxy/4.0.0/session'`. In `validate.test.ts`: a hello with `protocolVersion: 3` is **refused**, not downgraded (the #222 assertion, moved -one version along); a server hello missing `sessionPub` is refused; a -`sessionPub` that is not 32 raw base64 bytes is refused. +one version along); a server hello missing `sessionPub` or `answersExtNonce` is +refused; either field not 32 raw base64 bytes is refused; a ready missing +`mcpSessionPub` is refused, and so is one whose `mcpSessionPub` is not 32 raw +base64 bytes. Do: implement. Keep each payload function a single concatenation with a doc comment saying which version widened it, in the style `frames.ts:50-57` already -uses. +uses. `answersExtNonce` and `mcpSessionPub` are both required, fixed-length +fields — §2 says why the first is 32 zero bytes rather than absent when a hello +answers no extension session, and that reason belongs in its doc comment. **Task 1.2 — the AAD.** Where: `packages/protocol/src/crypto.ts` (`aesGcmSeal`, `aesGcmOpen`), `seal.ts:140` (`sealInnerFrame`) and both `openEncryptedFrame*`. @@ -438,6 +733,126 @@ refused frame). Do: implement by rebuilding member by member. Doc comment states the contract: it grants nothing, and its only callers are the two refusal paths. +**Task 1.4 — the pair code (decision 2; cut this task if the answer is no).** +Where: `packages/protocol/src/pair-code.ts`, `frames.ts:472` (the `pairCode` +field's `// formatted "XXX-XXX"` comment), `packages/server/src/host.ts:358` +(the `onPairCode` derivation) and +`packages/extension-core/src/background/hello.ts:369-373` (the needs-pair +derivation). Those last two are the only producers — the peer path self-derives +nothing and reads the relay's number instead, which is M1 and stays out of this +break. + +**It cannot be `transcriptHash` itself, and that is the part decision 2 got +wrong.** The session transcript (§3) contains `extSessionPub`, which arrives in +the `ready`; the pair code has to be shown at the pair *prompt*, before any +`ready` exists. The extension has no ephemeral of its own yet at +`hello.ts:369-373` (the auto-trust path mints one at `:303`, the approval path +at `:135` — both after this point), and the host derives at `:358` the moment +the extension hello lands, with no `ready` in hand either. So this is a SECOND, +separate function over the values both ends do hold at the prompt: + +``` +pairTranscript(mcpIdentityX25519Pub, extIdentityX25519Pub, + mcpNonce, extNonce, mcpSessionPub) + = SHA256(utf8('fetchproxy/4/pair') ‖ NUL ‖ mcpIdentityX25519Pub + ‖ extIdentityX25519Pub ‖ mcpNonce ‖ extNonce ‖ mcpSessionPub) + +code = first 8 bytes as a big-endian integer, mod 100_000_000 → "XXXX-XXXX" +``` + +Eight bytes read as a BigInt rather than the existing `uint32` (`pair-code.ts` +reads `h[0..3]`): `2**32 % 10**8` leaves a 2.4% skew across the digit space, +which is sloppy in a SAS and free to avoid; from 64 bits the bias is ~5e-12. +The domain label and the NUL are the `frameAad` convention, for the same +reason. + +**What it buys, stated honestly, because decision 2 overclaims it.** It does +**not** "remove the grind entirely". A MITM posing as the extension chooses its +own identity, its own nonce and its own ephemeral, so it can always grind its +side against a target code; what the two changes do is (a) make that grind +ONLINE and per-session — under v3 both inputs are long-term and public, so one +offline grind against a given MCP identity is reusable against that MCP +forever, while a transcript containing both fresh nonces and the MCP's +ephemeral makes each pairing attempt its own puzzle inside the pairing +window — and (b) raise the online cost from ~10⁶ to ~10⁸ hashes. Say exactly +that in the doc comment and in Task 6.2; "removes the grind" is the sentence a +future reader would rely on. + +Test (`packages/protocol/tests/pair-code.test.ts`, first): the code is +`/^\d{4}-\d{4}$/`; it changes when **each** of the five inputs changes, one +assertion apiece; the byte encoding is exact against a hard-coded vector; both +orders are fixed (MCP's identity before the extension's, MCP's nonce before the +extension's) and swapping either pair yields a different code — the +`derivePairCodeFromIds` doc comment already warns that both sides must agree on +the order, and five inputs is four more chances to disagree. + +**That file is rewritten, not extended, and every one of its eleven `it` +blocks needs an answer** (counted with `grep -c "^\s*it(" \ +packages/protocol/tests/pair-code.test.ts`, which is eleven, one `expect` +apiece) — leaving them to an executing agent's judgement is how a +"format assertion moved" turns into three known-answer vectors quietly +regenerated against the new reduction, which is the one thing a known-answer +vector must never be. Row by row, plus the comment that pins the reduction: + +| `pair-code.test.ts` today | What happens to it | +|---|---| +| `:5-9` `produces XXX-XXX format` (`^\d{3}-\d{3}$`, `:8`) | becomes the `^\d{4}-\d{4}$` assertion over `pairTranscript`'s output | +| `:11-16` deterministic for the same input | kept, restated over the five inputs | +| `:18-22` different inputs → different codes | subsumed by the five one-input-apiece assertions | +| `:24-29` comment pinning "first 4 bytes → big-endian uint32 → mod 1_000_000" | rewritten to pin the 8-byte BigInt reduction, keeping the *reason* (a refactor that moves the hash, the endianness or the width must fail here, not at pair time) | +| `:30-33` known answer `123-181` (all-zero pub) | **deleted.** Its input is a single pub, which `pairTranscript` has no argument for, and a new vector is hard-coded in its place from the five-input encoding | +| `:35-42` known answer `848-182`, the `>>> 0` high-bit regression | **deleted, and this one is a deliberate loss of coverage.** Its whole subject is the signed/unsigned slip in a 32-bit read, and reading 8 bytes as a `BigInt` removes the read that could slip. Say so in the replacement file's comment, so the next reader does not restore a test for a hazard the code no longer has | +| `:44-49` known answer `425-966` (1..32 pub) | **deleted**, same reason as `:30-33` | +| `:51-92` `describe('derivePairCodeFromIds (mutual auth)')` — 5 assertions, including `:60-66`'s `^\d{3}-\d{3}$` and `:85-91`'s "differs from single-arg `derivePairCode`" | **deleted whole.** Both functions it exercises are gone; `:85-91` in particular compares two exports that no longer exist. Its order-sensitivity and MITM-detection intent carry over into the five-input assertions above | + +Do: export ONE function, per this section's opening rule. **Delete both v3 +entry points**, not just the wrapper: `derivePairCodeFromIds` +(`pair-code.ts:42-47`) is a two-line wrapper over `derivePairCode` +(`:17-29`), and `derivePairCode` **is** the v3 derivation — SHA-256 → +`h[0..3]` → uint32 → mod 1e6 → `XXX-XXX` — exported and reaching the published +package's public API through `export * from './pair-code.js'` +(`protocol/src/index.ts:12`). Deleting only the wrapper would leave the thing +"nothing may keep the v3 derivation reachable" is about. So: `pairTranscript` +is the only export, and the digit reduction becomes a module-private helper +rather than a second entry point. +Thread the extension's own nonce into `handleServerHello` as a new `deps` +member — it is `link.sessionNonce` in `server-hello.ts` and the pure function +does not receive it today, which is why this is a signature change rather than +a body change. (Rule C's extension-side check needs the same value and does +**not** depend on this task: it lives in `onServerHello`, which holds `link` +already, so cutting Task 1.4 does not cut Rule C.) + +Then the surfaces outside the protocol source, all of which state the old +format and none of which the earlier draft of this task named. Format +assertions: `packages/extension-core/tests/multi-link.test.ts:432`, `:470` and +`:608` (that last one is `^[A-Z0-9]{3}-[A-Z0-9]{3}$`, a wider charset than the +other two — widen the *count*, not the charset). The popup's `.pair-code` +element must still fit nine characters (`popup.ts:880`) — the string is +rendered verbatim, so that is a CSS check, not a code one. Prose and comments, +each with the task that owns it: + +| Surface | States | Owner | +|---|---|---| +| `packages/protocol/README.md:26` | names `derivePairCode`, `derivePairCodeFromIds`, "6-digit", `SHA256[0..3] mod 1_000_000`, `XXX-XXX` — i.e. two functions this task deletes | **this task** | +| `packages/protocol/src/frames.ts:455-457` | "the same 6-digit joint pair code", the joint derivation spelled out, and "formatted `XXX-XXX`" | **this task** (the earlier draft named only the inline `:472` comment) | +| `packages/protocol/src/frames.ts:472` | `// formatted "XXX-XXX"` | **this task** | +| `packages/extension-core/src/background/server-hello.ts:300`, `packages/server/src/ws-server.ts:1663`, `packages/extension-core/tests/multi-link.test.ts:414` | comments reading "the same XXX-XXX the popup is displaying" | **this task** — comments, so one sed, but they are the sentences a reader trusts | +| `CLAUDE.md:79` (§Security model summary item **3**) | the derivation and `XXX-XXX` | **Task 6.4**, which named only "items 2 and 3b" and is corrected below | +| `docs/SECURITY.md:65` | "The 6-digit pair code (SAS …) is `SHA256(identityX25519Pub)[0..3] mod 1_000_000` formatted as `XXX-XXX`" — and note it states the *single-pub* v3 derivation, which has not been the shipped one since 0.4.0 | **Task 6.3** | +| `docs/PROTOCOL.md:418-419` | `mod 1_000_000` / `formatted "XXX-XXX"` | **Task 6.2** | +| `README.md:44`, `packages/cli/README.md:37`, `packages/extension-chrome/README.md:79` | "6-digit" in the pair-flow walkthrough | **Task 6.5**, extended below to cover them | +| `docs/PRIVACY.md:63` | "the 6-digit code dialog" | **Task 6.5** | +| `docs/store-assets/listing-description.md:32`, `:68` | "6-digit code (e.g. `482-931`)" and "6-digit pair code … derived from a SHA-256 hash of both parties'" — the prepared CWS listing, which Group 7 already treats as live | **Task 6.5** | + +`docs/superpowers/**` also says 6 digits in several places; leave every one of +them. Those are historical plans and specs, and editing them would rewrite +what was decided in May. + +Finally, say in the Group 1 commit BODY that the code the user +compares is now eight digits: it is the one v4 change a person sees with their +own eyes, and a release note silent about it turns "the numbers don't match" +into a support call. + Commit: `feat(protocol)!: protocol v4 — per-session ephemerals, a transcript-salted session key, and mcpId‖seq‖direction in the AAD`. --- @@ -445,61 +860,212 @@ Commit: `feat(protocol)!: protocol v4 — per-session ephemerals, a transcript-s ## Group 2 — the server **Task 2.1 — the host.** Where: `packages/server/src/host.ts` (hello built at -`:197` moves to the extension-connect path at `:371`; derivation `:501`; ready -verify `:458`; teardown `:511-528`), `build-server-hello.ts:79`. +`:177-197` moves into the extension-hello handler at `:289`, whose send is +already at `:372`; derivation `:501`; ready verify `:458`; teardown `:612-641`, +the extension socket's close handler, which nulls `ownSession` at `:622`), +`build-server-hello.ts:79`. Read §1a first: this task is Rules A and D on the +host. "Per connection" is the right shape here — the host does get one socket +per extension — but it is not the whole rule even on this path, because the +mint awaits and the socket can close inside it; Rule D is that half. Test (`packages/server/tests/host.test.ts`, first): two successive extension connections to one host receive hellos with **different** `sessionPub` and -`sessionNonce`; the session key derived by a mock extension against the hello's -`sessionPub` matches the host's; a `ready` whose `sessionSig` omits -`mcpSessionPub` from its payload is refused; after the extension socket closes, -the host holds no readable copy of the previous `sessionPriv` (assert through -the exported surface or an injected zeroing hook — do **not** add an accessor -that exists only for the test). -Do: mint `{nonce, pub, priv}` per extension connection; build and send the -hello from it; derive `X25519(sessionPriv, extSessionPub)`; salt with -`transcriptHash`; pass `direction: 's2e'` on seal and `'e2s'` on open; zero and -drop `sessionPriv` in the same statement that nulls `ownSession`. +`sessionNonce`; the hello's `answersExtNonce` equals the `sessionNonce` of the +extension hello that triggered it, on both connections; the session key derived +by a mock extension against the hello's `sessionPub` matches the host's; a +`ready` whose `sessionSig` omits `mcpSessionPub` from its payload is refused; +after the extension socket closes, the host holds no readable copy of the +previous `sessionPriv` (assert through the exported surface or an injected +zeroing hook — do **not** add an accessor that exists only for the test). +Then Rule C on this path, and note that the two outcomes must be asserted +apart: a `ready` whose `mcpSessionPub` is the host's CURRENT ephemeral but +whose `sessionSig` does not verify closes 1008 (`:470-476`, unchanged) and +rejects the pending session, while a `ready` whose `mcpSessionPub` is a +SUPERSEDED one is **discarded** — the socket stays open, `ownSession` and the +pending promise are untouched (assert the promise is still pending against a +fake clock), and a genuine `ready` arriving afterwards still establishes the +session. A test that only asserts "the stale one does not establish a session" +passes against today's 1008 and so proves nothing. +Then Rule D, which needs its own case because the two above cannot reach it: +they drive one mint at a time, and this is two **inside one process**. Drive it +by injecting a keypair generator whose first call is held (the same shape as +the injected zeroing hook, and for the same reason — it is the only way to be +inside the interval): E1 connects and its mint is held → E1's socket closes → +E2 connects and its mint completes → release E1's. Assert that E2's mock +extension receives **exactly one** server hello, that the `ready` it sends for +that hello's `sessionPub` **establishes the session** (which is the assertion +that fails on the bug — mint 1 having landed last, Rule C discards that `ready` +and the session never opens), that **no** frame reaches E2 from the released +mint, and that the private half mint 1 produced is zeroed. A test asserting +only "E1 gets nothing after its close" passes without Rule D, because a closed +socket swallows the send; the load-bearing assertion is about E2's session +opening. +Do: mint `{nonce, pub, priv}` per extension connection — after the liveness +re-check at `:337-345` and immediately before the send, so a hello the trust +decision refuses mints nothing; set `answersExtNonce` from the triggering +extension hello's `sessionNonce` (it is `frame.sessionNonce`, in scope in that +handler) and sign it. Do **all** of that into locals — `generateX25519` and the +`ed25519Sign` over the widened payload both await — and then commit under +Rule D: **re-read `extensionWs === ws` synchronously; if it has moved, zero the +private half just minted, install nothing, send nothing and return.** Only past +that check assign `{nonce, pub, priv}` and send the hello. The idiom is +`host.ts:508`'s `if (extensionWs !== ws) return;`, which guards the v3 +derivation against the same close; Rule D is the same guard one handshake +earlier, and skipping it lets a mint for a dead session overwrite the live one +(§1a Rule D, host bullet). The liveness re-check at `:337-345` does **not** +serve as that check and must not be mistaken for it: it asks a different +question (`closed || ws.readyState !== WebSocket.OPEN` — this socket's own +health, before the slot is taken and before `extensionWs` has been assigned at +`:350`), and it runs on the wrong side of the mint's awaits. Rule D reads the +authoritative variable, after them. Then derive +`X25519(sessionPriv, extSessionPub)`; salt with `transcriptHash`; pass +`direction: 's2e'` on seal and `'e2s'` on open; zero and drop `sessionPriv` in +the same statement that nulls `ownSession`. In the ready branch, put the +`mcpSessionPub` comparison **before** the `ed25519Verify` at `:466` — the +discard must not depend on a signature check, and putting it after would mean a +stale `ready` is refused for the wrong reason whenever the extension's +signature happens to be over the stale pub (which it always is). **Task 2.2 — the peer, whose "connection" is not a socket.** Where: `packages/server/src/peer.ts:181` (the hello), `:202` (the nonce), `:275`, -`:350` (the relayed extension hello), `:357` (`extension-disconnected`), `:385` -(derivation), `:392` (the renegotiation it already names), `frame-size.ts`; and -`host.ts:370`, `:374-376`, `:411-420`, `:424`. +`:349-351` (the relayed extension hello), `:356-363` (`extension-disconnected`), +`:374-381` (the ready's identity refusal, which Rule C narrows), `:385` +(derivation), `:392` (the renegotiation it already names), `:507-513` +(the socket's close handler), `frame-size.ts`; and `host.ts:370`, `:374-376`, +`:411-420`, `:424`, `:425`, `:428`. **Do not copy Task 2.1 literally — on this path "per extension connection" has no socket to hang on.** The host gets one WebSocket per extension and can mint on its `open`. The peer's single socket goes to the *host*, and it outlives every extension session: the extension's MV3 evictions arrive as fresh `ready` frames on that same socket, which `peer.ts:392` already calls a renegotiation. -A peer that mints in `startPeer` therefore holds a per-PROCESS ephemeral reused -across every extension connection for the life of the MCP — on mcp-host up to -ten idle minutes of real traffic per boot, on a laptop days. That is not an -ephemeral, and it is the same "worth having, not worth claiming as forward +A peer that mints ONLY in `startPeer` therefore holds a per-PROCESS ephemeral +reused across every extension connection for the life of the MCP — on mcp-host +up to ten idle minutes of real traffic per boot, on a laptop days. That is not +an ephemeral, and it is the same "worth having, not worth claiming as forward secrecy" the hello section refuses for the host. -**Lifetime, precisely.** It *begins* when the relayed extension hello arrives -(`peer.ts:350`) — the only event on the peer path that marks a new extension -session. Mint `{sessionNonce, sessionPub, sessionPriv}` there and send a -**fresh server hello** to the host in the same handler. It *ends* at whichever -comes first: `extension-disconnected` (`:357`), the next extension hello -superseding it, or the socket to the host closing — zeroing and dropping -`sessionPriv` in the same statement that clears `session`, exactly as Task 2.1 -does for the host. +**But it still mints in `startPeer`, and §1a says why.** The peer needs TWO +keypairs and they have different jobs, because the peer's hello does two jobs: +it registers the peer with the host *and* it offers a session. An earlier draft +of this plan said to mint "per connection rather than on `startPeer`", which +denies the registration hello the `sessionPub` Task 1.1 makes required and +`host.ts:387-404` verifies the signature over — and a peer that cannot register +is never told an extension exists, so it never reaches the other mint at all. -**Two consequences in the host, both required for that to work.** +| | Bootstrap | Session ephemeral | +|---|---|---| +| Minted | `startPeer`, before the registration hello (`peer.ts:181-203`) | when a relayed extension hello arrives (`peer.ts:349-351`), followed by a **fresh server hello** to the host in the same handler — both at Rule D's commit point, which is `extensionHello === frame` | +| Job | satisfies the required field and signs the registration hello | derives the session key | +| `answersExtNonce` on the hello it signs | 32 zero bytes — at dial this peer has been told of no extension session, and the frame says so | the `sessionNonce` of the relayed extension hello that triggered this mint (`frame.sessionNonce`, in scope in that handler) | +| Derives a key | **never** — the frame carrying it answers nothing, so Rule B below refuses to forward it and no `ready` can name it | yes: `X25519(sessionPriv, extSessionPub)`, salted with `transcriptHash` | +| Zeroed | at the first session mint that COMMITS, or at `:507-513` if none does | at `extension-disconnected` (`:356-363`), at the next session mint's COMMIT POINT, or at `:507-513` — and, if this mint is the one that loses Rule D's check, by itself, before it has installed anything | + +**"The next session mint" means the next one that commits, and the difference is +not pedantry.** Mints do not necessarily land in the order they started: +`onMessage` is `async` (`peer.ts:342`) and registered as +`ws.on('message', onMessage)` (`:501`), so E1's mint can still be inside +`generateX25519` when E2's relayed hello arrives, completes and installs. A mint +that resolves after a later one has committed must therefore zero **its own** +half and install nothing (§1a Rule D); phrased as "superseded at the next mint", +this row would license exactly the overwrite that leaves the peer holding pub1 +while the extension holds pub2, with Rule C then discarding the one legitimate +`ready` and nothing left to re-trigger a mint. + +Zeroing is by EVENT on this path, not by a statement that clears `session`: +`session` is deliberately never returned to null (`peer.ts:236-238`, the 2.5.0 +`extensionGone` comment — **not** `:209-215`, which is the call-time-read +comment this task repairs below), so each of the three events above zeroes and +drops `sessionPriv` where it already handles the extension going away. (The +fourth entry in that row is not an event: a mint that loses Rule D's check +zeroes the half it just produced at its own commit point, which is the one +zeroing that happens before anything was ever installed.) Two of +the three events end the extension session; the third (`:507-513`, the peer's own +socket to the host) does not — it ends this process's part in it, and zeroing +there is a teardown obligation rather than part of §1a's invariant. §1a's table +says which, and why the difference matters for the approval path. + +**Five consequences, all required for that to work.** The first three are in +the host: the first two are one rule — Rule B of §1a — and the third is a guard +that becomes load-bearing. The last two are back in the peer, and they are +Rules C and D. Rule D is listed last and is not optional garnish on Rule C: it +is what stops Rule C's discard from being the only thing that happens. - The host caches each peer's hello (`peers.set(mcpId, {ws, helloFrame})`, `:424`) and **replays it to every newly connected extension** (`:374-376`). Under v4 the cached frame is stale by construction — the private half it - names is gone — so the extension would derive against a key nobody holds. - Drop the replay: the relay the host already performs at `:370` (extension - hello → every peer) is what prompts each peer to hello afresh, and the host - forwards those as they arrive. The cost is one round trip before a peer's - session opens, which the peer already pays waiting for `ready`. + names is gone or about to be — so the extension would derive against a key + nobody holds. Drop the replay: the relay the host already performs at `:370` + (extension hello → every peer) is what prompts each peer to hello afresh, and + the host forwards those as they arrive. +- **The dial path has the same bug and is not fixed by dropping the replay.** + `:425` forwards a peer's hello to an already-connected extension the instant + it arrives, and `:428` then hands that peer the cached extension hello — + which is its Rule A trigger. So a peer joining a live extension would send + its registration hello, have it forwarded, be told about the extension, mint, + re-hello, and leave the extension holding a `ready` for a `sessionPub` whose + private half the peer has just zeroed. Gate `:425` on §1a's Rule B — forward + only when + `extensionHello !== null && frame.answersExtNonce === extensionHello.sessionNonce` + — so a registration hello (which answers 32 zero bytes) never reaches the + extension and the re-hello that follows always does. **Both operands are read + at the forward, one off the frame and one off the single authoritative + variable; nothing is recorded on `PeerSlot` and nothing is carried across a + slot overwrite.** §1a sets out why a per-peer mark cannot work here, and it + is worth restating in one line because the mark is the obvious fix and it is + wrong: `:425` runs **after** the `await ed25519Verify` at `:395`, and `:370` + re-points every peer's mark inside that window, so a mark says which + extension is attached now rather than which one this frame was minted for. An + echo the peer signed says the second thing, which is the question the gate is + asking. - A same-socket, same-identity re-hello must REPLACE the slot rather than be - refused. It already does — the squat guard at `:411-420` fires only when - `existing.ws !== ws` — but under v4 that is load-bearing rather than - incidental, so assert it. + refused, because that re-hello is now the *only* way a peer's session hello + reaches the extension. It already does — the squat guard at `:411-420` fires + only when `existing.ws !== ws`, and `:424` overwrites the slot — but under v4 + that is load-bearing rather than incidental, so assert it. Nothing else has + to survive the overwrite: `PeerSlot` keeps exactly the two fields it has + today (`{ws, helloFrame}`), which is the second reason the gate belongs on + the frame. +- **Rule C on this path, which is where it earns its keep.** After the replay + at `:374-376` is gone, a peer's re-hello is the only route its session hello + takes to the extension — so an extension reconnect that races a re-hello is + the ORDINARY case, not an exotic one, and today it ends in + `peer.ts:374-381`: a 1008 close plus `rejectFirstReady`, which strands the + bridged MCP on a failure an MV3 eviction caused. Compare + `frame.mcpSessionPub` against the ephemeral this peer currently holds before + `authenticateExtension` at `:374`; a mismatch logs and returns, touching + neither `session` nor `extensionGone` nor the promise, and the socket to the + host stays up because the host is not the party at fault. The 1008 stays for + the case it was written for — a `ready` naming the current ephemeral whose + signature does not verify. +- **Rule D on this path, which is what keeps "currently holds" meaningful.** + Rule C compares a `ready` against "the ephemeral this peer currently holds", + and after the bootstrap this handler is the only thing that ever assigns that + value — so if it can assign the wrong one, Rule C reads the wrong one and its + discard becomes a hang. It can: + `onMessage` is `async` (`:342`) and nothing awaits the promise + `ws.on('message', onMessage)` returns (`:501`), so E1's mint can be inside + `generateX25519` while E2's relayed hello arrives and completes, and the two + install in the order their crypto resolved. Do the mint and the hello + signature into **locals**, then commit synchronously and only if + `extensionHello === frame` — the frame that triggered this handler, already in + hand, which is why this costs nothing here. On a mismatch: **zero the private + half just minted, assign nothing, send no hello, return.** The hello it would + have sent is one Rule B refuses to forward anyway, so the send is merely + pointless; the **assignment** is the bug, and it is the one that leaves this + peer holding a superseded pub while the extension holds the live one, with + Rule C discarding the only `ready` there will be and no trigger left to mint + again (§1a Rule D, peer bullet). Note what the mismatch means on this path + and not on the host's: `extensionHello` may be `null` (an + `extension-disconnected` landed inside the mint) as well as a newer frame, and + both are the same refusal to commit. + +**What this changes about renegotiation, which a comment currently gets wrong.** +`peer.ts:209-215` says a renegotiation most commonly happens "after MV3 +service-worker eviction reconnects the browser side and **the host replays our +hello**". After this task there is no replay: the trigger is the host relaying +the new extension hello at `:370` and the peer hellos again. Update that +comment in the same task — a comment naming a path this task deletes is how the +next reader concludes the replay is still there. **The `warnedUnverifiable` branch dies, and must not be left looking alive.** `peer.ts:240-271` lets a peer proceed with a warning when the host never @@ -515,15 +1081,70 @@ salt made it uncomputable.) Test (`packages/server/tests/peer-hello-auth.test.ts` and the integration suites under `tests/integration/`, first): two successive extension hellos relayed to one peer produce two hellos to the host with **different** -`sessionPub` and `sessionNonce`; the peer derives the same key the host path +`sessionPub` and `sessionNonce`, each echoing the `sessionNonce` of the +extension hello that triggered it, while the registration hello sent at dial +echoes 32 zero bytes; the peer derives the same key the host path does against the same mock extension; after `extension-disconnected` the peer -holds no readable copy of the previous `sessionPriv` (through the exported -surface or an injected zeroing hook, not an accessor that exists only for the -test); a newly connected extension is **not** sent a cached peer hello; a peer -whose hello omits `sessionPub` is refused by the host at registration; a peer -that never receives an extension hello refuses rather than warning. -Do: the five changes of Task 2.1, mounted on the mint point above rather than -on `startPeer`. +holds no readable copy of the previous `sessionPriv`, and the same after the +socket to the host closes (through the exported surface or an injected zeroing +hook, not an accessor that exists only for the test); a newly connected +extension is **not** sent a cached peer hello; a peer whose hello omits +`sessionPub` is refused by the host at registration; a peer that never receives +an extension hello refuses rather than warning. +Then the dial path, which is the half an earlier round missed and which the +invariant in §1a is checkable against — **a peer that dials into a host with an +extension already attached**: the extension receives **no** frame for that +`mcpId` until after the peer has been sent the extension hello; it then +receives **exactly one** server hello for it; that hello's `sessionPub` is +**not** the one on the registration hello; the `ready` the mock extension sends +for it opens a frame the peer seals (i.e. the key the extension derived is the +key the peer still holds); and the peer's bootstrap `sessionPriv` is zeroed by +then. Plus the ordering control: with **no** extension attached at dial, the +extension that connects afterwards receives one server hello for that peer and +it is the post-relay one. + +Then the RACE, which the un-interleaved sequence above does not reach and which +is what a per-peer mark passed: **a peer hello minted for the previous +extension session, arriving at the host after `:370` has fanned the next +extension's hello out, is not forwarded.** Drive it at the host with two mock +extensions and a peer whose hello is held: peer told of E1 → its hello for E1 is +withheld → E1's socket closes → E2 connects (so `:370` has run for this peer and +`extensionHello` is E2's) → release the peer's hello for E1. Assert that E2 +receives **no** frame for that `mcpId` from it, that the peer then hellos again +off E2's relay, and that **that** hello is forwarded. Two controls keep it +honest: with E1 still attached the same withheld hello **is** forwarded, so the +test is about the supersession and not about withholding; and the assertion is +on what the extension RECEIVES, not on an internal flag, so an implementation +that gates by some other sound means still passes. +Then Rule C's discard, which is the same race seen from the other end: a +`ready` naming the peer's superseded `sessionPub` leaves `session` in place, +leaves the socket open and does not reject the first-ready promise, while a +`ready` naming the current one with a bad signature still closes 1008 and +rejects. Assert both in the same file — they are one branch and a test of +either alone passes on the pre-Rule-C code. +Then Rule D, and note first why the race test above cannot reach it: that one +is driven **at the host**, withholds a frame on the wire and asserts what the +extension RECEIVES, so it never has two of one peer's mints in flight — the +whole of Rule D is inside the peer process. Drive this one **at the peer**, with +an injected keypair generator whose first call is held (same shape as the +injected zeroing hook): relay E1's extension hello → its mint is held → relay +`extension-disconnected` → relay E2's extension hello, whose mint completes and +whose hello the host receives → release E1's mint. Assert that the peer sends +**no** further hello to the host, that its `sessionPriv` for E1 is zeroed, and — +the assertion that actually fails on the bug — that the `ready` naming **E2's** +`sessionPub` still establishes the session and seals a frame the mock extension +can open. Without Rule D that last one fails as a *hang*, not an error: mint 1 +landed last, so Rule C discards a legitimate `ready` and the first-ready promise +stays pending, which is why asserting it must be positive ("the session opens") +rather than negative ("nothing bad is sent"). Run the same case with a newer +extension hello in place of the `extension-disconnected`, so both mismatch +shapes — `extensionHello` newer and `extensionHello` null — are covered. +Do: the derivation, salt, direction and zeroing changes of Task 2.1, mounted +on the two mint points above, each with the `answersExtNonce` the table gives +it, and each committing under Rule D (`extensionHello === frame`, zero-and-drop +on a mismatch); the host changes (drop the replay, gate `:425` on Rule B, assert +the same-socket re-hello overwrite, Rule C's discard on the ready branch); and +the comment repair. Commit: `feat(server)!: mint a session ephemeral per extension connection and authenticate every frame against its own identity`. @@ -531,24 +1152,90 @@ Commit: `feat(server)!: mint a session ephemeral per extension connection and au ## Group 3 — the extension +**Where the tests go, checked rather than assumed.** `extension-core/tests` +has no `hello.test.ts`, no `socket.test.ts` and no `approval.test.ts`; its +suites are named for the BEHAVIOUR they pin, not for the module. Every target +below was resolved with `grep -rln packages/extension-core/tests` and +each task names the file it found, because a new file beside an existing home +splits one function's coverage in two — the thing Task 3.1's approval note +already says in the one place an earlier draft got right. The resolutions: +`handleServerHello` → `background.test.ts`, `multi-link.test.ts`, +`background-module-surface.test.ts`; `onServerHello` and `onApproval` → +`background.test.ts`; `onEncryptedFrame` and the `claimInboundSeq` / +`releaseInboundSeq` contract → `session-keys.test.ts` and +`replay-counter-after-auth.test.ts`; `hello-rejected` → `multi-link.test.ts` +(extension side) and `server/tests/host.test.ts` (server side). No task in this +plan creates a file under `extension-core/tests`; the one new test file +anywhere is `protocol/tests/frames.test.ts` (Task 1.1, which says why) plus +`server/tests/cross-version/` (Tasks 5.1 and 5.2). + **Task 3.1 — derivation and signature.** Where: `packages/extension-core/src/background/hello.ts:226-234` (verify) and `:303` (ephemeral), `approval.ts:135`, `server-hello.ts:136`. -Test (`packages/extension-core/tests/hello.test.ts`, first): a server hello -whose `sessionSig` does not cover `sessionPub` is rejected with a reason naming -the signature; the auto-trust and the approval paths derive the **same** key -for the same hello (they are two code paths deriving one thing and have drifted -before); the `ready` the extension emits signs all four fields. -Do: verify `helloSignaturePayload`; derive against `hello.sessionPub` rather +Test (`packages/extension-core/tests/background.test.ts`, where +`handleServerHello` and `onServerHello` are both exercised today, first): a +server hello whose `sessionSig` does not cover `sessionPub` is rejected with a +reason naming the signature; the auto-trust and the approval paths derive the +**same** key for the same hello (they are two code paths deriving one thing and +have drifted before); the `ready` the extension emits signs all four fields and +carries `mcpSessionPub` on the wire (Rule C's other end depends on it being +there, not merely signed over). Then Rule C's extension-side half: a hello +whose `answersExtNonce` is not `link.sessionNonce` is refused **before** any +binding — `bindMcpToLink` was not called for that `mcpId` (so a later, correct +hello for the same id is not blocked behind it), no trust record is read or +written, no session key exists, and the reason goes out through the existing +`tellServerWhy`; and a hello whose `answersExtNonce` is 32 zero bytes is +refused the same way, since on the wire that is a registration hello. +Do: verify `helloSignaturePayload`; add the `answersExtNonce` check in +`onServerHello` (`server-hello.ts:89-94`, which already returns early on +`!link.sessionNonce`, so the value is in hand and the check goes above +`bindMcpToLink`); derive against `hello.sessionPub` rather than `identityX25519Pub`; salt with `transcriptHash`; sign the widened ready payload. **Trust matching is untouched** — the record is still keyed on -`sha256(identityX25519Pub)` (`hello.ts:238`), so no registration re-pairs +`sha256(identityX25519Pub)` (`hello.ts:237-238`), so no registration re-pairs because of v4. Assert that: a trust record written under v3 still auto-trusts under v4 for the same identity, serverName and domain set. +**The approval path answers a hello it read back out of storage, so it needs +the `sessionPub` stored beside the nonce.** `approval.ts:111-165` derives from +`approved.sessionNonces[mcpId]` plus `approved.identityX25519Pub` — a long-term +key, which is why a stored record sufficed under v3 and does not under v4. So +`PendingPairRecord` gains `sessionPubs: Record` beside +`sessionNonces` (`pending-records.ts:51`, written at `server-hello.ts:270`), +refreshed per `mcpId` on every hello exactly as the nonce is +(`applyNeedsPairRecord` case 1, `:147-151`) — the refresh is what keeps a +record from naming a superseded ephemeral after a reconnect. Then derive +against `sessionPubs[mcpId]`, salt with `transcriptHash(storedNonce, +link.sessionNonce, storedSessionPub, ephemeral.publicKey)`, and sign the ready +over all four. An entry with **no** `sessionPubs` value — every pending record +already in `chrome.storage.local` when the extension is reloaded — is skipped +with the same warn as a missing nonce (`:119-122`), and the MCP hellos again; +do not fall back to `identityX25519Pub`, which is the v3 derivation reinstated +under a v4 signature. +Test (`packages/extension-core/tests/background.test.ts`, which is where +`onApproval` is exercised today — there is no `approval.test.ts` and this task +should not invent one; check with `grep -rln onApproval +packages/extension-core/tests` before writing, first): approving a +record refreshed by a second hello uses the **second** hello's `sessionPub`, not +the first; a record carrying no `sessionPubs` entry is skipped rather than +derived from the identity key; and the key the approval path derives opens a +frame sealed by the MCP that sent that hello. +Pin the `:127-130` guard with a test that a dead link produces no `ready` — but +do **not** write down that the guard proves the MCP's ephemeral is still live, +because for a PEER's mcpId it does not: `linkForMcp` is `mcpLink.get(mcpId)` +(`links.ts:98-99`), the concentrator socket, which the host's own mcpId and +every peer's share, so it survives the peer that hello'd on it. §1a states that +residual and its consequence (the `ready` is dropped at `host.ts:516-517` and +the extension banks an unusable key). What the approval path owes here is Rule +C's other end: assert that the `ready` it sends carries the **stored** +`sessionPubs[mcpId]` as its `mcpSessionPub`, so an MCP that has re-minted since +the prompt can discard it instead of reading it as a forgery. + **Task 3.2 — AAD on both directions.** Where: `send-inner.ts`, `socket.ts` (`onEncryptedFrame`, around `:258-300`). -Test (`packages/extension-core/tests/socket.test.ts`, first): a frame the +Test (`packages/extension-core/tests/session-keys.test.ts` for the +`onEncryptedFrame` half and `replay-counter-after-auth.test.ts` for the +claim/release half — the two files where those already live; first): a frame the extension sealed opens on the server side and not under a bumped `seq`; a server→extension frame replayed under `seq + 1` fails at `decrypt-failed` and — this is the part that matters — **releases** the claimed seq rather than @@ -559,7 +1246,9 @@ Do: `'e2s'` on seal, `'s2e'` on open. **Task 3.3 — the identity that signs is the identity that is pinned (L6, which v4 makes load-bearing).** Where: `packages/extension-core/src/background/hello.ts:261-272`. -Test (`packages/extension-core/tests/hello.test.ts`, first): a hello carrying a +Test (`packages/extension-core/tests/background.test.ts`, beside Task 3.1's — +the same `handleServerHello` decision, and splitting one function's trust +branch across two files is how the two halves drift; first): a hello carrying a trusted record's `identityX25519Pub` but a **different** `identityEd25519Pub`, with a `sessionSig` that verifies under that different key, does **not** auto-trust — it falls through to needs-pair, returns no session key and writes @@ -576,8 +1265,10 @@ that is already there, so a mismatch takes the needs-pair path the user can answer rather than a `reject` the popup cannot show. Do **not** normalise an absent stored value with `?? hello.identityEd25519Pub`, which turns the check into a tautology; an absent one must mismatch. Nothing has to be migrated — -`TrustRecord.identityEd25519Pub` is required (`trust-store.ts:115`) and written -unconditionally (`:214`), and a record old enough to lack it is a 0.3.0 +`TrustRecord.identityEd25519Pub` is required +(`packages/extension-core/src/trust-store.ts:115` — one directory up from this +task's `background/`, not beside `hello.ts`) and written unconditionally +(`trust-store.ts:214`), and a record old enough to lack it is a 0.3.0 leftover that `extensionIdentityX25519Pub ?? ''` already forces to re-pair, so its outcome is unchanged. Comment it with the `decideExtensionTrust` sentence quoted in §2 and a pointer to that function, because after this task the two @@ -595,7 +1286,8 @@ be cut for time. **Task 4.1 — a v4 extension refuses a v3 MCP, out loud.** Where: `packages/extension-core/src/background/socket.ts:246-248` (the drop), `server-hello.ts:81-85` (the existing `hello-rejected` sender). -Test (`packages/extension-core/tests/socket.test.ts`, first): a server hello +Test (`packages/extension-core/tests/multi-link.test.ts`, which is where the +`hello-rejected` path is exercised today, first): a server hello with `protocolVersion: 3` and `accepts: ['hello-rejected']` produces exactly one `hello-rejected` on that link, addressed to that `mcpId`, whose `reason` contains both numbers and the package version to upgrade to; **no** session is @@ -689,13 +1381,34 @@ asserting a *clean* outcome within a bounded time and never a timeout: **Task 5.3 — the mutation check.** Per `~/.claude/projects/.../mutation-testing-needs-a-rebuild.md`: cross-package tests run the built `dist/`, so a mutation without `npm run build` always -survives. Build first, then mutate each of the five v4 facts in turn — drop -`sessionPub` from the hello payload, drop `mcpSessionPub` from the ready -payload, drop `direction` from the AAD, leave `HKDF_SESSION_INFO` at -`1.0.0`, and delete the `identityEd25519Pub` comparison Task 3.3 adds — and -confirm a test fails for each. Record the five results in the PR body. A fact +survives. Build first, then mutate each of the **ten** v4 facts in turn — drop +`sessionPub` from the hello payload, drop `answersExtNonce` from it (§1a Rule +B's echo is only as good as the signature over it), drop `mcpSessionPub` from +the ready payload, drop `direction` from the AAD, leave `HKDF_SESSION_INFO` at +`1.0.0`, delete the `identityEd25519Pub` comparison Task 3.3 adds, restore the +cached-peer-hello replay and un-gate the `:425` forward Task 2.2 removes (one +mutation: put both sends back), turn Rule C's `mcpSessionPub` comparison into +an unconditional pass on both server paths (the mutation that must fail the +interleaving test AND the "stale is discarded, forged is refused" pair — if +only one of the two fails, the other assertion is not pinning what it claims), +delete Rule D's commit-point re-check on both server paths (one mutation: the +host's `extensionWs === ws` and the peer's `extensionHello === frame` come out +together, and a test must fail for EACH path — a suite covering only one leaves +the other's overwrite unpinned), and — if decision 2 said +yes — drop `mcpSessionPub` from `pairTranscript` +(Task 1.4) — and confirm a test fails for each. Record the ten results in the +PR body (nine if Task 1.4 was cut). A fact with no failing test is a fact the next refactor removes, which is the whole -history of the fifth one. +history of the sixth one; the seventh is there because the two sends it restores +are what the invariant in §1a exists to forbid, and a plan that only *says* +"drop the replay" is exactly the shape that leaves one of the two behind; the +eighth is there because Rule C's two outcomes are one branch, and a suite that +pins only the refusal passes on the code that 1008s a stale `ready`; the ninth +is the one likeliest to survive a careless suite, because deleting Rule D +produces no error and no closed socket — a session that silently never opens — +so the test it must fail has to assert positively that the LIVE session opens, +and a suite whose only Rule D assertion is "the stale hello is not sent" +survives the mutation on a closed socket's swallowed write. Commit: `test(server,extension): prove a v3 peer meets a v4 host with a clean refusal, against frozen v3 bytes`. @@ -709,12 +1422,20 @@ why it is a hard break, why there is no negotiated downgrade, and — new for this one — that the AAD does not change the wire size so `MAX_FRAME_BYTES` is unmoved. State the package-major off-by-one from decision 1 here, once. -**Task 6.2 — `docs/PROTOCOL.md`.** The four wire facts above with their exact +**Task 6.2 — `docs/PROTOCOL.md`.** The wire facts of §§1-5 above with their exact encodings, the verification rule that goes with them (a hello is trusted only when **both** long-term keys match the pinned record — Task 3.3 — stated beside the signature it makes meaningful, not in a footnote), the two refusal paths and -their message texts, and a table of -`PROTOCOL_VERSION` → package major → what changed, so the off-by-one is +their message texts, the ephemeral-lifetime invariant of §1a with its table and its four rules — +including which `ready` is discarded and which is refused, the residual on +the approval path the table names, and **Rule D**, which is the one rule with no +wire footprint at all and is therefore stated as what it is: an implementation +obligation on both ends, without which the invariant above it is unprovable and +the discard of Rule C becomes a hang. Those are the +facts a reader of the wire spec alone cannot reconstruct. Then the `mod +1_000_000` / `XXX-XXX` lines at `:418-419`, the pair code's +new derivation and the honest version of what it buys if Task 1.4 shipped, and a +table of `PROTOCOL_VERSION` → package major → what changed, so the off-by-one is readable rather than inferred. **Task 6.3 — `docs/SECURITY.md`.** Retract what v3 could not support and state @@ -725,19 +1446,38 @@ too, and say what changed and when. `:197`/`:326` on replay: v4's AAD closes it; say so and stop overstating it as already closed. Record Task 3.3: under v3 the extension's trust match could omit the Ed25519 half because the ECDH proved possession of the pinned key, and under v4 it cannot — so the doc must not carry -the old sentence into the new derivation. Add the new residuals -(Group 7's list) rather than letting them be discovered. - -**Task 6.4 — `CLAUDE.md`.** §Security model summary items 2 and 3b get the v4 -sentences; the "Current line" paragraph moves to 3.x. Two or three sentences, +the old sentence into the new derivation. `:65` states the pair code as +`SHA256(identityX25519Pub)[0..3] mod 1_000_000` / `XXX-XXX`, which has not been +the shipped derivation since 0.4.0 made it joint; fix the staleness and the v4 +change in one edit if Task 1.4 ships, and the staleness alone if it is cut. Add +the new residuals +(§"What this does NOT fix, stated plainly", below) rather than letting them be +discovered. + +**Task 6.4 — `CLAUDE.md`.** §Security model summary items 2, **3** and 3b get +the v4 sentences; the "Current line" paragraph moves to 3.x. Item 3 is the pair +code (`:78-80`, `SHA256(mcpPub || extPub)[0..3] mod 1_000_000` formatted +`XXX-XXX`) and an earlier draft of this task named only "2 and 3b", which would +have left the derivation Task 1.4 replaces stated as current in the file every +future agent reads first; `:17`'s "6-digit pair code" in the TL;DR moves with +it. If Task 1.4 is cut, items 2 and 3b are the whole of this task, as before. Two or three sentences, in the voice of the existing 2.0.0 note. Do not restate the release choreography here — it lives in this plan and in the PR body, and a third copy drifts. -**Task 6.5 — `README.md` and `packages/extension-chrome/README.md`.** The -install line says which extension version pairs with which package major, and -the "reload after pulling" note is upgraded from advice to a requirement with -the failure it prevents named. +**Task 6.5 — the user-facing walkthroughs.** `README.md` and +`packages/extension-chrome/README.md`: the install line says which extension +version pairs with which package major, and the "reload after pulling" note is +upgraded from advice to a requirement with the failure it prevents named. +Then, **if Task 1.4 shipped**, the digit count everywhere a person reading a +walkthrough meets it — `README.md:44`, `packages/cli/README.md:37`, +`packages/extension-chrome/README.md:79`, `docs/PRIVACY.md:63`, and +`docs/store-assets/listing-description.md:32` (which prints an example code, +`482-931`, that has to become an eight-digit one) and `:68` (which states the +derivation). The store listing is prepared copy Group 7 already treats as live, +so a stale "6-digit" there ships to the Chrome Web Store rather than to a repo. +Task 1.4's surface table lists these with their owners; this is the task that +holds them. Commit: `docs(protocol,server): record the v4 break, retract the confidentiality claim v3 could not support, and name what v4 still does not fix`. @@ -855,22 +1595,27 @@ this paragraph in the answer. *after* step 7, never before: a floor raised while the fleet is mid-move refuses exactly the registrations you are in the middle of moving. -### The prerequisite in mcp-host, which is not optional - -`packages/core/src/bridge-frames.ts:32-38` lists the five frame kinds -`readEnvelope` will forward, and `hello-rejected` is not among them -(bridge report **L10**). Over the hosted relay, therefore, Task 4.1's clean -refusal is **discarded by the Durable Object** and the child hangs for -`SESSION_READY_TIMEOUT_MS` exactly as it does today — which means every -mid-window straggler in step 5 looks like a dead connector rather than a -version mismatch, and the message this whole plan exists to deliver never -reaches anyone. - -mcp-host's PR A1 already carries this change (`fix(bridge): forward the -extension's rejection and disconnect frames to the child instead of dropping -them`). **It must be merged and deployed before step 2**, and step 1 checks it -against the live gateway rather than against `main`. If A1 has slipped, this -plan waits. +### The prerequisite in mcp-host, which is merged and not yet deployed + +`readEnvelope` forwards the frame kinds listed in +`packages/core/src/bridge-frames.ts`, and until 2026-09-11 there were five of +them with `hello-rejected` not among them (bridge report **L10**). Over the +hosted relay, that made Task 4.1's clean refusal **discarded by the Durable +Object**, with the child hanging for `SESSION_READY_TIMEOUT_MS` instead — which +would mean every mid-window straggler in step 5 looks like a dead connector +rather than a version mismatch, and the message this whole plan exists to +deliver never reaches anyone. + +That change is **on mcp-host `main`**: it landed as **#756** +(`fix(bridge): forward the extension's rejection and disconnect frames to the +child instead of dropping them`, commit `a2133a5`), so `BridgeFrameType` now +carries `hello-rejected` and `extension-disconnected` +(`bridge-frames.ts:63-70`) and `readEnvelope` routes both (`:127-136`). It is +**not** an ancestor of the `0.62.0` release commit this plan's other mcp-host +citations are read at, which is exactly the gap that matters: **merged is not +deployed.** So what is left of this prerequisite is one check, and it is +already step 1 — `readEnvelope`'s change must be **live in the gateway**, not +merely on `main`. If it is not live in the gateway, this plan waits. ### The one-installed-copy property, and the window it is closing @@ -941,10 +1686,14 @@ let a reader infer that a version bump fixed the bridge. change (surface only the self-derived code; treat a disagreeing `pair-pending` as an alarm) and needs **no wire break** — it can and should ship separately, before or after v4. -2. **The pair code's own strength**, if decision 2 goes the other way. 6 digits, - `SHA256(mcpPub‖extPub)`, both inputs public and long-term, so a MITM grinds - a target in ~10⁶ keygens (**L5**). If it is not folded into this break it - needs its own. +2. **The pair code's own strength** (**L5**). If decision 2 goes the other way: + 6 digits over `SHA256(mcpPub‖extPub)`, both inputs public and long-term, so + one offline grind of ~10⁶ keygens is reusable against that MCP forever, and + fixing it needs its own break. If decision 2 goes the default way and Task + 1.4 ships, a residual remains and belongs in `docs/SECURITY.md` rather than + being dropped from this list: a party posing as the extension still chooses + its own side of the transcript, so it can grind ~10⁸ hashes *online, inside + one pairing window*, against a user who will compare only the digits. 3. **The hosted relay still sees the metadata** (**I19**): serverName, version, declared domains, capabilities, cookie and storage key *names*, capture header names, GraphQL operation names, `mcpId`s, frame timing, frame sizes From 588453ae2f43632009baeb808b94919c489aeb6f Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Sat, 12 Sep 2026 09:41:05 -0400 Subject: [PATCH 4/4] docs(plans): gate the host's re-send of the extension hello, or every peer session establishment loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule A makes a relayed extension hello a mint trigger, and the plan reasoned about only one of the two ways one reaches a peer. `host.ts:428` sends the cached extension hello at the tail of the peer-hello branch unconditionally — on every peer hello, on the same socket, with no first-time gate — so the Rule A re-hello that Rule B's gate on `:425` exists to let through draws another trigger back: re-hello, forwarded, extension hello re-sent, mint, re-hello, unbounded. Rule B passes every turn (each re-hello answers the live session), Rule D commits every turn (`extensionHello === frame` is the frame the handler was entered with), and Rule C is downstream of a session that never settles. The same loop runs off `:370` on an ordinary MV3 reconnect, so under the rules as drafted every peer session establishment on both paths spins — and two things this document already asserts were unsatisfiable rather than unproven: Task 2.2's "exactly one server hello" and §1a's table row for `:425`. The fix taken is Rule B mirrored: hand a peer the cached extension hello only in answer to a hello that answers NO extension session. It is the complement of the same predicate over the same signed field, it reads no authoritative variable at all, and it makes the host's two relays mutually exclusive per (peer, extension session), so "exactly one" follows from the two sends rather than from a counter. The peer-side alternative — mint only when the arriving nonce differs from the committed ephemeral's `answersExtNonce` — is recorded with what it would buy (robustness to a duplicating relay) and why it is declined (a second fact carried across Rule D's interval). So: Rule A gains a trigger table, because the "a path added later owes a row" rule applies to the trigger side too; Rule B gains the mirror and the trace; the invariant gains its liveness half, without which the safety half is satisfied by a process that mints forever; Task 1.1 gains the one export the "answers nothing" value now owes; Task 2.2 gains the consequence (now six), the two-direction gate assertion, an end-to-end "one hello, one keygen per peer per extension session" run to QUIESCENCE rather than for N turns, and the Do line; Task 5.3 gains an eleventh mutation, whose failure mode is no error and no closed socket; Task 6.2 gains both. The liveness half is stated over SESSION-EPHEMERAL mints, and names the one mint outside that accounting where the invariant is stated rather than 250 lines later: the peer's bootstrap keypair, minted by `startPeer` (`peer.ts:181-203`) because the host will not map a slot for a hello it cannot verify, drawn by a dial and by no extension session at all. Unqualified, the clause "every mint is drawn by an extension session" was false of it, in the one blockquote this plan says is meant to be checked against the code. That export's population is stated exactly rather than counted twice and differently. Two of the places that looked like readers are not: Rule B's own gate at `host.ts:425` is a fixed comparison against the live nonce and §1a forbids it branching on this value, and Rule C's extension-side refusal is `answersExtNonce !== link.sessionNonce`, which the zero value fails anyway because the nonce is CSPRNG. So one writer, one reader, plus the wire and the vectors — and §1a's Rule C bullet says so too, where "refused here too" could have been read as asking for a second call. Also, the citations, this time swept on CONTENT rather than on which file a bare cite belongs to — for every cite carrying a quotation or a paraphrase, whether the cited line says it. Five were off: `docs/SECURITY.md:236` for the "Hosting an MCP does not give the host the user's cookies, requests or responses" sentence, which is at `:239` (`:236` is the section's `2.1.0+` intro); `:197`/`:326` on replay, which are blank lines either side of the paragraphs meant, now `:198`/`:327`; `host.ts:290` quoted for "extension already connected", which is the guard's condition, with the message at `:291`; `CLAUDE.md:79` for a derivation stated at `:78`, now the `:78-80` Task 6.4 itself cites; and the `pair-code.test.ts:24-29` comment, quoted in a shortened form, now verbatim. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Tar4Eh59YtFuxQy4BpRBQ --- .../2026-09-11-fetchproxy-protocol-v4.md | 352 ++++++++++++++---- 1 file changed, 279 insertions(+), 73 deletions(-) diff --git a/docs/plans/2026-09-11-fetchproxy-protocol-v4.md b/docs/plans/2026-09-11-fetchproxy-protocol-v4.md index 85748b5..e52a45f 100644 --- a/docs/plans/2026-09-11-fetchproxy-protocol-v4.md +++ b/docs/plans/2026-09-11-fetchproxy-protocol-v4.md @@ -112,7 +112,8 @@ So v4 needs a refusal path, not only a refusal. ## Decisions required before Task 1.1 -Each has a default; take the default unless Chris says otherwise. +The first three have a default; take the default unless Chris says otherwise. +The fourth is **answered** and is recorded here as settled rather than offered. 1. **Package major.** v4 ships as `@fetchproxy/*` **3.0.0**. The off-by-one between package major and protocol version (package 2.x ↔ protocol 3; @@ -148,12 +149,19 @@ Each has a default; take the default unless Chris says otherwise. a straggler registration is refused at spawn with an actionable message rather than discovered by a hanging tool call. This is a guard for the stragglers, not for the window. -4. **Beta timing.** See Group 7 §"The one-installed-copy property", below. The - short version: **ship v4 before the extension has an install base outside - the operator's machines.** If the beta has already handed Transporter to - strangers on a sideloaded zip with no auto-update, this plan's Group 7 needs - a Chrome Web Store submission in front of it. That is Chris's call and it - gates the whole plan, not a task in it. +4. **Beta timing — SETTLED, 2026-09-12: ship v4 now.** The operator's answer + is that **nothing has left his walls** — Transporter has no install base + outside his own machines. Three things follow and the rest of this plan is + written on them rather than around them: Group 7 needs **no** Chrome Web + Store submission in front of it; step 6 is a single reload; and + §"The one-installed-copy property" is a measured fact today, not an + assumption the executing session has to re-check. What does **not** change + is the deadline logic, which is the reason this is settled rather than + merely convenient: every day the listing stays unsubmitted is a day this + break stays cheap, and the answer expires the moment the beta hands the + extension to somebody who is not Chris. So this is a gate that is now open, + with the *other* arrangement (CWS submission first) still the only one + available if v4 slips past that moment. --- @@ -243,6 +251,22 @@ the code rather than believed: > sent; and a `ready` that names a `sessionPub` this process no longer holds is > **discarded**, never refused, because the hello that superseded it is already > on its way. +> +> **And it has a LIVENESS half, which is the trigger side of the same rule:** +> one extension session draws exactly **one** SESSION-EPHEMERAL mint per MCP +> process, and every session-ephemeral mint is drawn by an extension session. +> *Session-ephemeral* is what makes the second clause true rather than nearly +> true, and the exception is named here rather than left to a later paragraph: +> a peer also mints a **bootstrap** keypair, once, in `startPeer` +> (`peer.ts:181-203`), drawn by a dial and by no extension session at all — +> the host will not map a slot for a hello whose signature it cannot verify, +> and Task 1.1 makes `sessionPub` a required field. That mint is bounded where +> it is introduced below: no key is ever derived from it, the frame carrying it +> says on the wire that it answers nothing so Rule B will not forward it, and +> the first Rule A mint that commits zeroes it. Without the liveness half the +> safety half above is satisfied by a process that mints forever: every hello +> it sends names the live session, every mint it commits is the current one, +> nothing is ever stale — and no session ever opens. Four rules produce it, and each is a change to existing code rather than a restatement. **Committed**, rather than *minted*, is the load-bearing word, and @@ -255,16 +279,30 @@ invariant and a plausible-sounding claim about it. mints when the *relayed* extension hello arrives (`peer.ts:349-351`) and sends a fresh server hello in the same handler. Nothing else mints a session ephemeral, and nothing reuses one across two extension sessions. + + **Which frames are TRIGGERS is as load-bearing as what a mint does, because + the peer's trigger arrives from the host and the peer's answer goes straight + back to it** — so a trigger the host re-sends in answer to that answer is a + loop, and Rule B's mirror (below) is what forbids it. The host relays an + extension hello to a peer in exactly two places. This is the trigger side of + the table further down, and a path added later owes a row here too: + + | Frame reaching a peer | A mint trigger? | Why | + |---|---|---| + | the extension hello fanned out on extension connect (`host.ts:370`) | **yes** | one per extension session, to every peer in the map at that moment | + | the cached extension hello at the tail of the peer-hello branch (`host.ts:428`) | **yes — and only in answer to a REGISTRATION hello**, per Rule B's mirror | it is how a peer that registered *after* that fan-out learns an extension is attached at all; un-gated it also answers the peer's own re-hello, which is the livelock | + | `extension-disconnected` (`peer.ts:356-363`) | no | it ends a session: it zeroes, it does not mint | + | a `ready` on either path | no | Rule C's subject — it names an ephemeral rather than asking for one | - **Rule B — the host forwards a server hello to the extension only when the hello NAMES the current extension session.** The gate is `extensionHello !== null && frame.answersExtNonce === extensionHello.sessionNonce` — a fixed 32-byte comparison, with no branch for an absent field: a hello that answers no extension session carries 32 zero bytes, and a nonce minted from a CSPRNG is not that value. Two sends fail it and both are wrong under - v4 for the same reason: `:374-376` replays each peer's *cached* hello to a newly connected - extension, and `:425` forwards a peer's *registration* hello straight to an - already-connected extension. In both cases the `sessionPub` on the wire is - one Rule A is about to supersede. + v4 for the same reason: `host.ts:374-376` replays each peer's *cached* hello to + a newly connected extension, and `:425` forwards a peer's *registration* hello + straight to an already-connected extension. In both cases the `sessionPub` on + the wire is one Rule A is about to supersede. **The gate is on the FRAME, never on the slot, and that is the whole of it.** An earlier draft of this section gated `:425` on a mark recorded per peer — @@ -282,7 +320,7 @@ invariant and a plausible-sounding claim about it. hello suspends at `:395`; E1's socket closes (`:618` nulls `extensionHello`, `:635-639` sends `extension-disconnected`, which zeroes P's ephemeral at `peer.ts:356-363`); E2 connects and its handler runs to - completion, so `:370` sets P's mark to E2's hello; the suspended handler + completion, so `host.ts:370` sets P's mark to E2's hello; the suspended handler resumes, `:424` writes the slot, and the mark now matches at `:425` — so the hello minted for E1 is forwarded to E2. All four steps are ordinary — the code treats MV3 reconnects as routine — and the frame gate cannot be fooled by the @@ -310,6 +348,84 @@ invariant and a plausible-sounding claim about it. reconnect, and nothing to carry across a slot overwrite — a reconnecting extension has a new nonce, so every hello minted against the old one stops matching by arithmetic. + + **The same FIELD gates the other direction, with the test inverted, and + without it every peer session establishment livelocks.** `host.ts:428` — + `if (extensionHello) ws.send(JSON.stringify(extensionHello));` — sends the + cached extension hello at the tail of the peer-hello branch + UNCONDITIONALLY: on every peer hello, on the same socket, with no first-time + gate (the squat guard at `:411-420` fires only when `existing.ws !== ws`, and + `:424` simply overwrites the slot). It is a Rule A trigger, so a peer's Rule + A re-hello draws another one: + + > registration hello → `:425` withholds it (Rule B) → `:428` sends E's hello + > → peer mints and re-hellos → `:425` now forwards it (the re-hello echoes + > E's live nonce, so Rule B passes) → `:428` sends E's hello **again** → peer + > mints and re-hellos → unbounded. + + Nothing already written stops it. Rule B passes every iteration, because each + re-hello legitimately answers the live extension session. Rule D commits + every iteration, because `extensionHello === frame` compares against the + freshly parsed frame the handler was entered with. Rule C is downstream of a + session that never settles. And this is not an exotic path — the same loop + runs off `:370` on an ordinary MV3 reconnect, so under the rules as first + drafted **every** peer session establishment on **both** trigger paths spins, + and whether a session opens at all depends on the loop pausing long enough + for a `ready` to reach the pub the peer last committed. Each turn costs an + X25519 keygen and an Ed25519 sign on the peer, a verify on the host, and a + full `onServerHello` — bind, ECDH, HKDF, ready — on the extension. It also + makes two things this document already asserts unsatisfiable rather than + merely unproven: Task 2.2's dial-path test asserting the extension "then + receives **exactly one** server hello for it", and the `:425` row of the + table below, which describes one mint per extension session. + + **The fix taken is Rule B mirrored onto `:428`: hand a peer the cached + extension hello only in answer to a hello that answers NO extension session** + — `answersExtNonce` all zero, i.e. a registration hello, tested through + `answersNoExtSession`, the one predicate Task 1.1 exports for the fact, + rather than a literal spelled out here and again where the peer writes it. + Those two are the fact's whole population in shipped code — this gate reads + it and the bootstrap hello writes it — and Task 1.1 says why the two places + that look like further readers are not: Rule B's own gate at `:425` compares + against the live nonce and takes no branch on this value, and Rule C's + refusal is a comparison against the extension's own nonce that the zero value + fails anyway. `:370` already + covers every new extension session for every peer in the map, so nothing else + needs the re-send. The predicate is the complement of the one above rather + than a copy of it, which is the point: a hello that answers the current + session goes ON to the extension and draws nothing back, and a hello that + answers nothing goes nowhere and draws the extension hello back. And this + half is one degree cleaner than its mirror: it reads the frame and **no + authoritative variable at all**, so there is no await window of the kind case + 1 is about and nothing is recorded per peer. Termination and liveness both + follow from the two relays being mutually exclusive per (peer, extension + session): + + - a peer already in the map when an extension connects is triggered once by + `:370`, and its re-hello answers that session's nonce, so `:428` is silent + for it; + - a peer that registers *after* that fan-out is not in the map for `:370` — + its handler may be suspended at the `await ed25519Verify` on `:395` — and is + triggered once by `:428`, which reads the CURRENT `extensionHello`. That is + the job this send still has to do, and the reason the answer is a gate + rather than a deletion; + - a peer that registers with no extension attached is triggered by neither, + and by `:370` when one connects. + + **The alternative, and why it is not taken.** The loop can also be cut at the + peer: mint only when the arriving relayed hello's `sessionNonce` differs from + the `answersExtNonce` of the ephemeral this peer has currently **committed** + (committed, so it composes with Rule D rather than reading an in-flight mint + whose own commit may lose). That terminates too, and it is robust to a relay + that duplicates a frame, which the host-side gate is not — under the gate, a + duplicated extension hello costs a redundant mint and a redundant server + hello, the extension binding the last and Rule C discarding the `ready` for + the first: a degradation, never a loop, because the chain is cut where the + frames are produced. It is declined because it asks the peer to carry, across + Rule D's interval, a second fact about which session its ephemeral answers, + and that interval is where this design has already been wrong twice. If a + duplicating relay ever becomes real it is the repair to reach for, and it + composes with this gate rather than replacing it. - **Rule C — the extension refuses a hello that answers a nonce it did not send, and the MCP discards a `ready` for an ephemeral it no longer holds.** Rule B is the host's gate, and a gate that fails open must not be the only @@ -321,8 +437,12 @@ invariant and a plausible-sounding claim about it. it refuses a hello whose `answersExtNonce` is not `link.sessionNonce` **before** `bindMcpToLink` at `:94` — no binding, no trust read, no pair prompt, and the existing `tellServerWhy` path carries the reason. A hello - that answers 32 zero bytes is refused here too: on the wire that is a - registration hello, and it has no business reaching the extension. + that answers 32 zero bytes is refused here too, and by that same + comparison rather than by a second check: on the wire that is a + registration hello, `link.sessionNonce` comes from a CSPRNG and is never + the zero value, so "answers nothing" fails the equality like any other + wrong answer — which is why Task 1.1 counts this as no reader of + `answersNoExtSession`. - **MCP side.** The `ready` now carries `mcpSessionPub` explicitly (§2 already signs it), and both server paths compare it to the ephemeral they currently hold **before** verifying the signature. Equal → verify as @@ -367,7 +487,7 @@ invariant and a plausible-sounding claim about it. `:620` rejects the pending promise, `:622` nulls `ownSession` and `:630` resets the promise — and Task 2.1's zeroing takes whatever was installed **before** mint 1, which on a first connection is nothing at all, because - mint 1 has not landed). The `:290` "extension + mint 1 has not landed). The `:290-291` "extension already connected" guard is now open, so E2 connects and its own mint commits. Mint 1 then resumes, overwrites `{nonce, pub, priv}` with E1's and sends a hello down a closed socket. The extension derived against pub2 and @@ -430,9 +550,9 @@ invariant; a path added later owes a row. |---|---|---|---|---| | the host's own hello (`host.ts:372`) | the extension-hello handler (Rule A), committed under Rule D while `extensionWs === ws` — **not** same-tick: the mint awaits | `server-hello.ts:148`, immediately (auto-trust) | that mint | the commit is what makes it current, and from there it is zeroed only by the extension socket's close handler (`host.ts:612-641`), which *is* the end of this extension session. A mint for an EARLIER session resolving late cannot displace it: Rule D makes it zero its own half instead | | the host's own hello, needs-pair → the user clicks approve minutes later | as above | `approval.ts:163` | that same mint | the approval path skips an mcpId with no live link (`approval.ts:127-130`), and for the HOST's own mcpId that link IS the extension's socket to this host — so the thing the guard reads dies exactly when the mint is zeroed. Not true of a peer's mcpId; see the residual below | -| a peer's Rule A hello, forwarded by the host (`:425`, the hello answering the current extension nonce) | the relayed-extension-hello handler (`peer.ts:349-351`), committed under Rule D while `extensionHello` is still that frame | `server-hello.ts:148` or `approval.ts:163` | that mint | it is zeroed on `extension-disconnected` (`peer.ts:356-363`), or displaced by a LATER session mint at its commit point — never by an earlier one resolving late, which Rule D makes zero its own half. Those are the events that end this extension session; a `ready` arriving after one of them names a superseded `sessionPub` and is discarded by Rule C, not refused | +| a peer's Rule A hello, forwarded by the host (`host.ts:425`, the hello answering the current extension nonce) | the relayed-extension-hello handler (`peer.ts:349-351`), ONCE per extension session — Rule B's mirror on `host.ts:428` is what makes it once — committed under Rule D while `extensionHello` is still that frame | `server-hello.ts:148` or `approval.ts:163` | that mint | it is zeroed on `extension-disconnected` (`peer.ts:356-363`), or displaced by a LATER session mint at its commit point — never by an earlier one resolving late, which Rule D makes zero its own half. Those are the events that end this extension session; a `ready` arriving after one of them names a superseded `sessionPub` and is discarded by Rule C, not refused | | a peer's **registration** hello | `startPeer` (bootstrap) | — | nothing | it says so on the wire (`answersExtNonce` = 32 zero bytes), so Rule B will not forward it and Rule C's extension-side check would refuse it anyway; no `ready` can name it | -| a peer's cached hello, replayed to a newly connected extension (`:374-376`) | — | — | — | **path removed** by Task 2.2 | +| a peer's cached hello, replayed to a newly connected extension (`host.ts:374-376`) | — | — | — | **path removed** by Task 2.2 | Zeroing at the peer's own socket close (`peer.ts:507-513`) is on that list in Task 2.2 and deliberately **not** in this table's last column. It is a teardown @@ -649,7 +769,7 @@ nothing" half by construction. | Action | Path | Why | |---|---|---| -| Modify | `packages/protocol/src/frames.ts` | `PROTOCOL_VERSION` 4, `HKDF_SESSION_INFO`, `sessionPub` + `answersExtNonce` on the server hello, `mcpSessionPub` on the ready, `helloSignaturePayload`, widened `readySignaturePayload`, `transcriptHash`, `frameAad`, `Direction`, the standing v4 paragraph in the header | +| Modify | `packages/protocol/src/frames.ts` | `PROTOCOL_VERSION` 4, `HKDF_SESSION_INFO`, `sessionPub` + `answersExtNonce` on the server hello, `ANSWERS_NO_EXT_SESSION` + `answersNoExtSession`, `mcpSessionPub` on the ready, `helloSignaturePayload`, widened `readySignaturePayload`, `transcriptHash`, `frameAad`, `Direction`, the standing v4 paragraph in the header | | Modify | `packages/protocol/src/validate.ts` | require `sessionPub`, `answersExtNonce` and the ready's `mcpSessionPub`; `peekHelloVersion` | | Modify | `packages/protocol/src/crypto.ts` | `aesGcmSeal`/`aesGcmOpen` take `aad` | | Modify | `packages/protocol/src/seal.ts` | `direction` required on seal/open; AAD threaded; `sealedFrameWireBytes` unchanged | @@ -707,6 +827,26 @@ comment saying which version widened it, in the style `frames.ts:50-57` already uses. `answersExtNonce` and `mcpSessionPub` are both required, fixed-length fields — §2 says why the first is 32 zero bytes rather than absent when a hello answers no extension session, and that reason belongs in its doc comment. +Export the "answers nothing" value and its test **once**, as +`ANSWERS_NO_EXT_SESSION` plus `answersNoExtSession(b64)`, per the wire +section's one-function-per-fact rule. The shipped population is small enough to +state exactly, and worth stating because two of the places that look like +readers are not: **one place writes the value** — the peer's bootstrap hello +(§1a) — and **one place reads it**, Rule B's mirror on `host.ts:428` +(Task 2.2). Rule B's own gate at `host.ts:425` is +`frame.answersExtNonce === extensionHello.sessionNonce`, a fixed comparison +against the live nonce that takes no branch on this value and which §1a +forbids taking one; and Rule C's extension-side refusal (Task 3.1) is +`answersExtNonce !== link.sessionNonce`, which the zero value fails without a +second call, because `link.sessionNonce` comes from a CSPRNG and is never that +value. So the export exists for the writer, that one reader, and the vectors +below — a value that crosses a wire and is asserted in two suites is exactly +what this rule says to name once, and a literal spelled at both ends of it is +the drift the rule exists to prevent. Its assertions go in `frames.test.ts` beside the payload +facts: the constant is 32 zero bytes, the predicate is true of exactly that +value and false of a hundred CSPRNG nonces, and (in `validate.test.ts`) a hello +carrying the zero value VALIDATES — it is a registration hello, not a malformed +one. **Task 1.2 — the AAD.** Where: `packages/protocol/src/crypto.ts` (`aesGcmSeal`, `aesGcmOpen`), `seal.ts:140` (`sealInnerFrame`) and both `openEncryptedFrame*`. @@ -746,8 +886,9 @@ break. wrong.** The session transcript (§3) contains `extSessionPub`, which arrives in the `ready`; the pair code has to be shown at the pair *prompt*, before any `ready` exists. The extension has no ephemeral of its own yet at -`hello.ts:369-373` (the auto-trust path mints one at `:303`, the approval path -at `:135` — both after this point), and the host derives at `:358` the moment +`hello.ts:369-373` (the auto-trust path mints one at `hello.ts:303`, the +approval path at `approval.ts:135` — both after this point), and the host +derives at `host.ts:358` the moment the extension hello lands, with no `ready` in hand either. So this is a SECOND, separate function over the values both ends do hold at the prompt: @@ -799,7 +940,7 @@ vector must never be. Row by row, plus the comment that pins the reduction: | `:5-9` `produces XXX-XXX format` (`^\d{3}-\d{3}$`, `:8`) | becomes the `^\d{4}-\d{4}$` assertion over `pairTranscript`'s output | | `:11-16` deterministic for the same input | kept, restated over the five inputs | | `:18-22` different inputs → different codes | subsumed by the five one-input-apiece assertions | -| `:24-29` comment pinning "first 4 bytes → big-endian uint32 → mod 1_000_000" | rewritten to pin the 8-byte BigInt reduction, keeping the *reason* (a refactor that moves the hash, the endianness or the width must fail here, not at pair time) | +| `:24-29` comment pinning "first 4 bytes of SHA-256(pub) → big-endian uint32 → mod 1_000_000 → XXX-XXX" | rewritten to pin the 8-byte BigInt reduction, keeping the *reason* (a refactor that moves the hash, the endianness or the width must fail here, not at pair time) | | `:30-33` known answer `123-181` (all-zero pub) | **deleted.** Its input is a single pub, which `pairTranscript` has no argument for, and a new vector is hard-coded in its place from the five-input encoding | | `:35-42` known answer `848-182`, the `>>> 0` high-bit regression | **deleted, and this one is a deliberate loss of coverage.** Its whole subject is the signed/unsigned slip in a 32-bit read, and reading 8 bytes as a `BigInt` removes the read that could slip. Say so in the replacement file's comment, so the next reader does not restore a test for a hazard the code no longer has | | `:44-49` known answer `425-966` (1..32 pub) | **deleted**, same reason as `:30-33` | @@ -837,7 +978,7 @@ each with the task that owns it: | `packages/protocol/src/frames.ts:455-457` | "the same 6-digit joint pair code", the joint derivation spelled out, and "formatted `XXX-XXX`" | **this task** (the earlier draft named only the inline `:472` comment) | | `packages/protocol/src/frames.ts:472` | `// formatted "XXX-XXX"` | **this task** | | `packages/extension-core/src/background/server-hello.ts:300`, `packages/server/src/ws-server.ts:1663`, `packages/extension-core/tests/multi-link.test.ts:414` | comments reading "the same XXX-XXX the popup is displaying" | **this task** — comments, so one sed, but they are the sentences a reader trusts | -| `CLAUDE.md:79` (§Security model summary item **3**) | the derivation and `XXX-XXX` | **Task 6.4**, which named only "items 2 and 3b" and is corrected below | +| `CLAUDE.md:78-80` (§Security model summary item **3**) | the derivation (`:78`) and `XXX-XXX` (`:79`) | **Task 6.4**, which named only "items 2 and 3b" and is corrected below | | `docs/SECURITY.md:65` | "The 6-digit pair code (SAS …) is `SHA256(identityX25519Pub)[0..3] mod 1_000_000` formatted as `XXX-XXX`" — and note it states the *single-pub* v3 derivation, which has not been the shipped one since 0.4.0 | **Task 6.3** | | `docs/PROTOCOL.md:418-419` | `mod 1_000_000` / `formatted "XXX-XXX"` | **Task 6.2** | | `README.md:44`, `packages/cli/README.md:37`, `packages/extension-chrome/README.md:79` | "6-digit" in the pair-flow walkthrough | **Task 6.5**, extended below to cover them | @@ -878,8 +1019,8 @@ previous `sessionPriv` (assert through the exported surface or an injected zeroing hook — do **not** add an accessor that exists only for the test). Then Rule C on this path, and note that the two outcomes must be asserted apart: a `ready` whose `mcpSessionPub` is the host's CURRENT ephemeral but -whose `sessionSig` does not verify closes 1008 (`:470-476`, unchanged) and -rejects the pending session, while a `ready` whose `mcpSessionPub` is a +whose `sessionSig` does not verify closes 1008 (`host.ts:470-476`, unchanged) +and rejects the pending session, while a `ready` whose `mcpSessionPub` is a SUPERSEDED one is **discarded** — the socket stays open, `ownSession` and the pending promise are untouched (assert the promise is still pending against a fake clock), and a genuine `ready` arriving afterwards still establishes the @@ -900,8 +1041,8 @@ only "E1 gets nothing after its close" passes without Rule D, because a closed socket swallows the send; the load-bearing assertion is about E2's session opening. Do: mint `{nonce, pub, priv}` per extension connection — after the liveness -re-check at `:337-345` and immediately before the send, so a hello the trust -decision refuses mints nothing; set `answersExtNonce` from the triggering +re-check at `host.ts:337-345` and immediately before the send, so a hello the +trust decision refuses mints nothing; set `answersExtNonce` from the triggering extension hello's `sessionNonce` (it is `frame.sessionNonce`, in scope in that handler) and sign it. Do **all** of that into locals — `generateX25519` and the `ed25519Sign` over the widened payload both await — and then commit under @@ -984,14 +1125,17 @@ socket to the host) does not — it ends this process's part in it, and zeroing there is a teardown obligation rather than part of §1a's invariant. §1a's table says which, and why the difference matters for the approval path. -**Five consequences, all required for that to work.** The first three are in -the host: the first two are one rule — Rule B of §1a — and the third is a guard -that becomes load-bearing. The last two are back in the peer, and they are -Rules C and D. Rule D is listed last and is not optional garnish on Rule C: it -is what stops Rule C's discard from being the only thing that happens. +**Six consequences, all required for that to work.** The first four are in the +host: the first three are one rule — Rule B of §1a, gating BOTH directions — +and the fourth is a guard that becomes load-bearing. The last two are back in +the peer, and they are Rules C and D. Rule D is listed last and is not optional +garnish on Rule C: it is what stops Rule C's discard from being the only thing +that happens. (Reading key for these six bullets: a bare `:NNN` is in `host.ts` +unless the sentence names the file it is in.) - The host caches each peer's hello (`peers.set(mcpId, {ws, helloFrame})`, - `:424`) and **replays it to every newly connected extension** (`:374-376`). + `host.ts:424`) and **replays it to every newly connected extension** + (`host.ts:374-376`). Under v4 the cached frame is stale by construction — the private half it names is gone or about to be — so the extension would derive against a key nobody holds. Drop the replay: the relay the host already performs at `:370` @@ -1017,6 +1161,22 @@ is what stops Rule C's discard from being the only thing that happens. extension is attached now rather than which one this frame was minted for. An echo the peer signed says the second thing, which is the question the gate is asking. +- **`:428` is itself a mint trigger, so it needs the MIRROR of that gate or the + dial fix loops.** `if (extensionHello) ws.send(JSON.stringify(extensionHello));` + runs at the tail of this branch unconditionally, on every peer hello — + including the Rule A re-hello the bullet above exists to let through. So + gating `:425` alone converts the dial from one stale hello into an unbounded + exchange: re-hello → forwarded → cached extension hello sent back → mint → + re-hello. Send it only when the arriving hello answers NO extension session + (`answersNoExtSession(frame.answersExtNonce)`, Task 1.1): a REGISTRATION hello + is the one case + where this peer has not been told about the extension and needs to be, and + `:370` covers every other peer for every extension session already. Read off + the frame alone — this gate touches no authoritative variable, which is one + fewer moving part than its mirror at `:425`. §1a Rule B traces the loop, the + two trigger paths it runs on, why the two relays are mutually exclusive per + (peer, extension session) once gated, and why the peer-side alternative was + declined. - A same-socket, same-identity re-hello must REPLACE the slot rather than be refused, because that re-hello is now the *only* way a peer's session hello reaches the extension. It already does — the squat guard at `:411-420` fires @@ -1032,7 +1192,7 @@ is what stops Rule C's discard from being the only thing that happens. `peer.ts:374-381`: a 1008 close plus `rejectFirstReady`, which strands the bridged MCP on a failure an MV3 eviction caused. Compare `frame.mcpSessionPub` against the ephemeral this peer currently holds before - `authenticateExtension` at `:374`; a mismatch logs and returns, touching + `authenticateExtension` at `peer.ts:374`; a mismatch logs and returns, touching neither `session` nor `extensionGone` nor the promise, and the socket to the host stays up because the host is not the party at fault. The 1008 stays for the case it was written for — a `ready` naming the current ephemeral whose @@ -1063,7 +1223,7 @@ is what stops Rule C's discard from being the only thing that happens. `peer.ts:209-215` says a renegotiation most commonly happens "after MV3 service-worker eviction reconnects the browser side and **the host replays our hello**". After this task there is no replay: the trigger is the host relaying -the new extension hello at `:370` and the peer hellos again. Update that +the new extension hello at `host.ts:370` and the peer hellos again. Update that comment in the same task — a comment naming a path this task deletes is how the next reader concludes the replay is still there. @@ -1103,13 +1263,28 @@ then. Plus the ordering control: with **no** extension attached at dial, the extension that connects afterwards receives one server hello for that peer and it is the post-relay one. +Then `host.ts:428`'s gate, which is what makes that "exactly one" reachable at +all — un-gated, the dial never settles, so the assertion above is not merely +unproven but unsatisfiable. Two assertions on one gate, one per direction, so a gate +deleted either way fails: a peer's **registration** hello (echoing 32 zero +bytes) is not forwarded to the extension and **does** draw the cached extension +hello back; a peer's **re-hello** (echoing the live extension nonce) **is** +forwarded and draws **nothing** back. Then the property itself, end to end, on +**both** trigger paths — a dial into a live extension AND an extension +reconnect — asserted over a rig run to QUIESCENCE rather than for a fixed +number of turns, because the bug's signature is an exchange that never settles: +the extension receives exactly one server hello per peer per extension session, +and the injected keypair generator was called exactly once per peer per +extension session. A rig that takes its counts after N turns records a clean +number on the looping code. + Then the RACE, which the un-interleaved sequence above does not reach and which is what a per-peer mark passed: **a peer hello minted for the previous -extension session, arriving at the host after `:370` has fanned the next +extension session, arriving at the host after `host.ts:370` has fanned the next extension's hello out, is not forwarded.** Drive it at the host with two mock extensions and a peer whose hello is held: peer told of E1 → its hello for E1 is -withheld → E1's socket closes → E2 connects (so `:370` has run for this peer and -`extensionHello` is E2's) → release the peer's hello for E1. Assert that E2 +withheld → E1's socket closes → E2 connects (so `host.ts:370` has run for this peer +and `extensionHello` is E2's) → release the peer's hello for E1. Assert that E2 receives **no** frame for that `mcpId` from it, that the peer then hellos again off E2's relay, and that **that** hello is forwarded. Two controls keep it honest: with E1 still attached the same withheld hello **is** forwarded, so the @@ -1142,9 +1317,11 @@ shapes — `extensionHello` newer and `extensionHello` null — are covered. Do: the derivation, salt, direction and zeroing changes of Task 2.1, mounted on the two mint points above, each with the `answersExtNonce` the table gives it, and each committing under Rule D (`extensionHello === frame`, zero-and-drop -on a mismatch); the host changes (drop the replay, gate `:425` on Rule B, assert -the same-socket re-hello overwrite, Rule C's discard on the ready branch); and -the comment repair. +on a mismatch); the host changes (drop the replay, gate `host.ts:425` on Rule B, +gate `host.ts:428` on Rule B's mirror — send the cached extension hello only in +answer to a hello whose `answersExtNonce` is 32 zero bytes, without which the +gate on `:425` turns the dial into a livelock — assert the same-socket re-hello +overwrite, Rule C's discard on the ready branch); and the comment repair. Commit: `feat(server)!: mint a session ephemeral per extension connection and authenticate every frame against its own identity`. @@ -1203,15 +1380,17 @@ key, which is why a stored record sufficed under v3 and does not under v4. So `PendingPairRecord` gains `sessionPubs: Record` beside `sessionNonces` (`pending-records.ts:51`, written at `server-hello.ts:270`), refreshed per `mcpId` on every hello exactly as the nonce is -(`applyNeedsPairRecord` case 1, `:147-151`) — the refresh is what keeps a -record from naming a superseded ephemeral after a reconnect. Then derive +(`applyNeedsPairRecord` case 1, `pending-records.ts:147-151` — that function +lives in that file, not in the `server-hello.ts` cited in front of it) — the +refresh is what keeps a record from naming a superseded ephemeral after a +reconnect. Then derive against `sessionPubs[mcpId]`, salt with `transcriptHash(storedNonce, link.sessionNonce, storedSessionPub, ephemeral.publicKey)`, and sign the ready over all four. An entry with **no** `sessionPubs` value — every pending record already in `chrome.storage.local` when the extension is reloaded — is skipped -with the same warn as a missing nonce (`:119-122`), and the MCP hellos again; -do not fall back to `identityX25519Pub`, which is the v3 derivation reinstated -under a v4 signature. +with the same warn as a missing nonce (`approval.ts:119-122`), and the MCP hellos +again; do not fall back to `identityX25519Pub`, which is the v3 derivation +reinstated under a v4 signature. Test (`packages/extension-core/tests/background.test.ts`, which is where `onApproval` is exercised today — there is no `approval.test.ts` and this task should not invent one; check with `grep -rln onApproval @@ -1220,9 +1399,9 @@ record refreshed by a second hello uses the **second** hello's `sessionPub`, not the first; a record carrying no `sessionPubs` entry is skipped rather than derived from the identity key; and the key the approval path derives opens a frame sealed by the MCP that sent that hello. -Pin the `:127-130` guard with a test that a dead link produces no `ready` — but -do **not** write down that the guard proves the MCP's ephemeral is still live, -because for a PEER's mcpId it does not: `linkForMcp` is `mcpLink.get(mcpId)` +Pin the `approval.ts:127-130` guard with a test that a dead link produces no +`ready` — but do **not** write down that the guard proves the MCP's ephemeral +is still live, because for a PEER's mcpId it does not: `linkForMcp` is `mcpLink.get(mcpId)` (`links.ts:98-99`), the concentrator socket, which the host's own mcpId and every peer's share, so it survives the peer that hello'd on it. §1a states that residual and its consequence (the `ready` is dropped at `host.ts:516-517` and @@ -1260,8 +1439,8 @@ Task 3.1 makes, for the same reason); and a legacy record whose `identityEd25519Pub` is absent or empty falls through to needs-pair rather than being read as a match. Do: add `record.identityEd25519Pub !== hello.identityEd25519Pub` to the -`scopeIdentityChanged` disjunction at `:261-272` — one clause in the branch -that is already there, so a mismatch takes the needs-pair path the user can +`scopeIdentityChanged` disjunction at `hello.ts:261-272` — one clause in the +branch that is already there, so a mismatch takes the needs-pair path the user can answer rather than a `reject` the popup cannot show. Do **not** normalise an absent stored value with `?? hello.identityEd25519Pub`, which turns the check into a tautology; an absent one must mismatch. Nothing has to be migrated — @@ -1381,34 +1560,44 @@ asserting a *clean* outcome within a bounded time and never a timeout: **Task 5.3 — the mutation check.** Per `~/.claude/projects/.../mutation-testing-needs-a-rebuild.md`: cross-package tests run the built `dist/`, so a mutation without `npm run build` always -survives. Build first, then mutate each of the **ten** v4 facts in turn — drop +survives. Build first, then mutate each of the **eleven** v4 facts in turn — drop `sessionPub` from the hello payload, drop `answersExtNonce` from it (§1a Rule B's echo is only as good as the signature over it), drop `mcpSessionPub` from the ready payload, drop `direction` from the AAD, leave `HKDF_SESSION_INFO` at `1.0.0`, delete the `identityEd25519Pub` comparison Task 3.3 adds, restore the -cached-peer-hello replay and un-gate the `:425` forward Task 2.2 removes (one -mutation: put both sends back), turn Rule C's `mcpSessionPub` comparison into +cached-peer-hello replay and un-gate the `host.ts:425` forward Task 2.2 removes +(one mutation: put both sends back), turn Rule C's `mcpSessionPub` comparison into an unconditional pass on both server paths (the mutation that must fail the interleaving test AND the "stale is discarded, forged is refused" pair — if only one of the two fails, the other assertion is not pinning what it claims), delete Rule D's commit-point re-check on both server paths (one mutation: the host's `extensionWs === ws` and the peer's `extensionHello === frame` come out together, and a test must fail for EACH path — a suite covering only one leaves -the other's overwrite unpinned), and — if decision 2 said +the other's overwrite unpinned), un-gate the host's `host.ts:428` re-send of the +cached extension hello so it answers every peer hello rather than a +registration one (§1a Rule B's mirror, Task 2.2), and — if decision 2 said yes — drop `mcpSessionPub` from `pairTranscript` -(Task 1.4) — and confirm a test fails for each. Record the ten results in the -PR body (nine if Task 1.4 was cut). A fact +(Task 1.4) — and confirm a test fails for each. Record the eleven results in +the PR body (ten if Task 1.4 was cut). A fact with no failing test is a fact the next refactor removes, which is the whole history of the sixth one; the seventh is there because the two sends it restores are what the invariant in §1a exists to forbid, and a plan that only *says* "drop the replay" is exactly the shape that leaves one of the two behind; the eighth is there because Rule C's two outcomes are one branch, and a suite that pins only the refusal passes on the code that 1008s a stale `ready`; the ninth -is the one likeliest to survive a careless suite, because deleting Rule D +is, with the tenth, the likeliest to survive a careless suite, because deleting +Rule D produces no error and no closed socket — a session that silently never opens — so the test it must fail has to assert positively that the LIVE session opens, and a suite whose only Rule D assertion is "the stale hello is not sent" -survives the mutation on a closed socket's swallowed write. +survives the mutation on a closed socket's swallowed write. The tenth is the +ninth's hazard one turn further out: un-gated, `host.ts:428` raises no error, +closes no socket and rejects nothing — only an exchange that never settles, and +on the ordinary path rather than an interleaved one — so +the test it must fail is the one that counts server hellos and keygens after +QUIESCENCE, and a rig that takes its counts after a fixed number of turns +records a clean number on the looping code. Anything that bounds turns rather +than asserting settlement leaves this fact unpinned. Commit: `test(server,extension): prove a v3 peer meets a v4 host with a clean refusal, against frozen v3 bytes`. @@ -1426,12 +1615,16 @@ unmoved. State the package-major off-by-one from decision 1 here, once. encodings, the verification rule that goes with them (a hello is trusted only when **both** long-term keys match the pinned record — Task 3.3 — stated beside the signature it makes meaningful, not in a footnote), the two refusal paths and -their message texts, the ephemeral-lifetime invariant of §1a with its table and its four rules — -including which `ready` is discarded and which is refused, the residual on -the approval path the table names, and **Rule D**, which is the one rule with no -wire footprint at all and is therefore stated as what it is: an implementation -obligation on both ends, without which the invariant above it is unprovable and -the discard of Rule C becomes a hang. Those are the +their message texts, the ephemeral-lifetime invariant of §1a — **both halves**, +the safety one and the liveness one — with its two tables (the hellos the +extension can act on, and which frames reaching a peer are mint TRIGGERS) and +its four rules, including which `ready` is discarded and which is refused, the +residual on the approval path the table names, Rule B's SECOND half (the gate +on the host's re-send of the cached extension hello, whose absence is not a +stale key but an exchange that never settles), and **Rule D**, which is the one +rule with no wire footprint at all and is therefore stated as what it is: an +implementation obligation on both ends, without which the invariant above it is +unprovable and the discard of Rule C becomes a hang. Those are the facts a reader of the wire spec alone cannot reconstruct. Then the `mod 1_000_000` / `XXX-XXX` lines at `:418-419`, the pair code's new derivation and the honest version of what it buys if Task 1.4 shipped, and a @@ -1439,10 +1632,10 @@ table of `PROTOCOL_VERSION` → package major → what changed, so the off-by-on readable rather than inferred. **Task 6.3 — `docs/SECURITY.md`.** Retract what v3 could not support and state -the new residual precisely. Retract: `:236` "Hosting an MCP does not give the +the new residual precisely. Retract: `:239` "Hosting an MCP does not give the host the user's cookies, requests or responses" was **true only of a host that does not also hold the identity** — under v4 it is true of an identity holder -too, and say what changed and when. `:197`/`:326` on replay: v4's AAD closes +too, and say what changed and when. `:198`/`:327` on replay: v4's AAD closes it; say so and stop overstating it as already closed. Record Task 3.3: under v3 the extension's trust match could omit the Ed25519 half because the ECDH proved possession of the pinned key, and under v4 it cannot — so the doc must not carry @@ -1537,7 +1730,8 @@ this paragraph in the answer. ### The sequence -1. **Gate.** Decisions 1–4 answered. mcp-host PR H1 (the cohort bump to 2.11.3) +1. **Gate.** Decisions 1–3 answered (4 already is, and its answer is what + makes step 6 one reload). mcp-host PR H1 (the cohort bump to 2.11.3) is *merged and landed on the fleet*, so this operation moves one range, not two. mcp-host's `readEnvelope` change (Group 7 §prerequisite, below) is **live in the gateway**, not merely merged. @@ -1606,9 +1800,15 @@ would mean every mid-window straggler in step 5 looks like a dead connector rather than a version mismatch, and the message this whole plan exists to deliver never reaches anyone. -That change is **on mcp-host `main`**: it landed as **#756** -(`fix(bridge): forward the extension's rejection and disconnect frames to the -child instead of dropping them`, commit `a2133a5`), so `BridgeFrameType` now +That change is **on mcp-host `main`**: it landed as **#756**, commit +`a2133a5` — whose squash subject is +`fix(gateway): delete a row whose secret is gone, bound the login reaper's IN clause, stop echoing the runner's tools error` +and mentions none of this. The bridge repair is one of the three commits inside +that squash (`fix(bridge): forward the extension's rejection and disconnect +frames to the child instead of dropping them`, in the squash BODY, not its +subject), which is exactly why the citation is the hash and the PR number and +why a reader grepping mcp-host's one-line log for the bridge sentence finds +nothing. So `BridgeFrameType` now carries `hello-rejected` and `extension-disconnected` (`bridge-frames.ts:63-70`) and `readEnvelope` routes both (`:127-136`). It is **not** an ancestor of the `0.62.0` release commit this plan's other mcp-host @@ -1626,7 +1826,9 @@ The extension is distributed today as an unpacked sideload and a GitHub-release submitted: `README.md:62` still links `chromewebstore.google.com/detail/transporter/EXTENSION_ID_PLACEHOLDER`. So there is no auto-update channel, and step 6 above is a single reload because -there is essentially a single install. +there is a single install: **nothing has left the operator's walls** (decision +4, answered 2026-09-12), which is the operator's own statement rather than an +inference from the absence of a listing. **That is a property with an expiry date, and it is the argument for shipping v4 now rather than after the beta.** The moment mcp-host's beta hands @@ -1639,8 +1841,12 @@ why it is still a worse outcome than shipping first. Concretely: **either v4 lands before the beta distributes the extension, or the CWS submission lands before v4 does.** There is no third arrangement in which -the next wire break is cheap. Chris's call (decision 4); this plan assumes the -first. +the next wire break is cheap. Decision 4 has taken the first, so this section +describes the fleet as it is today and step 6 costs one keystroke. It describes +a fact with an expiry date and not a standing one: on the day Transporter +reaches somebody who is not Chris, the second arrangement is the only one left +and Group 7 is no longer the operation written above — which is the argument +for not letting this plan sit. ### Rollback