Skip to content

feat(server): pin the extension's identity, and verify it on the peer path - #213

Merged
chrischall merged 5 commits into
mainfrom
claude/extension-identity-pin
Aug 5, 2026
Merged

feat(server): pin the extension's identity, and verify it on the peer path#213
chrischall merged 5 commits into
mainfrom
claude/extension-identity-pin

Conversation

@chrischall

@chrischall chrischall commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes #208.

What was missing

The extension has pinned the MCP since 0.2.0 — trustedMcps, keyed by the SHA-256 of its X25519 pub, re-pair prompt on change. Nothing pinned in the other direction:

  • host.ts verified ready.sessionSig against the identity presented in the same connection — proof that the connecting party holds the key it just showed us, and no evidence at all that it is the party we paired with;
  • peer.ts verified nothing.

On loopback that is the local trust boundary doing its job. It stops being harmless the moment the far end of that socket can be something other than a process on this machine: "whichever extension said hello" means a stolen relay credential buys a working session with every bridged MCP — seeing every request they make (URLs, headers, bodies) and answering with content of its choosing — and there is no prompt anywhere, because the MCP has nothing to compare against and the user's browser is never involved.

What this does

The mirror of trustedMcps, in the same TOFU shape:

  • first contact is recorded at <identityDir>/<server-name>.extension-trust.json, mode 0600, written only after the ready signature proves the key — claiming an identity must never be enough to become the pinned one;
  • a different identity afterwards is refused with 1008 before any session exists;
  • an unreadable pin refuses too. Treating a scribbled-on pin file as "no pin" would turn it into a fresh trust-on-first-use, which is the state an attacker wants.

The peer path was worse than the issue said

Not just a missing pin. A peer derived its session key from ready.extensionSessionPub while verifying nothing — so a concentrator could put its own ephemeral public key in that frame, derive the same shared secret with the peer, and read and rewrite everything the peer believed was end-to-end encrypted, forwarding to the real extension to keep the illusion. That is precisely the attack T-host-MITM claims is closed. It was closed for the host's own session in 0.4.0 and never for peers.

Fixed with material 0.4.0 already defined: the host relays the extension's hello to peers (additive — old peers ignore the frame), and peers verify Ed25519Sign(extPriv, ownHelloNonce || extHelloNonce) before deriving.

This narrows T-host-MITM; it does not close it, and the docs now say so. Auto-review caught that sessionSig covers the two nonces and not extensionSessionPub — so a relay forwarding genuine hellos and a genuine signature can still substitute its own ephemeral key and derive the same session key. That is true of the host path too, and has been since 0.4.0. What this PR buys is that an attacker must be positioned to relay a live session and can no longer simply be the extension, which is what an unauthenticated ready allowed. Signing the ephemeral key is a wire change on both sides and is filed as a private security advisory per docs/SECURITY.md's reporting policy.

One deliberate compatibility seam: hosts before this relay no hello, so a new peer behind an old host has nothing to verify against. It warns loudly and proceeds, because the port election picks the concentrator arbitrarily and a strict default would break a mixed-version fleet at random. requireExtensionIdentity: true turns the warning into a refusal for deployments where the concentrator is not simply another MCP on the same laptop. The peer guarantee is only fully in force once every MCP on the machine has this version.

Getting out of a pin

A re-installed extension mints a new identity and would otherwise lock every MCP out at once:

fpx trust list what is pinned, where, and when
fpx trust clear <server-name> drop one
fpx trust clear --all drop the fleet — what a re-install actually needs
FETCHPROXY_TRUST_NEW_EXTENSION=1 re-pair an MCP whose source you don't own

The refusal message names the exact file, resolved from the MCP's real identityDir rather than the default (an early version pointed at ~/.fetchproxy/... while the MCP was using a different directory — caught by a test's own error output).

Compatibility

No consumer MCP needs to change. startHost/startPeer gained required options, but neither is exported; FetchproxyServerOpts gained only optional ones (allowNewExtensionIdentity, requireExtensionIdentity).

The reconnect integration tests did need changing, and that change is the point: they minted a fresh extension identity per connection, which is a browser nobody has — a real one persists in chrome.storage.local. They now reuse one identity across a reconnect, and are refused if they don't.

Testing

86 files / 1188 tests green on this branch, typecheck and build clean. New coverage: the trust store (TOFU, half-match refusal, 0600, corrupt-file refusal, atomic replace), the host path (pins after proof, does NOT pin a forged signature, recognises a returning browser, refuses a stranger, honours the override, refuses an unreadable pin), the peer path (verifies, refuses a MITM'd ready, refuses an unpinned identity, warns behind an old host, refuses when required), and the CLI.

Follows the mcp-host browser-bridge experiment (chrischall/mcp-host#166), which is what surfaced this; that work gates itself on this shipping and being consumed.

Closes #214 — five auto-review findings addressed, including a scoped-MCP bug in fpx trust clear (--all aborted partway, leaving a fleet half-cleared) and a 1-in-5 flake I introduced by putting file I/O on the renegotiation path.

@chrischall chrischall added the security Security-related changes label Aug 5, 2026
@github-actions github-actions Bot added the auto-review Trigger Claude + Copilot review on this PR label Aug 5, 2026
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Claude finished @chrischall's task in 6m 21s —— View job


Review — PR #213: pin the extension's identity, verify it on the peer path

  • Read CLAUDE.md conventions
  • Read the diff
  • Review server trust store + host/peer paths
  • Review CLI + docs + tests
  • Post review

The shape of this is right and the writing is unusually honest about what was broken. extension-trust.ts fails closed on an unreadable pin, requires both keys to match, writes 0600 via write-then-rename, and — the part that matters most — pins only after the ready signature verifies, so claiming an identity can't install you as the pinned one. extensionTrust being a required, undefaulted HostOpts/PeerOpts field is the correct call. The hello relay is genuinely additive: the pre-1.12.0 peer handler on origin/main has no else branch after the frame case, so an unrecognised hello/extension frame falls through harmlessly. Test coverage of the new store and both connection paths is thorough.

Two things I'd want addressed before this merges.

🔴 Important

1. The peer's signature check doesn't bind extensionSessionPub, so the MITM the PR describes still worksinline

The extension signs concatBytes(mcpSessionNonce, extSessionNonce) (background.ts:1094, :2674) and nothing else. Both nonces are values a malicious concentrator forwards verbatim, so it can relay the genuine sessionSig and substitute only extensionSessionPub; the peer's new check passes and derives X25519(peerIdentityPriv, hostEphemeralPub), which the host computes from the peer's long-term X25519 pub (public, in the hello the host already stores) — same key, with the HKDF salt being the peer's own public nonce. The suite's own passing test at extension-pin-peer.test.ts:122 mints an unrelated generateX25519() ephemeral and is accepted, which is exactly this. The only refused case is a garbage 64-byte signature, which a relaying concentrator wouldn't send.

The change is still a real improvement — wholesale identity impersonation by the concentrator is now refused — but docs/SECURITY.md and docs/PROTOCOL.md now state that the signature is what makes the ephemeral-key substitution fail, and it isn't. Either bind the key (sign mcpNonce || extNonce || extensionSessionPub, additively for compat, verified on both paths) or keep the code as-is and record the unbound ephemeral key under Residual risk. Given SECURITY.md is the threat model, an overstated guarantee there is the part I'd treat as blocking.

2. fpx trust clear can't clear a pin for a scoped server name, and --all aborts partwayinline

listPins reads serverName back out of the sanitised filename, so @fetchproxy/example-mcp returns as @fetchproxy_example-mcp, which matches neither SAFE_PLAIN nor SAFE_SCOPED in identity.ts:27-28 and makes clearExtensionPin throw. --all — the documented answer to an extension re-install — dies on the first such pin and leaves the fleet half-cleared under a generic fpx: unexpected error. This is the escape hatch from a lockout, so it should be the sturdiest code in the change.

🟡 Nits

  • host.ts — the new await extensionTrust.read() sits between the "extension already connected" guard and extensionWs = ws, making a previously-synchronous check-and-set interleavable; pinOnReady is startHost-scoped rather than per-connection.
  • identity.tsloadOrCreateIdentity's docblock was left attached to the extracted safeIdentityFileBase, which neither reads nor writes anything.
  • args.tsfpx trust clear <name> --all silently ignores <name>; trust list always prints JSON regardless of --json.

Open question (not a finding)

I could not run the suite — this checkout has no node_modules and dependency install is not available to me here, so the "86 files / 1188 tests green, typecheck and build clean" claim is unverified on my side. Everything above is from reading the source; the CI run on this PR is the authority on green.

Verdict: fail
· branch claude/extension-identity-pin

return true;
}

const payload = concatBytes(sessionNonce, fromB64(extensionHello.sessionNonce));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The signature does not cover extensionSessionPub, so the exact MITM this PR describes still works.

The payload signed by the extension is nonces only — packages/extension-core/src/background.ts:1094:

const sessionSig = await ed25519Sign(
  extIdentity.ed25519Priv,
  concatBytes(result.mcpSessionNonce, currentExtSessionNonce),
);

(and identically at background.ts:2674). ReadyFrame.extensionSessionPub is not in it.

Both nonces are values a malicious concentrator forwards verbatim: it relays the peer's hello unchanged (so the extension signs over the peer's real nonce), and relays the extension's hello unchanged (so the peer verifies against the real nonce). It then forwards the genuine sessionSig and substitutes only extensionSessionPub. This check passes, and peer.ts:294 derives

shared = X25519(peerIdentityPriv, hostEphemeralPub)

which the host computes as X25519(hostEphemeralPriv, peerIdentityX25519Pub) — the peer's long-term X25519 pub is public material the host already holds (host.ts stores peers.set(frame.mcpId, { ws, helloFrame: frame }), and HelloFrameFromServer.identityX25519Pub is in it). The HKDF salt is the peer's own sessionNonce, also public. Same key on both sides.

The suite's own passing test demonstrates this: extension-pin-peer.test.ts:122 mints const eph = await generateX25519() — an ephemeral pair with no cryptographic relationship to the extension identity — and the peer accepts the session. Only the forge: true case (a garbage 64-byte signature) is refused, which is not what a relaying concentrator would send.

What lands as a result is still a real improvement: wholesale identity impersonation by the concentrator is now refused. But the specific claim is not delivered, and it's asserted in three places that should not overstate:

  • docs/PROTOCOL.md: "the signature is what makes that fail, because it cannot be produced without the extension's Ed25519 private key" — it doesn't have to be produced, only relayed.
  • docs/SECURITY.md: "a peer verifies Ed25519Sign(extPriv, ownHelloNonce || extHelloNonce) against it before deriving anything… A concentrator cannot produce that signature without the extension's private key."
  • the comment at peer.ts:287 and the test comment at extension-pin-peer.test.ts:178-179.

Two ways forward, either acceptable:

  1. Bind the key: have the extension sign (mcpNonce || extNonce || extensionSessionPub) — additively, e.g. a second sessionSigV2 field so old peers/hosts keep working — and verify that on both the host and peer paths. This actually closes T-host-MITM.
  2. Ship as-is and correct the claims: say the peer now authenticates which extension is behind the concentrator, and record under Residual risk that the ephemeral key is still unbound, so a concentrator can substitute it and read/answer peer traffic until the signature covers it.

Note the host path has the same gap (a relay in front of the host↔extension socket can do the same substitution) — pre-existing since 0.4.0, but relevant since SECURITY.md is being edited here anyway.

Fix this →

Comment thread packages/cli/src/verbs/trust.ts Outdated
Comment on lines +89 to +97
for (const pin of pins) await clearExtensionPin(pin.serverName, identityDir);
io.err(
`cleared ${pins.length} extension pin(s): ${pins.map((p) => p.serverName).join(', ')} — ` +
`each re-pins on the next browser to complete a handshake with it`,
);
return EXIT.OK;
}

const had = await clearExtensionPin(cmd.serverName!, identityDir);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 trust clear can't clear a pin belonging to a scoped server name — including via --all.

listPins derives serverName from the filename (name.slice(0, -SUFFIX.length)), but the filename is the sanitised base: safeIdentityFileBase maps /_, so @fetchproxy/example-mcp is stored as @fetchproxy_example-mcp.extension-trust.json. Feeding that back into clearExtensionPin re-validates it against packages/server/src/identity.ts:27-28:

const SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
const SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;

@fetchproxy_example-mcp matches neither (@ isn't in SAFE_PLAIN; there's no / for SAFE_SCOPED), so safeIdentityFileBase throws unsafe serverName for identity file: …. Two consequences:

  • fpx trust clear --all — documented in the PR as "what a re-install actually needs" — throws on the first scoped pin in the loop at line 89. runCli catches it as fpx: unexpected error: … (main.ts:135-138), so the fleet is left partially cleared with no indication of which pins survived.
  • fpx trust clear @fetchproxy_example-mcp — copied verbatim from what trust list prints — fails the same way. Only guessing the original @fetchproxy/example-mcp works.

Scoped names are explicitly supported by the identity layer (SAFE_SCOPED, and the docblock says "scoped packages like @fetchproxy/example-mcp are OK"), so this isn't hypothetical. Since this verb is the escape hatch from a lockout, it should be the most robust part of the change.

Suggested shape: have listPins reverse the mapping for @-prefixed names (@scope_name@scope/name) and record it as the serverName, and/or wrap the --all loop in a per-pin try/catch that falls back to unlinking pin.file directly and reports failures rather than aborting.

Fix this →

Comment on lines 192 to 226
ws.close(1008, 'extension already connected');
return;
}
// #208: is this the extension we paired with? Checked HERE, before
// the connection becomes the extension slot, so a stranger never
// reaches the session machinery at all. The pin is only WRITTEN
// later, once the ready signature has proved the key — claiming an
// identity must not be enough to become the pinned one.
let pin: Awaited<ReturnType<ExtensionTrustPort['read']>>;
try {
pin = await opts.extensionTrust.read();
} catch (e) {
// An unreadable pin is the one state where carrying on would
// quietly mean "trust anybody".
console.error(`[fetchproxy] ${String(e)}`);
ws.close(1008, 'extension pin unreadable');
return;
}
const outcome = decideExtensionTrust({
pin,
hello: frame,
allowNew: opts.extensionTrust.allowNew,
serverName: opts.ownServerName,
location: opts.extensionTrust.location,
});
if (outcome.decision === 'refused') {
console.warn(outcome.message);
ws.close(1008, 'extension identity is not the pinned one');
return;
}
if (outcome.decision === 'replace') console.warn(outcome.message);
// Pin on first use, or replace a pin the operator chose to drop —
// but only after the ready frame proves the key (see below).
pinOnReady = outcome.decision !== 'pinned';
identified = 'extension';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The await opts.extensionTrust.read() at line 202 now sits between the if (extensionWs) … 'extension already connected' guard (line 190-193) and the extensionWs = ws assignment (line 227). Check-and-set used to be synchronous within one message handler; it isn't any more, and each connection's ws.on('message', …) handler runs independently, so two extension hellos arriving in the same tick can both pass the guard and the second silently replaces the first in the slot (the first's identified === 'extension' stays true but extensionWs === ws is now false, so its close handler no longer clears state).

Related: pinOnReady is startHost-scoped rather than per-connection, so in that same interleaving the second hello's decision overwrites the first's.

Neither is reachable except from a local process, and the ready-signature check still gates the session — so this is a tidiness issue, not an exploit. Capturing the slot synchronously (set extensionWs = ws before the await and roll it back on refusal), or keying pinOnReady off the connection, would keep the invariant the 1008 was written to enforce.

serverName: string,
dir: string = defaultIdentityDir(),
): Promise<Identity> {
export function safeIdentityFileBase(serverName: string): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The extraction left loadOrCreateIdentity's docblock attached to safeIdentityFileBase. "Read the identity for serverName from dir, generating + persisting a fresh X25519/Ed25519 keypair if no file exists. The file is written with mode 0o600…" describes the function two definitions below; safeIdentityFileBase neither reads nor writes anything. Worth moving it back down onto loadOrCreateIdentity and leaving the "reject unsafe names, translate / to _" half here.

Comment thread packages/cli/src/args.ts Outdated
// fleet re-pairs on the next connection), so it takes saying `--all`
// rather than happening by omitting an argument.
if (values.all) return { kind: 'trust', action: 'clear', all: true };
const serverName = rest[1];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 values.all is tested before rest[1], so fpx trust clear opentable-mcp --all silently ignores the named server and clears the whole fleet. Given the comment right above says clearing everything "takes saying --all rather than happening by omitting an argument", the inverse — a named server being swallowed by a stray --all — is worth a UsageError rather than a silent widening.

Minor and separate: runTrust's list branch always printJsons, ignoring the --json flag that parseCliArgs registers, unlike the other verbs.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🟡 Auto-review verdict: warn — The previous round's 🔴 (extension socket closing during the awaited pin read wedging the host) is fixed at the cause with a closed flag plus a liveness re-check, and backed by a regression test that forces the close to be processed before the read resolves; all three prior nits also landed. Three new minor issues remain — an unconditional extensionClaim clear that can drop another socket's claim, a test-helper waiter that drops non-matching mcpIds, and a display-only scoped-name split — none blocking.
📋 Tracking follow-ups: #218

chrischall added a commit that referenced this pull request Aug 5, 2026
… scoped pins

Auto-review on #213.

**The important one: the signature does not bind `extensionSessionPub`.**
`sessionSig` covers `(mcpNonce || extNonce)` only, so a relay that forwards the
genuine hellos and the genuine signature can still substitute its own ephemeral
public key in the `ready` frame, derive the same shared secret, and read and
rewrite the session. Everything the receiver checks still passes, because none
of it commits to the key the ECDH actually uses. That is true of the host path
too, and has been since 0.4.0 — `host.ts`'s own comment anticipated the wrong
half of it ("the signature won't verify because the MCP nonce differs" holds
only for a MITM that terminates the MCP connection with a hello of its own; a
relay that forwards the MCP's hello verbatim gets a signature over exactly the
nonce the MCP expects).

So the docs no longer claim this PR closes T-host-MITM. What it does is narrow
it: an attacker must be positioned to relay a live session and can no longer
simply BE the extension, which is what an unauthenticated `ready` allowed.
Closing the rest means signing the ephemeral key, which is a wire change on
both sides; filed privately per docs/SECURITY.md rather than described in a
public issue.

**`fpx trust clear` was broken for scoped MCPs.** `listPins` returned the pin
FILE's stem as if it were a server name, and `@fetchproxy/example-mcp` is stored
as `@fetchproxy_example-mcp` — which is refused as unsafe when fed back in. So
clearing a scoped MCP threw, and `--all` aborted on the first one, leaving the
fleet half-cleared while reporting nothing. Deletion now works from the path,
`--all` continues past a failure and names what it could not clear, and the
field is called `pinFile` so the next reader doesn't repeat the mistake.

Also: claim the extension slot synchronously before the awaited pin read, so
the check-and-set cannot interleave with a second hello; move the identity
docblock back onto the function that reads identities; refuse
`trust clear <name> --all` instead of silently doing the broader thing; and
honour `--json` on `trust list` (defaulting to a readable line per pin, since
someone running it is usually staring at a refusal).

One flake fixed at the cause rather than the symptom: reading the pin on every
`ready` put file I/O on the renegotiation path, widening the window in which a
call is sealed with the stale session key and then rejected. The peer reads it
once and updates the cache when it writes.

Closes #214

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chrischall and others added 2 commits August 5, 2026 19:23
… path

Closes #208.

The extension has pinned the MCP since 0.2.0 — `trustedMcps`, keyed by the
SHA-256 of its X25519 pub, re-pair prompt on change. Nothing pinned in the other
direction. `host.ts` verified `ready.sessionSig` against the identity presented
in the SAME connection, which proves the connecting party holds the key it just
showed us and says nothing about whether it is the party we paired with, and
`peer.ts` verified nothing at all.

On loopback that asymmetry is the local trust boundary doing its job. It stops
being harmless the moment the far end of the socket can be something other than
a process on this machine: "whichever extension said hello" means a stolen
relay credential buys a working session with every bridged MCP — every request
they make, and answers of the attacker's choosing — with no prompt anywhere,
because the MCP has nothing to compare against and the user's browser is not
involved.

So the MCP pins the extension too, in the same TOFU shape the extension uses:
first contact is recorded at `<identityDir>/<server-name>.extension-trust.json`
(0600), a different identity afterwards is refused with 1008 before any session
exists, and the pin is written only AFTER the ready signature proves the key —
claiming an identity must never be enough to become the pinned one.

WHAT THE PEER PATH TURNED OUT TO BE. Not just a missing pin. A peer derived its
session key from `ready.extensionSessionPub` while verifying nothing, so a
concentrator could put its own ephemeral pub in that frame, derive the same
shared secret with the peer, and read and rewrite everything the peer believed
was end-to-end encrypted — the exact attack `T-host-MITM` says is closed. It
was closed for the host's own session in 0.4.0 and never for peers. The host now
relays the extension's hello to peers (additive; old peers ignore it) and peers
verify the same signature before deriving. docs/SECURITY.md says so plainly
rather than quietly fixing it.

One compatibility seam, deliberate: hosts before this relay no hello, so a new
peer behind an old host has nothing to verify against. It warns loudly and
proceeds, because the port election picks the concentrator arbitrarily and
refusing would break a mixed-version fleet at random. `requireExtensionIdentity`
turns that into a refusal for deployments where the concentrator is not another
MCP on the same laptop. The peer guarantee is only in force once every MCP on
the machine has this.

GETTING OUT OF IT. A re-installed extension mints a new identity and would
otherwise lock every MCP out at once, so: the refusal names the exact file,
`fpx trust list` shows what is pinned, `fpx trust clear <server>` drops one and
`--all` drops the fleet, and `FETCHPROXY_TRUST_NEW_EXTENSION=1` re-pairs an MCP
whose source you do not own — the one lever that reaches an unmodified consumer.

No consumer MCP needs to change: `startHost`/`startPeer` gained required
options, but those are internal, and `FetchproxyServerOpts` gained only optional
ones. The reconnect tests did need changing, and the change is the point — they
minted a fresh extension identity per connection, which is a browser nobody has;
a real one persists in `chrome.storage.local`, and is now refused if it doesn't.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… scoped pins

Auto-review on #213.

**The important one: the signature does not bind `extensionSessionPub`.**
`sessionSig` covers `(mcpNonce || extNonce)` only, so a relay that forwards the
genuine hellos and the genuine signature can still substitute its own ephemeral
public key in the `ready` frame, derive the same shared secret, and read and
rewrite the session. Everything the receiver checks still passes, because none
of it commits to the key the ECDH actually uses. That is true of the host path
too, and has been since 0.4.0 — `host.ts`'s own comment anticipated the wrong
half of it ("the signature won't verify because the MCP nonce differs" holds
only for a MITM that terminates the MCP connection with a hello of its own; a
relay that forwards the MCP's hello verbatim gets a signature over exactly the
nonce the MCP expects).

So the docs no longer claim this PR closes T-host-MITM. What it does is narrow
it: an attacker must be positioned to relay a live session and can no longer
simply BE the extension, which is what an unauthenticated `ready` allowed.
Closing the rest means signing the ephemeral key, which is a wire change on
both sides; filed privately per docs/SECURITY.md rather than described in a
public issue.

**`fpx trust clear` was broken for scoped MCPs.** `listPins` returned the pin
FILE's stem as if it were a server name, and `@fetchproxy/example-mcp` is stored
as `@fetchproxy_example-mcp` — which is refused as unsafe when fed back in. So
clearing a scoped MCP threw, and `--all` aborted on the first one, leaving the
fleet half-cleared while reporting nothing. Deletion now works from the path,
`--all` continues past a failure and names what it could not clear, and the
field is called `pinFile` so the next reader doesn't repeat the mistake.

Also: claim the extension slot synchronously before the awaited pin read, so
the check-and-set cannot interleave with a second hello; move the identity
docblock back onto the function that reads identities; refuse
`trust clear <name> --all` instead of silently doing the broader thing; and
honour `--json` on `trust list` (defaulting to a readable line per pin, since
someone running it is usually staring at a refusal).

One flake fixed at the cause rather than the symptom: reading the pin on every
`ready` put file I/O on the renegotiation path, widening the window in which a
call is sealed with the stale session key and then rejected. The peer reads it
once and updates the cache when it writes.

Closes #214

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chrischall
chrischall force-pushed the claude/extension-identity-pin branch from c6208ff to 70bd76f Compare August 5, 2026 23:24
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Claude finished @chrischall's task in 4m 54s —— View job


Review — PR #213: pin the extension's identity, verify it on the peer path

Previous round

Both 🔴 findings are addressed, and the three 🟡 nits with them.

  • Signature doesn't bind extensionSessionPub — option 2 taken, and taken properly. docs/SECURITY.md now says "It does not close the attack, and the difference matters", spells out the relay-forwards-genuine-frames case, and notes the host path has the same gap since 0.4.0; docs/PROTOCOL.md and CLAUDE.md match. The correction is more explicit than the finding asked for.
  • fpx trust clear and scoped names — fixed at the cause: serverNamepinFile with a docblock explaining why the stem is not a server name, --all deletes by path (unlink(pin.file)), continues past failures, and reports which pins survived instead of reporting a clean sweep. Two new tests (clears a scoped MCP…, clears every pin with --all even when one is scoped).
  • Nits: extensionClaim claims the slot synchronously; the identity.ts docblock is back on loadOrCreateIdentity; trust clear <name> --all is a UsageError; trust list honours --json and defaults to a readable line. Also good: peers joining after the extension get the relayed hello (host.ts:318-320), which the first round didn't cover.

The cachedPin change (read once per peer, updated on write) is the right call for the renegotiation path and the comment says why.

🔴 Important (new)

Closing the extension socket during the awaited pin read wedges the host permanentlyinline

extensionClaim closed the two-hellos interleaving; the same await also admits a close. The close handler at host.ts:443 only cleans up a connection that has reached identified = 'extension' (241), so a socket that drops while suspended at await opts.extensionTrust.read() (215) is a no-op for cleanup — and then the resumed handler assigns extensionWs = ws (242) on a closed socket. ws.send() on a closed socket is swallowed, so nothing throws: every later extension connection is refused at line 197 with 1008 'extension already connected', onExtensionDisconnect never fires, and sendOwnInner writes into the dead socket. Nothing clears extensionWs short of restarting the MCP.

git diff origin/main...HEAD -- packages/server/src/host.ts confirms this is new — pre-PR the guard and the assignment were adjacent with no await between them. The fix is the guard the file already uses twenty lines below (if (extensionWs !== ws) return; at 387, after the ECDH/HKDF awaits, for exactly this reason): re-check extensionClaim === ws && ws.readyState === WebSocket.OPEN after the read, before taking the slot.

🟡 Nits

  • extension-pin-peer.test.ts:178 — "A concentrator substituting its own ephemeral pub cannot produce this signature" is the claim the docs just walked back; the forge: true case is a garbage 64-byte signature, not the substitution. Same for the file's opening docblock.
  • host.ts:330-333 still says a MITM that relays "the real extension's bytes" fails because "the signature won't verify because the MCP nonce differs". 70bd76f's own commit message identifies that as the wrong half — a relay forwarding the MCP's hello verbatim gets a signature over exactly the expected nonce. Three docs were corrected in this commit; this comment sits in the security-critical file and wasn't.
  • main.ts:111fpx profile remove deletes fpx-<name>.json but not fpx-<name>.extension-trust.json, so the pin outlives the profile. Re-creating a profile with the same name silently inherits a pin it never made (and it shows up in fpx trust list forever otherwise).

Open question (not a finding)

Still no node_modules in this checkout and no dependency install available, so I could not run vitest, npm run typecheck, or the build. Everything above is from reading the source; CI on this PR is the authority on green.

Verdict: fail
· branch claude/extension-identity-pin

The #214 item was two things and I had fixed one: `extensionClaim` closed the
check-and-set race, but the flag itself was still host-wide, kept correct only
by the claim and a reset on close. Scope is a better argument than discipline —
it now lives in the connection closure, so the ready handler cannot consult a
decision made for a socket that has since gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
if (outcome.decision === 'replace') console.warn(outcome.message);
// Pin on first use, or replace a pin the operator chose to drop —
// but only after the ready frame proves the key (see below).
pinOnReady = outcome.decision !== 'pinned';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 An extension WS that closes during the awaited pin read leaves extensionWs pointing at a dead socket, and the host never accepts another extension.

extensionClaim fixed the two-hellos interleaving, but the other thing the new await at line 215 admits is a close in the same window. The close handler (line 443) can only clean up a connection that has already reached line 241-242:

ws.on('close', () => {
  if (extensionClaim === ws) extensionClaim = null;
  if (identified === 'extension' && extensionWs === ws) {  }

Sequence:

  1. hello arrives → extensionClaim = ws (207) → suspend at await opts.extensionTrust.read() (215);
  2. the socket drops (MV3 SW eviction right after connect, chrome://extensions reload, a FIN arriving behind the hello). ws emits 'close' while the handler is suspended — identified is still null and extensionWs is still null, so the branch at 445 does nothing. Only the claim is cleared;
  3. the read resolves, the handler resumes and runs identified = 'extension'; extensionWs = ws; (241-242) on a socket that is already CLOSED.

ws.send() on a closed socket with no callback is swallowed, so nothing throws — the bridge is just silently dead:

  • every later extension connection hits if (extensionWs || extensionClaim) (197) and is closed with 1008 'extension already connected', and nothing ever clears extensionWs again (grep -n "extensionWs" packages/server/src/host.ts → assigned only at 242, nulled only at 446 behind that guard). Recovery requires restarting the MCP;
  • onExtensionDisconnect never fires, so rejectAllPending / resetSessionPromise never run and sendOwnInner (499) sees a truthy extensionWs and writes into the dead socket — callers hang to their own timeout.

Pre-PR this was structurally impossible: the guard and the assignment were adjacent with no await between them (git diff origin/main...HEAD -- packages/server/src/host.ts shows the read inserted between if (extensionWs) and identified = 'extension').

The codebase already has the guard for exactly this class of gap, twenty lines below at 387 — if (extensionWs !== ws) return; after the ECDH/HKDF awaits, with a comment saying "the extension WS may close during derivation". The same check belongs after the pin read:

// The pin read yielded; the socket may be gone.
if (extensionClaim !== ws || ws.readyState !== WebSocket.OPEN) return;

placed before identified = 'extension' (and mirrored on the two refusal paths, which currently ws.close() a socket that may already be closed — harmless, but the early return makes it moot).

Fix this →

});

it('refuses a ready whose signature does not verify — the MITM case', async () => {
// A concentrator substituting its own ephemeral pub cannot produce this

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This comment is the claim the rest of the PR just walked back. readyFor(..., { forge: true }) sends new Uint8Array(64).fill(3) — a garbage signature, which is the concentrator that invents an extension, not the one that substitutes an ephemeral pub. The substituting concentrator relays the genuine signature and is accepted; the passing test above (derives a session when the forwarded hello signs the ready) already mints an unrelated generateX25519() ephemeral and gets a session.

Worth saying what the case actually is — "a concentrator that cannot sign as the extension is refused; one that relays a genuine signature can still swap extensionSessionPub, per docs/SECURITY.md §T-host-MITM" — so the next reader doesn't take the test as proof of the stronger property. Same for the file's opening docblock, whose closing line ("The fix is the same material in both places…") reads as though the substitution is now blocked.

@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Claude finished @chrischall's task in 3m 55s —— View job


Re-review — PR #213: pin the extension's identity, verify it on the peer path

Both 🔴 findings from the last pass are properly resolved, and resolved the honest way.

The overstated T-host-MITM claim. docs/SECURITY.md now carries an explicit "It does not close the attack, and the difference matters" section naming extensionSessionPub as unbound, docs/PROTOCOL.md says the same under ready, and CLAUDE.md §3b repeats it in the summary a future reader will actually hit first. The guarantee is now stated as what it is: an attacker must be positioned to relay a live session, and can no longer simply be the extension.

fpx trust clear for scoped MCPs. --all now deletes by pin.file rather than re-deriving a path from the stem, collects failures instead of throwing out of the loop, and names the survivors — the right shape for an escape hatch. PinnedEntry.pinFile is honest about what the string is. Covered by two new tests (trust.test.ts:122, :142).

The three nits also landed: extensionClaim closes the check-and-set window, pinOnReady is now per-connection scope rather than host-wide discipline (host.ts:184), the identity docblock is back on loadOrCreateIdentity, trust clear <name> --all is a UsageError, and trust list honours --json with a readable default.

One new issue, introduced by the fix to the third nit.

🔴 Important

1. An extension socket that closes during the awaited pin read wedges the host permanentlyinline

extensionClaim closed the interleaving race but opened a liveness one. On origin/main the extension branch ran straight from the guard to extensionWs = ws with no await; now await opts.extensionTrust.read() sits between them, and identified = 'extension' is only set after it. If the socket closes during that read, the close handler's guard (identified === 'extension' && extensionWs === ws) doesn't match, so it clears only the claim — and the resumed handler then assigns extensionWs = ws on a dead socket. grep -n "extensionWs = null" packages/server/src/host.ts returns exactly one site, line 448, inside the close handler that already fired. The host then refuses every later extension with 1008 'extension already connected', never fires onExtensionDisconnect, and can't recover without an MCP restart — taking every peer on the machine with it, since the host holds the port.

The window is one file read, so this is unlikely rather than routine. It's flagged because the state is unrecoverable and the symptom points away from the cause, and because a ws.readyState re-check after the await (or setting identified synchronously with the claim) closes it in a line.

🟡 Nits

  • peer.ts:303 — the docs were corrected about the unbound ephemeral key; three code comments asserting the opposite were not (peer.ts:301-303, extension-pin-peer.test.ts:22-28 and :178-179, plus the 0.4.0 comment at host.ts:332-335 that commit 70bd76f's own message calls out). The source now contradicts the threat model it cites.
  • trust.ts:94trust list still prints the file stem, so a scoped MCP's listed name (@fetchproxy_example-mcp) is the one string trust clear rejects. --all covers the lockout case; this is the last papercut on the single-server path.

Open question (not a finding)

Still unverified on my side: this checkout has no node_modules (ls node_modules → not found) and dependency install isn't available here, so the "typecheck and build clean, suite green" claim rests on CI, not on me. Everything above is from reading the source and from git/grep output quoted inline.

Verdict: fail
· branch claude/extension-identity-pin

// but only after the ready frame proves the key (see below).
pinOnReady = outcome.decision !== 'pinned';
identified = 'extension';
extensionWs = ws;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 An extension socket that closes during the awaited pin read leaves extensionWs pointing at a dead socket forever.

extensionClaim closes the interleaving race, but it introduced a second one. On origin/main the extension branch was fully synchronous up to the assignment:

git show origin/main:packages/server/src/host.ts | sed -n '174,181p'
        if (frame.type === 'hello' && frame.role === 'extension') {
          if (extensionWs) {
            ws.close(1008, 'extension already connected');
            return;
          }
          identified = 'extension';
          extensionWs = ws;

Now await opts.extensionTrust.read() (line 217) sits between the guard and both identified = 'extension' (243) and extensionWs = ws (244). If the socket closes while that read is in flight — MV3 eviction right after the hello, a chrome://extensions reload, the user toggling the extension — the close handler runs first, and its guard is

if (extensionClaim === ws) extensionClaim = null;
if (identified === 'extension' && extensionWs === ws) {  }   // identified is still null here

so it clears the claim and nothing else. The message handler then resumes and sets identified = 'extension'; extensionWs = ws on an already-closed socket. ws.send() in the ws library no-ops on a non-OPEN socket rather than throwing (sendAfterClose only does buffered-byte accounting when no callback is passed), so the branch completes normally.

extensionWs = null has exactly one assignment site:

$ grep -n "extensionWs = null" packages/server/src/host.ts
448:        extensionWs = null;

— inside the close handler that has already fired for this socket. From that point the host refuses every subsequent extension connection with 1008 'extension already connected', never fires onExtensionDisconnect, and never recovers short of restarting the MCP. Because the host holds the port, every peer on the machine is down with it, and the diagnostic ("extension already connected" while no extension is connected) points away from the cause.

The window is one file read, so this is unlikely rather than impossible — but it's a new, unrecoverable state on the path this PR added, and it's cheap to close. Either set identified = 'extension' synchronously alongside extensionClaim = ws so the existing close guard matches, or re-check liveness after the await:

if (ws.readyState !== WebSocket.OPEN) { extensionClaim = null; return; }

Fix this →

Comment thread packages/server/src/peer.ts Outdated
// #208: authenticate the extension BEFORE deriving anything from a
// key it supplied. Without this a concentrator can put its own
// ephemeral pub here, derive the same shared secret with us, and read
// the traffic `T-host-MITM` says it cannot.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 docs/SECURITY.md and docs/PROTOCOL.md were corrected in this PR to say the signature does not bind extensionSessionPub — but the three code comments that assert the opposite were left as-is, so the source now contradicts the threat model it points at:

  • here (peer.ts:301-303): "Without this a concentrator can put its own ephemeral pub here, derive the same shared secret with us, and read the traffic T-host-MITM says it cannot" — it still can, with this check in place.
  • packages/server/tests/extension-pin-peer.test.ts:178-179: "A concentrator substituting its own ephemeral pub cannot produce this signature" — it doesn't have to produce one; relaying the genuine one works. And the file's docblock (:22-28) still frames the fix as closing "the exact attack T-host-MITM … says is closed".
  • packages/server/src/host.ts:332-335 (unchanged by this PR, but the commit message for 70bd76f calls it out explicitly): "relay the real extension's bytes (signature won't verify because the MCP nonce differs)" — true only for a MITM that terminates the MCP connection with a hello of its own.

The prose fix landed; the comments are what the next reader will actually reach for.

Fix this →

Comment thread packages/cli/src/verbs/trust.ts Outdated
// at a refusal and wants to know which browser is pinned and since when,
// not to parse 32 bytes of base64.
for (const pin of pins) {
io.out(`${pin.pinFile} pinned ${pin.pinnedAt} x25519=${pin.identityX25519Pub}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 --all and clear <real-server-name> both work now, and the pinFile rename makes the mismatch legible in the source. What's left is the operator-facing half: trust list prints the stem, and for a scoped MCP the stem is the one string trust clear will reject.

$ grep -n "SAFE_PLAIN\|SAFE_SCOPED" packages/server/src/identity.ts
27:const SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
28:const SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;

So fpx trust list prints @fetchproxy_example-mcp, and pasting that into fpx trust clear throws unsafe serverName for identity file: …, surfacing as fpx: unexpected error (main.ts:135-138). The user has to know to guess back to @fetchproxy/example-mcp. Since this verb exists precisely for someone already staring at a refusal, printing a copy-pasteable name (reverse the @scope_name@scope/name mapping, unambiguous for @-prefixed stems) would finish the job the rename started.

Fix this →

…ready gone

Auto-review round three on #213. The claim I added to close a check-and-set
race opened a worse hole, and the reviewer is right that it is worse: the pin
read is awaited, so the socket can close inside it, and the resuming handler
then set `extensionWs` to a dead connection. Its close event had already fired,
so nothing would ever clear it — every later extension refused with 1008
"already connected", for the life of the process. A wedged bridge, from the
guard meant to protect it.

The handler now checks liveness, not just identity, after the await: a
per-connection `closed` flag set the instant the socket goes, plus a
`readyState` check. The test drives the exact ordering — gate the pin read,
close the socket, let the server PROCESS the close, then release — and it fails
without the guard.

Three nits with it:

- The peer tests claimed a concentrator "cannot produce this signature" when
  substituting its own ephemeral pub. It cannot forge one, which is what that
  test shows; it does not need to, because it can relay a genuine one. Wording
  corrected, and the residual is now pinned by a test that derives the relay's
  key and asserts it equals the peer's session key. It records today's
  behaviour and should fail the day the signature covers the ephemeral key.
- `host.ts` still carried the claim three docs had already corrected.
- `fpx profile remove` deleted the identity and left the pin, so a profile
  re-created under the same name inherited a browser identity it never met.

Closes #214

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Claude finished @chrischall's task in 4m 34s —— View job


Review — PR #213: pin the extension's identity, verify it on the peer path

  • Read CLAUDE.md conventions
  • Read the diff (re-review after 152efdd + 54f5754)
  • Verify the round-3 🔴 (extension socket closing during the pin read)
  • Verify the round-3 nits
  • Fresh pass over host / peer / trust store / CLI / tests
  • Post review

Previous round

The 🔴 is fixed, and fixed at the level it should be. 54f5754 adds a per-connection closed flag set in the close handler plus a readyState check after the pin read (host.ts:250-253), so the resumed handler no longer takes the extension slot for a socket whose close event has already fired. The regression test drives the exact ordering the bug needed — gate the read, close, let the server process the close, then release — which is the part a naive test would get wrong.

The three nits landed too: extension-pin-peer.test.ts renames the forged-signature case and adds a KNOWN RESIDUAL test that derives the relay's key and asserts it equals the peer's session key (a residual recorded as an executable fact rather than a sentence — good, and it will fail loudly the day the signature covers the ephemeral key); the stale host.ts:343-358 comment is corrected and says why the old wording was wrong; fpx profile remove now takes the pin beside the identity, with a test.

No 🔴 findings this round.

🟡 Nits

  • host.ts:250-253 — the new bail-out (and the two refusal paths at :226/:239) release extensionClaim unconditionally, but by then the claim may belong to a later socket: ws1 closes during its read → close handler releases → ws2 claims → ws1 resumes and nulls ws2's claim → a third hello passes the guard. Bounded and local-only, but the close handler at :470 already has the right form (if (extensionClaim === ws)).
  • peer.ts:300-303 — the one comment the correction didn't reach. It still reads as though authenticating the extension is what stops a concentrator substituting its own ephemeral pub; host.ts, both docs, and the new test all now say it isn't.
  • packages/server/tests/peer.test.ts — untouched by this PR, and it shows: all five startPeer({…}) call sites (:55, :107, :131, :160, :227) omit the now-required extensionTrust, which only goes unnoticed because packages/server/tsconfig.json has "include": ["src/**/*"] so tests aren't in tsc -b. At runtime they take the no-relayed-hello branch and never dereference it. The sibling host.test.ts got a blankTrust() helper for exactly this. Related: the comment at peer.test.ts:214"peer.ts does not itself verify sessionSig cryptographically (see peer.ts's ready handling)" — is now false, and the placeholder signature at :221 passes only by way of the compatibility branch.
  • packages/cli/src/verbs/trust.ts:94 — carried over from the last round and still open: trust list prints pin.pinFile, and for a scoped MCP that stem (@fetchproxy_example-mcp) is the one string trust clear rejects — safeIdentityFileBase throws and main.ts:141 surfaces it as fpx: unexpected error. --all and clear @fetchproxy/example-mcp both work now and are tested; it's only the copy-pasteable label that's missing.

Notes (not findings)

  • I checked whether the new pin write could leak into a developer's real ~/.fetchproxy/identity from the suite. Of the 18 test files that build a FetchproxyServer without identityDir, only bridge-health.test.ts and convenience.test.ts reach listen()/connect(), and neither drives an extension hello — so nothing hits the trust store. reconnect.test.ts passes idDir at all 12 construction sites.
  • The relay of the extension hello reads as genuinely additive in both directions, including the later-joining peer (host.ts:334-336).

Open question

Still no node_modules in this checkout and no dependency install available here, so I could not run npm test, npm run typecheck, or npm run build. The "1188 tests green" claim is unverified on my side; everything above is from reading the source, and CI on this PR is the authority on green. Note that tsc -b would not catch the peer.test.ts gap above either way, per its include.

Verdict: warn
· branch claude/extension-identity-pin

…ng peer.ts

Two items from the round-three review comment that its follow-up checklist
did not carry over.

`fpx trust list` printed the pin FILE's stem, so a scoped MCP listed as
`@fetchproxy_example-mcp` — the exact string `fpx trust clear` refuses as an
unsafe server name. The listing is what an operator copies, and they copy it at
the moment they are already locked out, so it now prints the recovered server
name (`@scope/name`) and a test round-trips list → clear. `--all` covered the
lockout case; this closes the single-server path.

And the last of the three code comments that still asserted what the docs had
been corrected about: `peer.ts` claimed the check stops a concentrator
substituting its own ephemeral pub. It does not — that is the residual the
advisory tracks and `extension-pin-peer.test.ts` now pins in place. The comment
says so, and points at both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +250 to +253
if (closed || ws.readyState !== WebSocket.OPEN) {
extensionClaim = null;
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The liveness guard is right, but this releases the claim unconditionally — and by the time it runs, the claim may belong to somebody else.

The close handler uses the guarded form for exactly this reason (host.ts:470):

if (extensionClaim === ws) extensionClaim = null;

The unguarded form here (and on the two refusal paths at :226 and :239) can reopen the window the claim was added to close:

  1. ext1 hello → extensionClaim = ws1 → suspends in the pin read;
  2. ws1 closes → close handler clears the claim (extensionClaim === ws1);
  3. ext2 connects → guard at :203 passes (both slots null) → extensionClaim = ws2 → suspends in its pin read;
  4. ws1's read resolves → closed is true → extensionClaim = null, clobbering ws2's claim;
  5. ext3 connects → guard passes → claims → both ws2 and ws3 resume live and each run identified = 'extension'; extensionWs = ws.

Bounded (last writer wins the slot, no permanent wedge) and only reachable from a local process racing three connections through one file read — so a nit, not a repeat of the round-3 bug. But it is one word:

Suggested change
if (closed || ws.readyState !== WebSocket.OPEN) {
extensionClaim = null;
return;
}
if (closed || ws.readyState !== WebSocket.OPEN) {
if (extensionClaim === ws) extensionClaim = null;
return;
}

Same for :226 and :239.

Fix this →

Comment on lines +300 to +303
// #208: authenticate the extension BEFORE deriving anything from a
// key it supplied. Without this, anything that could reach us could BE
// the extension with no key material at all.
//

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The last place still carrying the claim the rest of the PR walked back.

54f5754 corrected host.ts:343-358 ("It does NOT stop a relay that forwards the real hellos and the real signature… nothing here commits to extensionSessionPub"), and the test file's docblock and case name with it. This comment sits directly above the authenticateExtension call it describes and still reads as though the check is what stops ephemeral-pub substitution:

Without this a concentrator can put its own ephemeral pub here, derive the same shared secret with us, and read the traffic T-host-MITM says it cannot.

With this, a concentrator relaying a genuine hello and a genuine sessionSig can still put its own ephemeral pub here — that is what extension-pin-peer.test.ts's KNOWN RESIDUAL case now asserts, and what docs/SECURITY.md §T-host-MITM now says. What the check actually buys is that the concentrator can no longer be an extension of its own invention.

Fix this →

@chrischall chrischall added the ready-to-merge Owner has reviewed; arm auto-merge to land when CI is green label Aug 5, 2026
@chrischall
chrischall enabled auto-merge (squash) August 5, 2026 23:39
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Claude finished @chrischall's task in 4m 57s —— View job


Re-review — PR #213: pin the extension's identity, verify it on the peer path

  • Read CLAUDE.md conventions
  • Re-read the diff (5 commits, 07af72c9c1ec9d)
  • Verify the previous round's 🔴 (wedged host on close-during-pin-read)
  • Verify the previous round's 🟡 (stale comments, trust list names)
  • Review the new code for fresh issues
  • Post review

Previous round

The 🔴 is fixed at the cause, and fixed with a regression test that would actually have caught it.

54f5754 adds a per-connection closed flag set in the close handler, and a liveness re-check between the awaited pin read and taking the slot (host.ts:250-253). extension-pin-host.test.ts:153 gates the trust store's read(), closes the socket inside it, sleeps 50 ms so the server has processed the close before the read resolves — the ordering comment (:182-184) is explicit that without the pause the handler tidies up after itself and the test proves nothing — then asserts a returning extension still gets a working bridge.

The three 🟡s landed too:

  • peer.ts:300-310, host.ts:343-358, and extension-pin-peer.test.ts:23-44 / :238 now say what the check actually buys. The host.ts comment names the earlier wrong claim explicitly ("An earlier version of this comment claimed such a relay fails because the MCP nonce differs; that is only true of a MITM that terminates our connection with a hello of its own"), and the residual case is pinned as a test named KNOWN RESIDUAL: a relay can still swap the ephemeral key and share the session — with a comment saying it should start failing the day the signature covers the ephemeral key. That's the right way to hold a known gap.
  • trust list prints a name clear accepts (serverNameFromPinFile), covered by lists a scoped MCP under a name clear will accept.
  • fpx profile remove now unlinks the pin beside the identity (main.ts:112, profiles.ts:46), tested in main.test.ts.

Nothing new at 🔴 level. Three minor things, none blocking.

🟡 Nits

  • host.ts:251 — the new abandon path clears extensionClaim unconditionally, but the close handler (:470) may already have released this socket's claim, so a newer connection's claim gets dropped instead. A → close → B claims → A resumes and nulls B's claim → C passes the guard, and B and C both reach extensionWs = ws: the interleaving extensionClaim exists to prevent. Self-heals, and needs a close inside a file read plus two reconnects — but if (extensionClaim === ws) is a one-word fix, and applies to the two refusal paths at :226 / :239 as well.
  • mock-extension.ts:81waitForServerHello drains every waiter with splice(0) on each hello and drops the ones whose mcpId doesn't match, so a multi-MCP use of this helper settles only via the 5 s timeout. No current caller waits on two ids; it's a trap for the next one.
  • trust.ts:36serverNameFromPinFile splits on the first _, and SAFE_SCOPED (identity.ts:28) permits _ on both sides of the /, so @my_org/tool-mcp lists back as @my/org_tool-mcp. Display only — the lossy mapping means clear still finds the same file.

Open question (not a finding)

Unchanged from the last two rounds: ls node_modules in this checkout returns nothing and dependency install isn't available here, so I could not run vitest, npm run typecheck, or the build. The "1188 tests green" claim rests on CI. Everything above is from reading the source and from git/grep output.

Worth noting one thing I checked but could not execute: the peer path now verifies Ed25519Sign(extPriv, ownHelloNonce || extHelloNonce) on every ready, including renegotiations. That is sound only because background.ts mints currentExtSessionNonce inside connect()'s open handler (:1019-1021) and sends a fresh hello on the same event (:1033) — so every renegotiation arrives with a new hello the host relays (host.ts:278) before the ready that follows it. Both signing sites (:1112, :2907) use that same current nonce. The reconnect integration tests exercise this, and their diff is the right change: reusing one extension identity across a reconnect instead of minting a new one per connect.

Verdict: warn
· branch claude/extension-identity-pin

@chrischall
chrischall disabled auto-merge August 5, 2026 23:40
@chrischall
chrischall merged commit 0eeced7 into main Aug 5, 2026
15 checks passed
@chrischall
chrischall deleted the claude/extension-identity-pin branch August 5, 2026 23:40
@chrischall
chrischall restored the claude/extension-identity-pin branch August 5, 2026 23:42
// extension would be refused "already connected" until the process
// restarts. Check liveness, not just identity.
if (closed || ws.readyState !== WebSocket.OPEN) {
extensionClaim = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The liveness guard is right, but this clear is unconditional — it drops whatever claim is current, which by this point may belong to a different socket.

The close handler already released this socket's claim (if (extensionClaim === ws) extensionClaim = null;, line 470), so by the time the read resolves extensionClaim can legitimately be a newer connection:

  1. A's hello → extensionClaim = A → suspends in read();
  2. A closes → close handler sets extensionClaim = null;
  3. B's hello → guard passes (extensionWs and extensionClaim both null) → extensionClaim = B → suspends in its own read();
  4. A resumes, sees closed, and runs extensionClaim = nullclearing B's claim;
  5. C's hello now passes the guard too (extensionWs still null, claim cleared), so B and C both go on to run identified = 'extension'; extensionWs = ws; — the exact interleaving extensionClaim was introduced to prevent.

It self-heals (whichever socket ends up in extensionWs clears it on close), and it needs a close inside a file read plus two reconnects, so this is narrow rather than dangerous. But the fix is the same shape as the guard twenty lines below:

Suggested change
extensionClaim = null;
if (extensionClaim === ws) extensionClaim = null;

Worth applying to the two refusal paths above (lines 226, 239) as well, for the same reason.

Fix this →

*/
function serverNameFromPinFile(stem: string): string {
if (!stem.startsWith('@')) return stem;
const at = stem.indexOf('_');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 indexOf('_') splits on the first underscore, and the docblock's justification ("a scoped one has exactly one separator") isn't quite true — SAFE_SCOPED in identity.ts:28 admits _ on both sides of the /:

$ grep -n "SAFE_SCOPED" packages/server/src/identity.ts
28:const SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;

So @my_org/tool-mcp is a legal server name, stores as @my_org_tool-mcp, and lists back as @my/org_tool-mcp. The listed string is wrong but harmlessly so — safeIdentityFileBase maps it to the same file, so clear still deletes the right pin. Purely a display defect, and the mapping is genuinely lossy in that case; the cheap version is to translate only when the stem has exactly one _, and otherwise print the stem with a note.

Fix this →

if (seen) return seen;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`no server hello for ${mcpId}`)), 5_000);
helloWaiters.push(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The waiter is dropped, not re-queued, when a server hello for a different mcpId arrives first.

ws.on('message', …) does helloWaiters.splice(0) — draining every waiter — and each callback returns early if its own mcpId isn't in serverHellos yet. So a waiter for B that is woken by A's hello is discarded, and B's later hello finds an empty waiter list: the promise only settles when the 5 s timeout rejects with no server hello for B.

Every current caller waits on a single mcpId, so nothing is failing today. But this helper is the obvious thing to reach for the first time a pinning test involves a host plus a peer, and the failure mode is a 5-second timeout that reads like a protocol bug. Re-push the waiter when it doesn't match (or key the waiter list by mcpId).

Fix this →

chrischall added a commit that referenced this pull request Aug 5, 2026
…scoped names (#219)

The three nits from #218 — the post-merge review of #213 — on a fresh
branch off the squashed main.

## The claim cleanup could drop someone else's claim

`extensionClaim` exists so a second extension hello cannot interleave
with the awaited pin read. Its own cleanup could defeat it: the abandon
and refusal paths cleared the claim unconditionally, but the close
handler may have released this socket's claim already and a newer
connection may hold it by now.

```
A claims → A closes (handler clears A's claim) → B claims
        → A's handler resumes, clears B's claim → C passes the guard
        → B and C both reach `extensionWs = ws`
```

Self-healing and improbable — it needs a close inside a file read plus
two reconnects — but it is the exact interleaving the claim was added to
prevent, so all four sites now check `=== ws` before releasing.

## The test helper's waiters were keyed by nothing

`waitForServerHello` drained *every* waiter on each hello and dropped
the ones whose `mcpId` didn't match, so a caller waiting on two MCPs
could only settle by timing out. No current caller waits on two ids; the
next person to write one would have paid for it with a 5-second mystery.
Keyed by `mcpId` now.

## `serverNameFromPinFile` guessed where it cannot know

`_` is legal on both sides of a scope's `/` (`SAFE_SCOPED`), so
`@my_org/tool-mcp` and `@my/org_tool-mcp` share the stem
`@my_org_tool-mcp` and neither is recoverable from it. My docblock
claimed the mapping was unambiguous; it isn't, and printing a guess
names a package that may not exist.

It now translates only the unambiguous case (leading `@`, exactly one
`_`) and shows the stem otherwise — and because a bare stem is the one
string `clear` used to reject, `clear` now accepts a stem as well as a
server name. Everything `list` prints stays usable, rather than being
legible and rejected at the same time.

## Testing

1232 tests, three consecutive clean runs, `tsc -b` across all six
workspaces. New test covers the ambiguous-stem path end to end: list
shows the stem, does *not* show a fabricated name, and `clear` takes the
stem.

Closes #218

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
chrischall added a commit that referenced this pull request Aug 6, 2026
Closes the residual recorded in the private advisory
**GHSA-j6jv-w774-77m6**, found while building the mcp-host browser
bridge (chrischall/mcp-host#162) and left open by #213 because the fix
breaks the wire.

> ⚠️ **BREAKING.** `PROTOCOL_VERSION` 2 → 3. Every package **and the
browser extension** must be updated together; a v2 peer is refused at
the hello. Reload the unpacked extension when this ships, or the bridge
stops working — loudly, at the handshake, not silently.

## The hole

0.4.0's mutual auth proved the extension was the one whose hello
arrived. It never committed to the key the session is derived **from**:

```
sessionSig = Ed25519Sign(extEdPriv, mcpHelloNonce || extHelloNonce)   // v2
shared     = X25519(mcpIdentityPriv, extensionSessionPub)             // what we derive
```

So a relay could forward the genuine hellos and the genuine signature —
every nonce and identity intact, the pair code unchanged — substitute an
`extensionSessionPub` it held the private half of, and compute the same
shared secret as the MCP. Nothing either side checked said otherwise.
The peer path made it trivial (it verified nothing at all until #213),
but **the host path had the same hole from 0.4.0 onward**.

## The fix

```
sessionSig = Ed25519Sign(extEdPriv, mcpHelloNonce || extHelloNonce || extensionSessionPub)
```

One definition — `readySignaturePayload()` in `@fetchproxy/protocol` —
used by the extension that signs it and by both server paths that verify
it, so they cannot drift apart the way the *comments* about them already
had. A relay would now have to sign its own ephemeral key with the
extension's Ed25519 private key.

## Why no negotiated version

A version-gated variant avoids the break, and hands the attacker the
choice: a relay that can rewrite frames can rewrite the field
advertising v3 support, and both ends would then agree on the weaker
payload. Closing that needs the negotiation itself signed, which is the
same wire change with more moving parts. The 0.4.0 precedent applies —
hard break, all packages together, old version refused at the hello.

## One test changed sides

`KNOWN RESIDUAL: a relay can still swap the ephemeral key and share the
session` was added in #213 with a comment saying it should start failing
the day the signature covered the ephemeral key. It did, on the first
run. It now performs the same substitution and asserts the refusal,
under a name that says so — the residual's own tripwire, doing its job.

## Testing

1233 tests, three consecutive clean runs, `tsc -b` across all six
workspaces, `npm run build` clean. The 128 tests that initially failed
were fixtures hand-building v2 hellos and signatures; each now goes
through `readySignaturePayload`, and `validate.test.ts` asserts v2 is
refused rather than downgraded.

After merge: the advisory can be published, and mcp-host's gate 1
(chrischall/mcp-host#162) is satisfiable — its whole premise is a relay
that *cannot* read the traffic, which until now was narrowed rather than
true.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
chrischall added a commit that referenced this pull request Aug 6, 2026
🤖 I have created a release *beep* *boop*
---


##
[2.0.0](v1.11.0...v2.0.0)
(2026-08-06)


### ⚠ BREAKING CHANGES

* **protocol:** bind the ephemeral key into the ready signature
([#222](#222))

### Features

* **protocol:** add write_cookies, the one verb that can repair a
rotated session
([#211](#211))
([b2557c2](b2557c2))
* **protocol:** bind the ephemeral key into the ready signature
([#222](#222))
([c13aeed](c13aeed))
* **server:** let a request name the tab that relays it
([#207](#207))
([c5d3f4d](c5d3f4d))
* **server:** pin the extension's identity, and verify it on the peer
path ([#213](#213))
([0eeced7](0eeced7))


### Bug Fixes

* **cli:** let a real filesystem error be itself, not "no extension pin"
([#221](#221))
([c87a864](c87a864)),
closes [#220](#220)
* **cli:** validate --via-tab before connecting, like the request URL
([#210](#210))
([959fcc5](959fcc5))
* **extension:** reattach the write_cookies doc block, and name the
writable cookies as writable
([#215](#215))
([2730c4a](2730c4a))
* **extension:** use the guarded caps local for the cookie heading
([#217](#217))
([f95c832](f95c832))
* **server:** release only our own extension claim, and stop guessing
scoped names
([#219](#219))
([3d90a64](3d90a64)),
closes [#218](#218)
* **server:** type no-tab rejections so they stop reading as version
mismatches ([#205](#205))
([dc30bd9](dc30bd9))


### Refactor

* **server:** drop the concatBytes imports the signature change orphaned
([#224](#224))
([4985ba7](4985ba7)),
closes [#223](#223)

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-review Trigger Claude + Copilot review on this PR ready-to-merge Owner has reviewed; arm auto-merge to land when CI is green security Security-related changes

Projects

None yet

1 participant