E2E remote control, stages 4–5: Noise pairing/connection cutover, delivery-keyed push, Host bounds - #517
Conversation
The protocol both ends of the end-to-end ceremonies speak, with nothing consuming it yet on this commit: - **ACL v2.** `HostAclRecord` replaces `devicePublicKey` with `clientStaticPublicKey` (raw X25519) and gains `deliveryId`, both checked for exact length on read — which is the whole of the Host-ACL version, since a record written before the cutover fails it and is dropped. `HostAcl` keys on (passkey credential, Client static); the miss is `client-not-paired`. - **The invitation grammar** — `PairingInvitation`, the 146-character positional fragment, its formatter, and one parser boundary that answers the complete invitation or `null`. - **The ceremony messages** — `PresenceProofV1` and the one `verifyPresenceProof` both ceremonies run, `PairingRequestV1` / `PairingOutcomeV1`, `ConnectionRequestV1` / `ConnectionOutcomeV1`, and `samplePairingCode`. - **Wire.** Push re-keyed on `deliveryId`, the reauth binding variants, `/api/setup/retire`, and the deletion of `mintId` and the `setup-token-redeemed` frame. `PairingCeremony` and `authorizeConnection` are deleted; the legacy types and guards the Server and Pocket still import stay until stage 4c. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`RemoteHost` now speaks exactly two frames — the `e2e` envelope and
`client-gone` — and every legacy frame is ignored:
- **Invitations are Host memory.** `mintInvitation` generates a one-use X25519
responder keypair that never leaves the process; `setupQr` composes the
`#pair?` URL around it, and the panel flips on the Host's own `invitation`
event rather than on a Server redemption.
- **Pairing.** IK against the invitation key, then a `PairingRequestV1` whose
presence proof is verified against a binding built from the Host's own
`hostId` and handshake hash. The service mirrors `{ clientId, pairingId,
label, requestedAt }` and never the expected code; the webview echoes typed
digits, the Host compares them constant-time exactly once, and every terminal
outcome consumes the invitation.
- **Connection.** IK against the Host static, message 2 carrying a fresh
challenge, then proof ∧ the one-record ACL conjunction. Every ACL miss answers
`pairing-required`; protocol-v1 rides the same session.
- **Enrollment gate.** A Host without a usable Noise static does not enroll and
does not start: absent is backfilled and persisted at start, and halves that
do not correspond keep it down rather than presenting a changed identity.
- Deletes `adopt`, the webview `localStorage` Host, and `ACL_KEY_PREFIX`.
`lib/src/remote/test-e2e-client.ts` is the shared Client driver both Host suites
run against, so no ceremony step is stubbed in either.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…stage 4a) - **`/api/reauth/*` take the binding.** `begin` requires a kind-tagged `PresenceBinding` naming a registered credential, mints a single-use 2-minute nonce, and answers `presenceChallenge(binding, nonce)`; `finish` consumes it, verifies against the stored key for exactly that credential, and extends nothing. The bodyless legacy arm stays marked `STAGE-4 TRANSITIONAL`. - **`POST /api/setup/retire`** spends a live token without registering, so a photographed code cannot register a passkey after the phone moves on. - **Push is keyed on `deliveryId`.** Possession is the proof: no challenge, no signature, and the Server never lists an id to a session. Adds the possession-only query and the always-204 delete; drops `/api/push/challenge` and `GET /api/push/subscriptions`. Rows from before the cutover are dropped on read with one warning naming the file. - `mintId` and `RelayHub.notifyHost` are gone with the `setup-token-redeemed` frame. The harness now mirrors the real Host: `FakeHost` runs the same invitations, presence verification, and ACL conjunction, and `FakeClient` drives both ceremonies through the real relay with proofs obtained from the real reauth routes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`remote-security-model.md` is rewritten around what now ships: one end-to-end channel per ceremony, presence proofs, the invitation-based pairing with its two-digit confirmation, the connection conjunction, the Host bounds stage 4 carries, and Host identity. The Device Keys, Pairing Ceremony, and Connection Establishment sections described code that no longer exists and are gone, along with the end-to-end-confidentiality non-goal, the setup proof, the fingerprint compare, and the WebAuthn PRF item. A new `remote-security-model.rationale.md` holds the evidence the spec no longer carries inline. `server.md` promotes the QR grammar and its parser, the reauth and retire routes, the delivery-keyed push surface, the new relay diagrams, and the Host side; what is left under the fold is the 4c deletions and stage 6's sealed push. `SECURITY.md`'s `FAIL IF` lines are rewritten in the same commit rather than deferred: none may name `authorizeConnection`, `PairingCeremony.approve`, `adopt`, or the setup nonce, and the Server-compromise bullet no longer claims read and write on an established session — it cannot read one at all. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Quality-only pass over the stage-4a range; no wire bytes, QR grammar, presence encoding, ACL conjunction, one-attempt rule, or bound changes. Reuse: `isExactBase64Url` and `randomBase64Url` land in `server-lib-common` and replace four hand-written copies of each; the QR fragment is now composed through `pairingInvitationFields`, so the emitter and the prologue cannot disagree about order; the two denial-code lists are the source of their own union types. Simplification: `RemoteHost` grows `#sendE2e`, `#evictOldestIfFull`, and `#aclMiss`, collapsing four frame literals, two identical eviction scans, and a four-level nested ternary (with an unreachable log arm); `#disposeClient` now composes the three teardown paths instead of re-implementing them; `#promoteConnection`'s send closure retains only the established id and session; `flushUntil` is written over `pollFor`. Dead code from the legacy Host paths: `verifyPushSubscribeSignature` (its only caller went with the challenge/signature subscribe gate), `MAX_PENDING_TICKETS` (its ceremony is gone), and `boundedPairingAccount` (the modal no longer renders an account), plus a dangling `sameRequest` citation and the stale "nothing calls this yet" on `presence.ts`. Comment proportionality: the rewritten specs are normative, so the code stops restating them — eight blocks in `remote-host.ts`, `verifyPresenceProof`'s and `isHostAclRecord`'s doc, and two of the three copies of the delivery-id rule. Two `Reserved:` pointers that stage 4a promoted are rewritten from "will" to "is", and `server.md`'s parser paragraph keeps only the cross-boundary rules. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
**A dropped invitation is not a scan.** Losing the relay socket retired every held invitation as `consumed`, which the QR panel renders as "Scanned. Finish on the phone" — so a wifi roam sent the user to a phone that never asked. The Host now reports `dropped` for a code it discarded un-scanned (socket loss, or eviction at the mint cap) and `consumed` only where a phone really completed message 1, and the panel offers a new code instead. **A rejected connection `init` no longer allocates a challenge.** It was issued before the responder ran, so a frame that never authenticated left an entry nothing but its 2-minute TTL reclaims. Issued after message 1 decrypts, on the Host and in the harness that mirrors it. **Enrollment mints the Noise static before the exchange.** The mint became fatal in this stage but still ran after the POST, so a runtime without X25519 orphaned a `hosts.json` row and spent the installer's one-time token on every attempt, with no way back. **Pairing refuses when this Host has no static to present** rather than writing a record and handing the Client `hostStaticPublicKey: ''` — an authorized Client whose next connection could never complete IK. **Every delivery-id route bounds its ids** the way subscribe already did: `query`, `send`, and the `DELETE` path parameter. The delete stays 204 for anything, so it is still no oracle. Also states the single-account assumption `removeDelivery` rests on. Plus: `client-gone`'s clientId is bounded as its comment claimed; a promotion with no session factory clears the disposed handler and prunes the entry; `SECURITY.md` keeps the pre-authorization relay clause its still-live legacy `msg` gate needs; and `PushSubscribeResponse`, `remote-security-model.md` and `server.md` say what the code does about endpoint rotation, the mint order, and the transitional `/api/reauth/*` arms. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
The documented dev loop serves Pocket on `http://localhost:3000`, so a QR minted there could not parse at all. The parser now accepts plain HTTP on `localhost`, `127.0.0.1`, and `[::1]` — exactly the set a browser already treats as a secure context, and the same exemption WebAuthn and service workers get. The origin compare is unchanged, so this widens nothing a remote code could reach. Also adds `isE2eServerToClientFrame`, the Client's mirror of the guard the Host already runs on what the relay hands it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`PocketClient` now speaks only the `e2e` envelope: Noise IK for both ceremonies, presence proofs through the real `/api/reauth/*` bindings, and protocol-v1 as application messages on the connection's own session. The device key, the setup password, `pair-status`, the `:paired:` markers, and the legacy push shim are gone. - `pair(invitation, label, onCode)` mints a per-Host static, runs IK against the invitation key, samples the two digits and shows them before the WebAuthn prompt, then verifies the outcome's passkey fields against its own and the Host static against any existing pin before writing `KnownHostV1`. A changed pin is a terminal `HostIdentityMismatchError` that keeps the old record. - `connect(hostId)` runs IK against the pin, binds its proof to the Host challenge from message 2, and on `pairing-required` tombstones the delivery id *before* the record forgets it. - Push is by capability: subscribe and query present this browser's own delivery ids, and `retirePendingDeletions` drains the tombstones. - Every denial maps to fixed copy; a timer expiring reports unavailability. - IndexedDB goes to v3, deleting the `device-key` store. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
The phone's first user-visible change. A `#pair?` the native camera opened is erased before first render and nothing is kept from it — not even a parse — so the auth screen leads with "Install or open Pocket, then scan this Host QR in Pocket" and every real pairing starts at the in-app scanner. - `ScanInvitation` reads a code as data through a lazily imported `@zxing/browser` rear-camera scan, with a paste field beside it feeding the same parser; both stop the tracks on success, cancel, error, and unmount. - The auth screen loses the setup password: a first run leads with the scanner, a browser holding a passkey leads with sign-in. - The two-digit waiting screen shows the code while the outcome is pending. - The Hosts view is the pinned records — labeled locally, online state stamped on from `GET /api/hosts`, one action each (Connect, or Pair again), plus Remove and Scan a Host QR. - `probeNoiseSupport` gates the whole app: a runtime without X25519 gets a fixed upgrade requirement and no remote operation. `PK`/`pkButton`/`ErrorRow` move to `pocket-chrome.tsx` so the scanner draws from the same vocabulary without importing the app shell. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…ge 4b) `pocket-client.test.ts` now runs the **real** `RemoteHost` and the **real** `PocketClient` through a new in-memory relay (`lib/src/remote/test-relay.ts`, which stamps `clientId` and `hostId` the way `server/src/relay.ts` does). No ceremony step is stubbed: real Noise IK, real ES256 assertions over the shared challenge builder, outcomes decrypted on the session that produced them. It covers pairing, connection, protocol-v1 inside the session, `pairing-required` with its tombstone, a mistyped code, a Host-static pin mismatch, tampering, and both deadlines. App coverage splits: `App.scan.test.tsx` drives the capability gate, the parse, setup-versus-retire, the code screen, and the connect a pairing continues into; `ScanInvitation.test.tsx` covers the reader and every way the camera stops; `pair-link.test.ts` covers the fragment being erased with nothing retained. `pocket-db.test.ts` pins the v1→v3 and v2→v3 upgrades deleting `device-key`. `FakeSocket` gains an `onSend` hook, which is the seam the relay stub bridges. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`pocket-app.md` loses the setup-password auth screen, the `pair-status` sweep, the device-fingerprint line, and the `:paired:` markers; it gains the native-camera bootstrap, the scanner and its camera-teardown rule, the after-the-parse flow with the two-digit screen, the record-driven Hosts view, the capability gate, IndexedDB v3, and the durable deletion queue — which lands here rather than in stage 6 because 4b needed it, so `pair` now also tombstones a delivery id a re-pair replaces. `remote-security-model.md`: stage 4b is done, stage 6 no longer owns the deletion queue, the runtime gate moves above the fold into Host identity, the session-is-authentication-plane-only rule moves into Presence proofs, and the first run's three authenticator prompts are stated once. What is left under Future is the pair of properties only a real iOS device can settle. `server.md`: the legacy relay frames now have no speaker at all, `#setup?` has no reader, and "Running it" walks the scan. `docs/stories/pairing.mdx` follows the phone through its new screens. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
It is the Host's own label as it arrived inside the encrypted pairing outcome, not a nickname chosen on the phone — there is no rename UI, and the point of the field is keeping the Server's copy of the name out of Pocket's rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Quality-only pass over the Pocket cutover. Reuse: `E2E_ID_BYTE_LENGTH` moves to `server-lib-common/src/remote/wire.ts` beside the `isE2eId` it already derives, so a minter and the shared guard cannot drift; the relay stub and the pocket-app suites mint routing ids through `testRoutingId()`, and `invitationUrl(origin)` lives once in `app-test-utils.ts`. Simplification: `PocketClient` grows one `#sendE2e` funnel — every Client→Host byte now leaves through it — plus `#exchangeControl` for the send-then-await both ceremonies share; `pair()` hoists its route as `connect()` already did; `#connectionUnavailable` is `#unavailable` plus a flag; the caller-less `knownHost()` is gone; the two localStorage prefix scans share `keysWithPrefix`. `App`'s `Phase` becomes a discriminated union carrying the pairing digits and the connected Host, which retires `activeHost`, `pairingCode`, the impossible `(wall, null)` guard and the duplicated placeholder; `loadPushConfig` is the one copy of the config read the effect and Retry both wanted; `PushConfigStatus` derives from `PushConfigState`. `ScanInvitation`'s three camera teardowns become one `release(controls, video)`. Efficiency: the Hosts list paints before the tombstone drain rather than behind N serial DELETEs. Altitude: the relay stub binds each Client socket on its own `init` and reports `client-gone` on a replaced registration, as `registerClient` / `unregisterClient` do; its sync claim now names where the rules are pinned. The parser's loopback set is stated as the policy list it is rather than as a re-derivation of the platform's secure-context rule, which is wider. Say it once: the run token, "a timer is unavailability", the install notices' shared line, and the `KnownHostV1` shape rationale each drop to one statement plus pointers. No wire bytes, prologues, presence rules or bounds move, and no fallback or plaintext path appears. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Nine confirmed findings, each pinned by a test proven load-bearing by
reverting the fix.
**Nothing may throw out of the two-digit screen.** `PairingCodeView` shows
two digits and a Cancel button and renders no error, but the whole span it is
up — `pair`, then the connect that follows an approved pairing — throws for a
Host-static mismatch, a dismissed authenticator prompt, a lost passkey cache,
and any connection denial that is not `pairing-required`. Every one of those
messages was invisible until the user tapped Cancel, `HostIdentityMismatchError`
above all. `onScanned` now leaves for the Hosts list before rethrowing, and
re-reads defensively so a failed re-read cannot strand them either.
**A Host static was pinned without checking its shape.** `PairingOutcomeV1`
bounds `hostStaticPublicKey` as a string and nothing else, so a Host
announcing anything that is not base64url of 32 bytes got stored — and every
later `connect` then threw building a handshake from it, long after the
screen that could have explained it was gone. `isNoisePublicKey` joins
`isNoiseStaticMaterial` in `server-lib-common/src/security/noise.ts`.
**The scanner latched forever on the first accepted code.** Everything
between acceptance and the pairing screen can fail — a refused setup token, a
sign-in that did not work — and each leaves the scanner up telling the user
to scan again, with the camera *and* the paste field inert. The latch now
opens when the handover settles, so `onScanned` is awaited.
**And it ran a camera through the whole ceremony.** `accept` stopped the
camera, then `setBusy('pair')` re-rendered `App`, re-created `onScanned`, and
restarted it behind the WebAuthn prompt — indefinitely on any failure that
kept the screen up. `onScanned` is a `useCallback` and the effect is gated on
`busy`, which also reopens the scanner for a second attempt.
**Cancelling the scanner showed an empty Hosts list.** A scan signs in but
only reads the list on a path that reaches pairing, so Cancel after a later
failure claimed nothing was paired until Refresh. It reads the list instead.
**A relay chose the sentence the user read.** An `error` frame's text is
unbounded, unshaped, and reaches the alert row through the protocol-v1
requests `#rejectAll` fails. Fixed copy now, same rule as the denial tables;
the relay's words go to the console.
**A storage failure replaced a ConnectResult with a raw error.**
`#dropAuthorization` ran outside a `try`, so an IndexedDB throw surfaced as
`QuotaExceededError` instead of *Pair again*. The tombstone-before-transition
order still holds: a failed write leaves the record paired, and the next
Connect earns the same authenticated denial and retries.
**The scanner left a camera running when the decoder threw late.** A
`startScan` that rejects *after* `getUserMedia` attached a stream hands back
no controls to stop. The rejection path releases the element's tracks.
**A ceremony's control waiter was registered after its send** — the reverse
of `#exchange` — so an answer delivered with no await in between would find
no waiter, be dropped, and hang to the deadline. Latent behind the Host's own
crypto awaits, and left untested for that reason: it is an ordering
invariant, not an observable behavior.
Also: `pocket-app.md`'s design source-of-truth now names `pocket-chrome.tsx`
for the `pkButton`/`PK` vocabulary that moved there this stage, and the
session-gate bullet names the refused setup token rather than the setup
password and device signature the phone no longer has.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
The relay routes the `e2e` envelope and nothing else. `pair`, `pair-status`, `connect`, `connect2` and `msg` — up and down — are gone, and with them the `Handshake` gate, its relayed-challenge memory, `Session.lastVerifiedPresence`, and the two `STAGE-4 TRANSITIONAL` arms of `/api/reauth/*`. No production speaker had used any of it since stage 4b. `/api/setup/begin` and `/finish` take a Host-minted setup token only: the setup password enrolls Hosts and no longer registers a passkey. `/api/host/enroll` keeps both credentials but takes no `label` — the name a machine presents is its own, told to a Client inside an encrypted outcome — and `hosts.json` rows and `GET /api/hosts` lose it with them. Enrollment now pins the `hostId` shape at base64url of 16 bytes, validated on read: `e2e` routes on it and the shared guard accepts no other length, so a hand-edited row of another shape would be a Host the relay admits and no Client can address. The `FakeHost` harness loses its legacy half and `LegacyAcl`; `server/scripts/fake-host.mjs` mints a Noise static and prints a pairing URL, so it is still a usable headless stand-in. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…ge 4c)
Deleted outright: `security/deviceKey.ts` and `security/setup-proof.ts` whole,
`SETUP_HASH_*`, `pairingFingerprint`, `PairingRequest`/`isPairingRequest`,
`PairStatusQuery`/`isPairStatusQuery`, `PAIRING_PRESENCE_WINDOW_MS`,
`PAIRING_STALE_PRESENCE_ERROR`, `PUSH_SUBSCRIBE_DOMAIN` with
`pushSubscribePayload`/`signPushSubscribe`, and `ConnectionRequest` /
`ConnectionFailure`. `PAIRING_LABEL_LIMIT` and `isSetupTokenHandle` stop being
exported; each has exactly one caller, in its own file.
The four relay unions lose their legacy arms — `ClientFrame` and `HostFrame`
are now the `e2e` envelope alone, and the other two add only `host-gone` /
`error` and `client-gone` — which is what drops the `HostAclRecord` import from
the wire contract.
`ConnectionPolicy` survives, since it is what a Host records at enrollment and
what both verifiers demand of an assertion, but `connection.ts` would have held
nothing else. It moves to `passkey.ts` as `Omit<PasskeyAssertionExpectations,
'challenge'>` — the standing half of what that module already defines — and
replaces `PresencePolicy`, its structural twin with no external caller.
`SetupCredential` becomes `{ setupToken }`, `HostEnrollRequest` loses `label`,
and `HostsResponse` rows carry `hostId` and `online` only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`performEnrollment` stops sending `label`: the Server takes none, and the name this machine presents stays beside the enrollment, told to a Client only inside an encrypted ceremony outcome. The rest is naming that outlived its subject — `SettingsDialog`'s Storybook parameters seeded push devices by `devicePublicKey`, which the view has called `deliveryId` since the cutover, and `QrCode`'s fixture URL still carried a `#setup?` hash no emitter composes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`NoiseHandshake.#step` had no in-flight guard, so a second `writeMessage` or `readMessage` entered while one was still awaiting could interleave with it. A step is a dozen awaited WebCrypto calls that mutate the symmetric state in order; two of them running together mix the same key twice or read `h` between two of its writes, producing a transcript neither peer can reproduce. The pattern-order checks could not catch it — they read `#firstMessageDone`, which a step sets at its *end*. A second entry now fails the handshake outright rather than queueing: a caller reaching here has a bug, and a handshake is cheap to redo and impossible to resynchronize. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
… 4c) `client-gone` was dispatched inline while every `e2e` step runs on the per-socket promise chain, so a teardown could land *between* an init's awaits: `#disposeClient` found no pairing entry to dispose, and the resumed handshake then reserved the invitation and allocated a client entry for a peer the relay had already forgotten — one nothing would ever remove, holding a `NoiseTransportSession` and a `MAX_PENDING_PAIRINGS` slot until the Host's own socket dropped. Both transport handlers already re-check after their awaits; the two init handlers had no equivalent. Queueing the frame is the fix, and it covers both of them at the source. Second, `mintInvitation`'s cap eviction announced `dropped` unconditionally. Eviction takes the oldest by insertion whatever it is doing, so a code a phone had already scanned was reported as one nobody asked for — the inverse of the state table, which `#dropTransientState` encodes correctly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`PushSubscriptionStore.upsert` read the address it was replacing from the row for this exact `(hostId, deliveryId)`, so a subscribe with no row of its own pruned nothing — leaving a sibling Host's row on an address the browser had already moved off, which the possession query then reported as registered. The replaced addresses now come from every row carrying this `deliveryId`, whichever Host holds it: a delivery id names one Client's pairing and so speaks for one worker scope. The rows dropped are still matched on the endpoint, which is what reaches siblings whose delivery ids the request never names. What it still cannot do is documented rather than papered over: a brand-new delivery id after a re-pair has no row to read a previous address from, and closing that would need cross-Host device identity the Server deliberately does not hold. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`server.md`: the Relay's `STAGE-4 TRANSITIONAL` paragraph is replaced by the rule it was deferring — the envelope is the whole client surface and the relay holds no policy — and the `hostId` shape it promised moves above the fold into State files, beside the `hosts.json` row that no longer carries a label. The setup routes' rows, Guardrails, the opening loop, Configuration, Testing, and "Running it" all say what registering a passkey now requires: a code an enrolled Host displayed, and nothing else. State files also states the push-upsert rule and, plainly, the one case it cannot cover. `remote-security-model.md`: the Presence-proofs transitional sentence is gone, stage 4 is landed whole, and its (c) list is deleted — stages 5–7 remain. `SECURITY.md`: the setup password now buys `/api/host/enroll` and reaches the account only through a Host it enrolled, which is what the setup-password bullet and the accepted-risk section now say; the relay `FAIL IF` names the shipped rule instead of a legacy clause. `SELF_HOST.md` checkpoint 4 is reordered — the Host must be enrolled before the phone has anything to scan — and `pairing.mdx` § 9 drops the legacy-path item. Budgets: `server.md` and `remote-security-model.md` lowered where they shrank, `SELF_HOST.md` raised by the sentences the new ordering costs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`#dropTransientState` is not a frame: `stop()` and the socket's own `close` run it synchronously, so the chain cannot order it against a ceremony step. A pairing or connection `init` whose handshake was still awaiting WebCrypto when one landed went on to reserve an invitation the teardown had just retired — announcing `reserved` for an id the panel was told was `dropped` — and to allocate a client entry, holding a `NoiseTransportSession` and a `MAX_PENDING_PAIRINGS` slot that nothing would ever clear. After `stop()` there is no later close to clean it up. Reachable in normal use: a server restart or a network blip mid-scan, or toggling remote control off while a phone is scanning. Teardown stays synchronous — the service clears its mirrored pairing queue the instant `stop()` returns, and deferring it would write to that queue afterwards, possibly past a replacement Host. Instead it bumps an `#epoch` that `#enqueue` stamps each step with. The capture is at *enqueue* time, not at step start: a step is queued on the microtask queue, so a `stop()` on the very next line tears down before it has begun. A step already stale never runs; one that goes stale mid-flight is handed its epoch and refuses to mutate afterwards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Leftovers of the deletion, and the prose that outgrew it. Dead after the legacy paths went: `pickCredential`'s `tokenError` (one caller, one value), `#resolveHost`'s missing-`hostId` arm and `parseFrame`'s matching annotation (only `isE2eClientFrame` reaches there, and it proves the shape), `enrollHost`'s options bag and the four `label` arguments still threaded into it, and `reauth.test.mjs`'s assertion on a `Session` field that no longer exists. Said once instead of twice: `setupRetire` re-ran `readSetupGated`'s four steps inline, which only stopped being possible while that helper still took a password; the eviction-vs-teardown state derivation lived at both `#retireInvitation` callers, so it moves into `#retireInvitation` as the default when no cause is named; and the three `e2e` envelope builders this stage grew across two relay suites move to `harness/e2e.mjs`, which already owns the envelope's other facts. `reauthFinish` checked the nonce's shape *after* consuming it, purely to re-narrow; checking first keeps "consumed FIRST" true for every value that could be a nonce. Comments on the `hostId` pin and on `upsert` restated their own spec paragraphs at length; they keep the rule and hand the argument back to `server.md`, and `SELF_HOST.md` does the same with `pocket-app.md`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
**The Host did not pin its own `hostId`.** The Server now mints and reads it as `isE2eId`, but `isEnrollment` still took any string — and that value is the routing id of every `e2e` envelope and the second field of every QR fragment, which the parser accepts at exactly one width. A Server answering another shape (or a hand-edited store) left this Host minting codes no phone could parse, with nothing anywhere to explain it. `missingEnrollmentFields` mirrors the guard's shape check too, so the rejection cannot name nothing. **The harness stopped mirroring the Host.** `RemoteHost` now reports an evicted invitation by what it was doing — `consumed` if a phone had scanned it, `dropped` if nobody had — while `FakeHost` still said `consumed` for both. The harness exists to fail where a real Host would, and this is also the event `scripts/fake-host.mjs` re-mints on, so the drift would have re-offered a code mid-ceremony. It now derives the same way. Pinning tests for two rules the relay already followed and nothing asserted: a `transport` from a Client with no binding, or naming a Host it has bound away from, reaches no Host and is dropped rather than answered; and the eviction distinction above. Both fail against the code without the fix. The rest is text that outlived what it described: three installers and the server's own startup error still told the operator the setup password creates a passkey, which `/api/setup/*` stopped accepting in this stage; and `server.md`'s Relay section still had an `established` flag, "legacy frames", and sessions where the relay now keeps only a binding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`mintInvitation` is the panel's action, not the relay's: it runs off the frame chain and reaps and evicts synchronously. A suspended `#onPairingInit` holds the `HeldInvitation` it looked up before its dozen awaited WebCrypto calls, and the `#epoch` guard added with the teardown fix covers only teardown — so a mint landing mid-handshake could retire that entry underneath it. The handler then wrote `reserved` onto a detached object, announcing a state change for a code the panel had already been told was gone, and allocated a pairing naming an invitation no later dispose could retire. Re-checking that the map still holds this exact entry covers both causes at once, next to the epoch check it joins. Found by the stage-4c review. The other in-scope finding — an enrollment rejection that could name no field at all — went in with the previous commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`client-gone during a handshake disposes what the handshake then creates` failed in roughly a third of full-suite runs, before this review and after it. Not a race in the Host: `settle()` gave one timer's worth of event-loop turns, and a ceremony step is a dozen awaited WebCrypto calls resolving off the threadpool, so under the suite's parallel load the chain had not drained when the assertions ran. `settleUntil`'s own docstring says a fixed settle cannot cover this; the tests added alongside it called bare `settle()` anyway. `settle()` now spends several turns rather than one, which is what the tests asserting *absence* need — a step that had not resumed yet made those pass vacuously, which is the worse half of the bug. The two that assert a positive outcome name it through `settleUntil` instead of counting turns. Four consecutive full-suite runs clean, against three failures in the seven before it, and no measurable change in wall time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`StreamReassembler` queued one array entry per stream body. The byte cap bounds what a peer can declare, but not how many entries carry it: one 1 MiB message delivered as single-byte bodies grew the array to ~234 MB of live `Uint8Array` headers. Bodies now compact into one geometrically-grown buffer whose live window slides forward, so memory is proportional to the bytes actually queued (1.11 MB backing store for a 1 MiB incomplete message, 41 ms to feed it a byte at a time) and the work stays linear. Pinned by a memory bound, a drain-releases-the-buffer case, and a seeded 400-pattern fuzz over random message sizes and split points. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`MAX_ESTABLISHED_E2E_SESSIONS`, `E2E_KEEPALIVE_INTERVAL_MS`, `ESTABLISHED_E2E_IDLE_TIMEOUT_MS`, and the crypto token bucket's burst and refill, in one shared module: the Host reaps on the idle timeout and the Client keepalives against it, so two copies would be two opinions about when a live session looks dead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Three Host-enforced bounds, all independent of the relay: - `MAX_ESTABLISHED_E2E_SESSIONS = 16`, checked at promotion and only there — after the presence proof and the ACL conjunction have both succeeded, so nothing unauthenticated can decide who gets in. A Client static that already holds a session replaces its own atomically, whatever relay-chosen `clientId` it arrived under; any other identity at the cap gets the fixed-size `host-busy` and evicts nobody. - A Host-global crypto token bucket on accepted `init` frames: an eight-operation burst decaying to one per second, on the injected clock. A refused frame performs no WebCrypto, allocates nothing, and is dropped — an accepted-but-failing pairing `init` costs 1 deriveBits and 3 digest, a refused one costs zero. - One reaper over absolute timestamps for invitations, pending pairings, pending connections, and established sessions, replacing the lazy per-frame invitation sweep. It runs on every init, every local decision, every relay lifecycle event, and a next-expiry timer that is re-armed on change and cleared on `stop()`. `lastClientActivityAt` is refreshed only by a successfully decrypted Client->Host transport message; an expiry answers only where a transport cipher exists and someone is still owed one. Instrumented by `remote-host-bounds.test.ts`: a counting wrapper over `globalThis.crypto.subtle` plus a spy on `NoiseTransportSession.receive`, an injected clock and timer, and the private-map sizes read through the existing counters. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`PocketClient` sends one fixed-size keepalive every `E2E_KEEPALIVE_INTERVAL_MS` on an established session, and only while `document.visibilityState === 'visible'`: a backgrounded tab has its timers throttled or suspended, so it must not promise a liveness it cannot keep. Returning to the foreground sends one immediately and resumes the interval; the session ending stops both. The timer and the visibility state are injected, so the tests fire an interval by hand rather than waiting thirty seconds, and `host-gone` now runs the same `#disposeCeremony` every other teardown does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
A relay that records, drops, reorders, duplicates, modifies, and invents frames between the real fake Client and fake Host. It wraps the shipped `RelayHub` rather than reimplementing it, so what it routes is the routing under test, and both harness halves now accept an injected socket so the relay can sit between them in memory (HTTP stays a real server: the presence proofs go through the real `/api/reauth/*`). What it proves: no pairing decision, Host label, delivery id, static, handshake hash, remote-api message, or terminal byte crosses it; a forged or corrupted outcome is not believed and pins nothing; every tampered, duplicated, or reordered frame poisons the receiving session and buys nothing else; dropping everything is denial of service and not authorization. Its last case removes the relay's own `ct`/`id`/shape guards entirely and runs the flood cases through the guard-less pipe: every one is refused by the Host's own `isE2eServerToHostFrame`, no entry is allocated under a relay-chosen key, no scanner is spent, and the honest ceremony still completes on the same wire. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`remote-security-model.md` -> Host bounds gains the session cap, the crypto token bucket, the one reaper, and the idle deadline's refresh rule, with `Source of truth` naming the two suites that pin them; stage 5 leaves the `## Future` ledger. `pocket-app.md` states what keepalives cost a backgrounded phone — a new handshake and one WebAuthn prompt. `server.md` records the malicious-relay harness under Testing, drops the promoted item from its own ledger, and corrects the reassembler's framing note. `SECURITY.md` -> Remote Control gains a `FAIL IF` for the Host-enforced bounds naming the constants and the tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
None of these are stage-5 work; they are the small, clearly-correct half of the thirteen findings the stage-4c review left behind. Each lands with a pinning test. - A `setupFinish` whose answer never arrived left a browser that reads as returning while holding a credential the account never got: every later scan signed in, failed, and clearing site data was the only way out. A Server *refusal* of the sign-in now outranks this browser's own record of prior use and falls through to registration; a dismissed prompt or a dead radio still propagates and spends nothing. - An `expired` invitation rendered as "Scanned. Finish on the phone", sending the user to a phone that never asked — the exact confusion the `dropped` branch exists to prevent. It has its own copy now, and the state-to-phase map is exhaustive over `InvitationState`, so a state added to the Host cannot fall through to "Scanned" again. - `POST /api/setup/finish` stored `credentialId` unchecked while type-checking `label` on the next line. It is handed back to every later `setup/begin` and base64url-decoded by the Client, so one malformed id wedged passkey registration for the account. - The Host's own label went on the wire unbounded, while the Client's outcome guards refuse any field over `CEREMONY_FIELD_LIMIT` — a machine name past that paired on the laptop and was discarded by the phone. - `#sendApp` disposed the session on any throw, including the over-size refusal the transport raises *before* encrypting anything — turning a caller's size error into a re-handshake, and doing it re-entrantly from inside the receive loop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`isEnrollment` now checks the *shape* of `hostId`, not just its type: it is the routing id of every `e2e` envelope, so it must be `isE2eId` — base64url of 16 bytes. The glue tests still stored `hostId: 'host-1'`, so the service read every one of those enrollments as malformed and never started a Host, timing out five tests. The ACL fixtures are untouched: `isHostAclRecord` type-checks `hostId` and length-checks only the two E2E fields. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Quality only: no bound's value or semantics moved, no wire change, the reaper is still one mechanism, and the flood cases still prove zero crypto. - One deadline enumeration. `#reap` and the arming pass both walked the invitation map and both client maps, and spelled the idle deadline's arithmetic twice; `#deadlines()` is now the one place a fifth deadline would be added. The reaper runs on `init` frames, which is what the spec and `SECURITY.md` already said it did — an established transport frame was paying two O(sessions) walks on the terminal path — and re-arms only when the soonest instant moves *earlier*, which is what its own comment already promised. - One walk answers both questions a promotion asks, and the reconnect backoff goes through the same injected timer as the reaper, so "`stop()` leaves nothing armed" is a thing the bounds test can see. - One shared `RemoteTimer` + `hostTimer` in `remote/ws.ts`, replacing the identical `setTimeout` wrapper and four spellings of its type. Pocket's visibility handler decides "is a timer armed?" in one place, and reads `globalThis.document` instead of a hand-rolled structural type. - The E2E fixture, `establish`, `watch`, and `flip` move into `server/test/harness/e2e.mjs`; the malicious-relay suite was a 60-line fork of `e2e-relay.test.mjs`'s copy. Its drop case now lowers the harness's frame deadline — that one case was 2.0s of a 2.6s suite, which is now 0.35s. - `remote-host-bounds.test.ts` reuses `pairThroughSocket` and its own `makeHost`/`establish` instead of re-inlining them three times, and the case that asserts *zero* crypto no longer claims to cost a decrypt. - Comment proportionality: the promoted bounds table owns the session cap, the bucket, and the idle rule; the four restatements in `e2e-bounds.ts`, `remote-host.ts`, and the tests are one-liners plus pointers. Three rationale clauses move to `remote-security-model.rationale.md`, the reaper's outcome mapping becomes a table, and both budgets are retuned. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Six confirmed findings, each with a test that fails without its fix. - **A reaped session left Pocket holding a corpse.** The Host disposes an idle session silently, and the Client's relay socket is to the *Server*, so nothing closed and no `host-gone` arrived: a phone backgrounded past the idle timeout came back to a wall whose every `request()` hung forever with no error and no way out but a reload. `PocketClient` now runs the Host's own deadline against its own last send — the message the Host's deadline is measured from — before a keepalive and before every request, and reports host loss when it passes. This is what `pocket-app.md` already promised. - **`ServerRefusalError` is every non-ok answer**, so the stage-4c registration fallthrough also fired on a signin challenge that expired at the Face ID prompt, a rejected assertion, and a restarting server's 502 — each of which spent the single-use setup token and minted a redundant second passkey. The error carries its status now, and only the 404 that proves the Server holds nothing falls through. - **`#sendApp`'s poisoned branch disposes re-entrantly**, and the receive loop went on handing the rest of the receipt to an api it had just disposed. It stops at the dispose. - **`StreamReassembler` is exported on its own**, so it cannot lean on the session's poison: a caller pushing after a terminal error grew past the bound and eventually threw a bare `RangeError`. The first failure latches and releases the buffer. - **`#disposeConnection` consumes the challenge it drops**, so every path that abandons a pending connection reclaims it, rather than leaving the issuer's own lazy sweep as a second reclaim policy beside the reaper. - **Two invariants were stated wrong.** An entry holds one established session, so a promotion under a relay-reused `clientId` replaces it whatever identity it belonged to — the cap displaces nobody, which is the rule that actually matters; and `malicious-relay.test.mjs` drives the fake Host, so it shows the wire refusals surviving a guard-less relay rather than auditing the Host bounds. Also pinned, without code changes: the bucket's burst after a long idle and across a clock rewind, a deadline falling exactly on `now`, one clock jump reaping every deadline kind, the inputs that must *not* refresh the idle deadline, a keepalive whose socket throws, and alternating body sizes against the reassembler's bound. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Deploying mouseterm with
|
| Latest commit: |
495243e
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://ff787c96.mouseterm.pages.dev |
| Branch Preview URL: | https://e2e-client-host-2.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
Standalone Smoketest is red on this head, and it is this diff: the new 'jwk' overload in SubtleCryptoLike makes the globalThis cast in getWebCrypto uncheckable once DOM types are in the program. standalone/tsconfig.json is the only project that compiles server-lib-common's source with "lib": ["ES2020", "DOM", "DOM.Iterable"] (via its paths mapping to ../server-lib-common/src/index.ts), which is why root pnpm test and pnpm build are green and this job is not. Inline suggestion below; { readonly x?: string } is the field deriveNoiseStaticPublicKey actually reads, and JsonWebKey declares x?: string, so the cast becomes comparable again. A named JsonWebKeyLike interface beside CryptoKeyLike would read better if you expect more JWK fields later — either is fine, but a bare Record<string, unknown> cannot work here.
Everything else I traced held up. Worth recording what I checked, since a diff this size gets reviewed once: the pairing/connection state machine (epoch guard across teardown, one-use invitations, the single confirm attempt spent before the compare, #disposePairing always retiring consumed), the StreamReassembler growth bound (needed provably stays under MAX_BUFFER_CAPACITY, so the Math.min clamp never truncates), the reaper's one-enumeration deadline sweep and its re-arm rule, the eviction loops against their caps, the four-field ACL conjunction at connect, and the push store's deliveryId rekey including the endpoint-rotation prune. The keepalive/idle-reap pair is also sound in the direction that matters: the Client's lastSentAt is always at or before the Host's lastClientActivityAt, so #reapedByHost can only fire early, never late.
One minor thing, and one note
RemotePairingModal gives the code input autoFocus while also passing initialFocusRef={denyButtonRef}. useModalFocusTrap focuses in a useEffect, which runs after the browser applies autoFocus, so Cancel wins and the autoFocus never takes effect. Focusing Cancel looks like the intent here (same pattern as KillConfirm), so the attribute is the part to drop — suggestion inline.
Not a finding, just noting I read it deliberately: isStoredPushSubscription checks typeof s.deliveryId === 'string' where every route uses isDeliveryId (exact base64url length). Harmless — a wrong-length id on disk matches no query and no send target — so I'd leave it unless you want the read boundary to state the same shape the routes do.
Happy to push the webcrypto.ts fix if you'd rather not round-trip it.
The `Record<string, unknown>` return on the new `'jwk'` overload made `SubtleCryptoLike` incomparable to the DOM's `SubtleCrypto` — `JsonWebKey` has no index signature — so the `globalThis` cast in `getWebCrypto` failed TS2352 in every project that compiles this package's source with DOM libs. `standalone/tsconfig.json` is the only such project, which is why root `pnpm test` and `pnpm build` were green while Standalone Smoketest was red. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`ModalFrame` passes `initialFocusRef={denyButtonRef}` to
`useModalFocusTrap`, which focuses in a `useEffect` — after the browser has
applied `autoFocus` — so Cancel always won and the attribute never took
effect. Cancel-focused is the intent (same as `KillConfirm`), so the
attribute is the half to remove.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
|
On the |
main's Noise rewrite (#515, #517, #518, #522, #523, #524) already landed this PR's change: `test-remote-host-link.ts` now imports `DEFAULT_PAIRING_TTL_MS` from `server-lib-common` and builds the fixture's `expiresAt` from it, exactly as this branch intended. The surrounding QR grammar it was written against is gone — the fixture composes a positional `#pair?` invitation via `formatPairingInvitationUrl`, and `SetupTokenResponse` no longer carries `mintId`. Both conflict hunks resolved wholly on main's side, so the branch is now an exact no-op against main: nothing pre-Noise is reintroduced, and the intent this PR chased is already enforced by main's import. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Second of three stacked PRs for the e2e-client-host scope (
docs/specs/remote-security-model.md). Stacked on #515 — review the diff againste2e-client-host, notmain. This PR is the atomic cutover: after it, the Host, the Server relay, and Pocket speak only the Noisee2eprotocol. Existing Hosts must re-enroll and every phone must pair again from a fresh Host QR (pre-launch; no migration reader, no fallback).What lands
clientStaticPublicKey+deliveryId; legacy records drop on read). The positional QR grammar and oneparsePairingInvitationUrlboundary.PresenceProofV1verified by one shared verifier on the Host against a handshake-hash-bound reauth binding (/api/reauth/begintakes a requiredPresenceBinding;finishextends nothing). The Host runs both ceremonies itself: invitations with one-use responder keys, pairing IK → proof → reverse two-digit confirmation (constant-time, one attempt, the code never crosses to the webview) → one ACL record; connection IK → challenge → proof ∧ four-field conjunction → promotion with protocol-v1 inside. Mint-on-start backfill and a halves-correspond gate for the Host static.POST /api/setup/retire; push re-keyed on possession-onlydeliveryIds (query, idempotent delete, endpoint-rotation pruning).adopt, setup proofs, fingerprints, andsetup-token-redeemeddeleted.PocketClientrewritten on thee2eenvelope; native-camera bootstrap (fragment erased, nothing retained, token unspent); in-app scanner (@zxing/browser, lazy chunk) plus paste, both through the shared parser (loopback HTTP exempted for the dev loop); scan-first auth screen; two-digit waiting screen; record-driven Hosts view;probeNoiseSupportgate; IndexedDB v3 drops the device key; durable delivery-id tombstones with retry. Tests drive the realRemoteHostagainst the realPocketClientthrough an in-memory relay stub with no ceremony step stubbed.handshake.ts, all nine legacy relay arms, the transitional reauth arms, the setup-password arm of/api/setup/*(the password now only enrolls Hosts), Host labels on the Server; shareddeviceKey.ts/setup-proof.tsand thirteen exports;hostIdshape pinned. Plus carried fixes: Noise handshake re-entrancy guard,client-goneand teardown serialized against in-flight handshakes (epoch guard), invitation eviction state, push-row pruning.MAX_ESTABLISHED_E2E_SESSIONS = 16with same-static replacement andhost-busy; a Host-global crypto token bucket (8 burst, 1/s); Pocket keepalives every 30 s while visible and a 120 s idle reaper refreshed only by decrypted Client→Host traffic; one next-expiry reaper over every pending and established entry;StreamReassemblercompaction bound; instrumented flood tests proving rejected frames perform zero crypto and allocate nothing; a malicious-relay harness whose guard-less variant shows the Host bounds itself.Each sub-stage had a
/simplifypass and a high-effort/code-reviewpass committed on top; specs,SECURITY.md,SELF_HOST.md, anddocs/stories/pairing.mdxwere rewritten as each claim changed.Verification
Root
pnpm testandpnpm buildpass on the head commit (lib 2005, server-lib-common 223, server 221, vscode-ext 113, standalone 62, website 65, all six lints). Pocket builds with the zxing decoder in its own chunk.Open items (device-only)
Two facts cannot be verified in Node or jsdom and need a real iPhone before this ships: an X25519
CryptoKeysurvives IndexedDB structured clone on iOS Safari, andgetUserMediaworks inside a Home Screen web app (paste remains as the fallback). Remaining carried findings from the 4c review that were judged product calls rather than bugs are listed in the stage-5 commit message.Next
PR 3 (stacked) lands sealed push (
dormouse/push/v1), the built classic service worker, and the documentation/lint enforcement.🤖 Generated with Claude Code
https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj