E2E remote control, stages 1–3: Noise suite, identities, relay harness (no user-facing change) - #515
Conversation
Dissolve the mandatory end-to-end Client–Host migration plan into the specs that own each part of it: the trust model, suite, presence proofs, pairing, connection, Host bounds, and push sealing under remote-security-model.md `## Future` as **Scope: e2e-client-host**; the QR grammar, relay envelope, routes, and state files under server.md; the phone flows and worker build under pocket-app.md; one pointer each in remote-api.md and alert.md. Design changes relative to the earlier draft, all recorded in the scope: pairing verifies presence exactly like connection (a handshake-hash-bound proof after Split, replacing the 30-second window); delivery IDs are possession-only capabilities the Server never lists; push is re-keyed in the same stage that removes the device key so no stage ships a broken push path; the `setup-token-redeemed` frame gives way to a Host-owned invitation state; paste joins the scanner; the app-session key, the one-minute resume, and the versioned state-file refusals are dropped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Stage 1 of the e2e-client-host scope: the one Noise suite the end-to-end protocol will run on, with nothing wired to it yet. `server-lib-common/src/security/noise.ts` implements Noise revision 34 for IK only — CipherState, SymmetricState, and the two-message handshake — with no pattern registry, cipher negotiation, or protocol-name override. X25519, SHA-256, and HMAC are WebCrypto, so a long-term private key can stay a nonextractable CryptoKey; HKDF is Noise's own HMAC construction, not WebCrypto's. ChaCha20-Poly1305 comes from @noble/ciphers pinned to exactly 2.4.0, and the module header records the pin, the Cure53 audit of 1.0.0 (Sep 2024), and what changed in the chacha path since. Conformance is the Cacophony vector for this suite, vendored with its Unlicense attribution and checked byte for byte through both handshake messages, every transport message in both directions, and the handshake hash, alongside RFC 7748 X25519 and RFC 8439 AEAD. Ephemeral-key injection is the only test hook. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Quality-only pass over stage 1; no wire bytes change (the Cacophony vector tests still pass byte for byte). - Each handshake message's framing size gets one encoding (`MESSAGE_1_OVERHEAD` / `MESSAGE_2_OVERHEAD`) and one guard (`requireMessageLength`) that owns both ends of the contract. The write cap lived in the leaves while the read cap lived in the `readMessage` wrapper and the read minimum back in the leaves, spelling the same number four different ways. - `NoiseHandshake`'s constructor is private with a validating static `start`, so the module-private `SymmetricState` no longer leaks its whole shape into the published `.d.ts`, and every key is length-checked in one place. - Collapse `#injectedEphemeral`/`#ephemeral` into one field, drop the dead `#messageIndex = 2` stores for a boolean, and derive `Split`'s direction from `#role` instead of two magic booleans. - Reuse `constantTimeEqual` for the all-zero DH check, and import the HMAC key once per HKDF instead of three times. - Drop the unused `KeyDataLike` widening added alongside `deriveBits`: every `importKey` caller passes a `Uint8Array`. - Say it once: cut header, docstring, and test comments that restated `docs/specs/remote-security-model.md` -> Noise suite or the code below them. The `@noble/ciphers` pin and audit-delta note stay verbatim; the spec requires them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Five confirmed findings, each pinned by a test that fails without its fix. - **Buffer aliasing.** Node aliases `Buffer.prototype.slice` to `subarray`, so `message.slice(0, 32)` returned a live view for the `Buffer` a Node host gets from a WebSocket frame. The responder's `re` could change under the `ee` DH that message 2 still owed if the caller reused its read buffer; the initiator's `rs` had the same defeat. One `copyKey` helper now copies unconditionally, and the `remoteStaticPublicKey` getter hands back a copy rather than the array the `se` DH still depends on. - **Transport messages were uncapped.** Noise's 65,535-byte cap covers every message, but only the handshake enforced it, so an over-length frame was silent on the sending side and failed only at the conformant peer. `encryptWithAd`/`decryptWithAd` now enforce it, tag counted. - **`generateNoiseKeyPair` could reject with a `DOMException`** on a runtime without X25519 — the one exported function that escaped `NoiseError`, so a caller's `instanceof NoiseError` branch missed it. - **`NoiseHandshake.start` could pair a role with a mismatched `rs`**, whose only symptom was `#writeMessage1` dereferencing `undefined` into an opaque 'handshake failed'. The role is derived from `rs` instead — in IK the initiator is exactly the side that knows it — so the mismatch is unrepresentable and the `#remoteStatic!` assertion is sound. - **An over-long remote static** would have been accepted as its first 32 bytes once the copy truncated it; it is length-checked before the copy. Skipped: the review's claim that no spec cites the e2e-client-host stages by number is false — `docs/specs/pocket-app.md` and `docs/specs/server.md` both do, so the `## Future` numbering stays. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…ores (stage 2) Stage 2 of the e2e-client-host scope: additive only. Nothing in production reads any of this yet — no legacy store deleted, no v1 state rejected, no ceremony, relay, or reader switched, no gate enforced. - `probeNoiseSupport` (server-lib-common/src/security/noise-support.ts): one X25519 generateKey plus one deriveBits, `false` on every rejection including a missing WebCrypto, never throws. No caller yet. - `presenceChallenge` / `PresenceBinding` / `isPresenceBinding` (server-lib-common/src/security/presence.ts) under `dormouse/presence/v1`, with one encoding rule — base64url fields hashed as their bytes, everything else UTF-8 — pinned by a vector computed from `node:crypto` in the test. - `mintNoiseStaticKeyPair` / `importNoiseStaticPrivateKey` in noise.ts; the Host mints its static after the Server answers `POST /api/host/enroll` (request body unchanged) and carries it in the enrollment record, so both stores persist it unchanged. `isEnrollment` takes both halves or neither; `RemoteHost` imports the private half nonextractably in `start()` and holds it unread. - Pocket IndexedDB v2 (lib/src/remote/client/pocket-db.ts): one owner of the database name, version, and stores, adding `known-hosts` and `pending-deletions` beside `device-key`, with typed records, IndexedDB and in-memory stores, and a best-effort `navigator.storage.persist()`. `device-key.ts` now opens through the same function. - Specs promoted above the fold (remote-security-model, pocket-app, server), stage 2 marked landed, SECURITY.md's credentials table names the new key, two word budgets raised. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Four parallel cleanup reviews (reuse, simplification, efficiency, altitude) over the stage-2 diff; efficiency found nothing. - `noise.ts` absorbs `probeNoiseSupport` (deleting `noise-support.ts`, which had re-declared the X25519 algorithm and key length) and gains `isNoiseStaticMaterial`, so the persisted-static shape rule lives beside what mints and imports it instead of being hand-rolled a package away in `isEnrollment`. `generateNoiseKeyPair` and `mintNoiseStaticKeyPair` now share one `generateX25519`. - `pocket-db.ts` gets `withPocketStore`, collapsing nine copies of open/transaction/close; the two unused in-memory fakes are gone (the repo's precedent is a test-local fake, and only their own test used them). - `#loadNoiseStatic` returns `void`, matching its one fire-and-forget caller; `isAddressedFrame` adopts the hoisted `isBoundedString`, and `pairing.ts` drops the alias-plus-shadow around it so both guard modules spell the wrapper `bounded`. - Prose: comments that restated the specs shrink to pointers, and the pocket-app budget tightens. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Every finding below is pinned by a test that fails without its fix. - `isPresenceBinding` takes exactly one kind's field set. It accepted extra keys, and only what `presenceChallenge` hashes is covered by the assertion — an unhashed field would have reached the Host inside a binding it had just verified. - `presenceChallenge` bounds `serverNonce`. It is the one input no binding guard covers, and on the Host's recompute path it arrives from the Client, so nothing stopped a megabyte of base64url from being decoded and hashed. - `openPocketDb` handles both version-collision directions: it closes on `versionchange` so a handle here cannot block the staged v3, and rejects on `blocked` so a pre-v2 tab holding v1 open names the failure instead of leaving the open promise unsettled forever. - `mintNoiseStatic` checks its own output against `isNoiseStaticMaterial`. Minting is documented as best-effort, but a PKCS#8 outside what `isEnrollment` accepts failed the whole exchange — with an error naming no field, since `missingEnrollmentFields` only reports the server's. - `isNoiseStaticMaterial` says out loud that it is shape only: it does not derive the public point, so halves from different keypairs pass. - Spec: the stage-4 gate needs a mint-on-start backfill, since minting is never retried and a gate alone would un-enroll a machine over one transient failure. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…e 3) Stage 3 of the e2e-client-host scope: the `e2e` relay envelope, the E2E transport framing, and a relay-integrated Noise harness. Harness-only — `RemoteHost`, `PocketClient`, pairing, connection, `/api/hosts`, and every UI are untouched, and nothing in production distributes or sends a Host static key. Both parties' statics are injected by the tests. - `server-lib-common/src/remote/wire.ts`: the `e2e` frames beside the legacy union (nothing legacy removed), with `E2E_ID_LENGTH`, `MAX_E2E_CIPHERTEXT_LENGTH` computed from `NOISE_MAX_MESSAGE_LENGTH`, and guards a stage-4 Host reuses verbatim. - `server-lib-common/src/security/noise-transport.ts`: `[kind: u8][body]` plaintexts (keepalive / stream / 4096-byte padded control), the length-prefixed application stream with its 1 MiB reassembly cap, the chunker, and `NoiseTransportSession`, which the first decrypt failure, nonce gap, or framing violation poisons permanently. - `server/src/relay.ts`: `init` binds as `connect` does, transport is forwarded only within the binding, and the hub never parses `ct` or keeps Noise state. Its shape and size guards are defense in depth. - Harness: `FakeClient` (initiator) and `FakeHost`'s responder half, with the shared prologue builder in `server/test/harness/e2e.mjs`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Quality-only cleanups from /simplify over the stage-3 diff. No framing constant, byte layout, or wire bound changes; the harness is still the only speaker of the `e2e` envelope. Reuse - `isBoundedBase64Url` + `base64UrlLength` move to `bytes.ts`; the base64url charset regex had three copies. `isSetupTokenHandle`, `isE2eId`, and `isE2eCiphertext` now share one. - `writeUint32BE` / `readUint32BE` / `UINT32_SIZE` move to `bytes.ts`, used by `lengthPrefixedConcat`, the app-stream framing, and the test that hand-rolled its own length prefix. - `MAX_E2E_CLIENT_ID_LENGTH` and `remote-host.ts`'s `MAX_CLIENT_ID_LENGTH` were the same bound with the same rationale twice; one now, shared. - `RelayHub.#bindClientToHost` is the one place the client↔host binding transition is written, so `connect` and an `e2e` `init` cannot drift. - `server/test/harness/frame-socket.mjs` holds the socket, frame recording, and teardown both fake peers had copies of. Simplification - `StreamChunker` was a stateless class; it is now `chunkAppMessage`. - Dropped `MAX_REASSEMBLY_BUFFER`'s export, `sendNonce`, `entry.noise`, `FakeHost.sendFrame`, `e2eSendKeepalive`, `e2eSendControl`, and `e2eEntry`'s unused keyed lookup — none had a caller. - `TransportReceipt` derives from `TransportPlaintext`; the two shared arms were spelled twice with nothing pinning them. - "Say it once": the relay's opacity, the both-sides guard rule, and the no-production-speaker caveat were stated 5-7 times each across code, harness, tests, and the spec. Each keeps one home plus pointers. Efficiency - `StreamReassembler` queues bodies and copies once per completed message instead of re-concatenating a growing buffer on every push. `MAX_STREAM_BODY_LENGTH` is a maximum, not a minimum: an authenticated peer may split one 1 MiB message into single-byte bodies, which the old form turned into seconds of blocking memcpy. Pinned by a one-byte-body reassembly test and a split-length-prefix test. - `isE2eServerToHostFrame` checks the free `clientId` bound before the ciphertext scan it used to follow (~33 us on a maximal `ct`). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Every finding is pinned by a test that fails without its fix. - `NoiseTransportSession` no longer destroys itself when `sendControl` or `sendApp` refuses an over-size input. That refusal happens before the first `encryptWithAd`, so no ciphertext exists and no counter moved — the stream is exactly as synchronized as it was. Poisoning there turned a caller's size error into a re-handshake, which costs fresh user presence. A failure at or after the first encrypt still poisons. - `FakeHost` serializes socket frames through a promise chain, as the relay does for its client socket and for the same reason: `#onFrame` awaits three times on an `e2e` `init` before recording the session, so a `transport` pipelined behind its own `init` was answered "no e2e session" instead of being read against the ceremony it belongs to. Stage 4 pipelines exactly that — the Client's first transport payload is what authorizes the connection. - Dropped the reassembler's queue-size throw. It could not fire: the drain loop only ever waits on a declared length it already accepted, so the length cap is what bounds the queue. Keeping an unreachable branch that reads like the backstop would hide that if the cap moved. Spec says so now instead. - Capped the harness frame logs. Recording moved into `FakeHost` with the shared socket helper, and `scripts/fake-host.mjs` is a long-running dev stand-in driving a live echo terminal — it would have retained every relayed PTY byte for the life of the process. - Pinned two behaviors that were correct but untested: a Client `transport` before any `init` is dropped rather than forwarded, and control JSON containing a NUL survives the padding strip (trailing-NUL stripping cannot truncate legitimate JSON, since `JSON.stringify` escapes NUL). Noted for stage 4 in server.md `## Future`: `e2e` requires a `hostId` of base64url-16-bytes where `isStoredHost` accepts any string, so once the legacy frames are deleted, enrollment has to pin the shape or a hand-edited `hosts.json` row becomes an unreachable Host. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Deploying mouseterm with
|
| Latest commit: |
3abb214
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://1b4f8b0a.mouseterm.pages.dev |
| Branch Preview URL: | https://e2e-client-host.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
Build & Test is red on f1be971, and the failure hides more than it looks like: it fires at the Dependency disclosure is current step, which sits ahead of Test and Build in the job — both were skipped, so pnpm test and pnpm build never actually ran on this head. The PR body's "Root pnpm test ... passes on the head commit" is a local claim CI has not confirmed.
The cause is this PR's own new dependency. website/src/data/dependencies-npm.json still has 49 entries and no @noble/ciphers. Fix is node website/scripts/generate-deps.js, committed; the hunk CI printed goes right after the @hono/node-ws entry:
{
"name": "@noble/ciphers",
"version": "2.4.0",
"license": "MIT",
"author": "Paul Miller (https://paulmillr.com)",
"homepage": "https://paulmillr.com/noble/"
},It's outside the diff, so no inline suggestion — happy to push that commit if you want it off your plate.
Since CI couldn't, I ran what I could locally, and it's clean: all six root lint/selftest scripts pass, and server-lib-common is 218/218 under node --test against a locally-built dist and a real @noble/ciphers@2.4.0 — Cacophony vector byte-for-byte through both handshake messages and all six transport messages, RFC 7748, RFC 8439, framing, reassembly, and the wire guards included. lib, server, vscode-ext, and website remain unverified until the disclosure step goes green.
No other findings. Details of what I checked and came up empty on, in case it saves a re-derivation later:
Review notes
Noise conformance — traced IK against Noise rev 34 by hand before trusting the vector: message 1 e, es, s, ss and message 2 e, ee, se mix in pattern order on both the write and read sides, the pre-message <- s mixes rs on the initiator and its own s on the responder, and Split gives the initiator k1 for send. NOISE_PROTOCOL_NAME is exactly 32 bytes, so initialize's "name is exactly HASHLEN" guard is load-bearing rather than decorative. Static reflection (a Client claiming the Host's own s) fails at ss because the forger cannot compute DH(priv_r, pub_r) — no separate guard needed.
Framing bounds — MAX_STREAM_BODY_LENGTH (65518) + kind byte + tag lands exactly on 65535, so the chunker sits on the boundary rather than under it. StreamReassembler's queue is bounded by the accepted declared length: at most 4 + 1 MiB - 1 held plus one maximal body. #take's peek path increments index even on a partial chunk, which is harmless — a partial chunk is always the last iteration. #queued stays equal to the sum of the live tail on every path, including the length === 0 case that consumes 4 and pushes an empty message.
Relay — #bindClientToHost is behavior-preserving for pair/connect/connect2: dropEstablished || client.hostId !== hostId is the old client.hostId !== frame.hostId || frame.t === 'connect'. The host→client e2e case is gated by the pre-existing client.hostId !== host.hostId return, so it inherits the binding rule. isE2eId on hostId matches the server's toBase64Url(randomBytes(16)) minting (22 chars), and clientId is minted the same way, well inside MAX_CLIENT_ID_LENGTH.
Packaging — @noble/ciphers@2.4.0 really does map "./chacha.js" in its exports, so the import specifier resolves; it appears in no esbuild external list, so it bundles into the VS Code host rather than needing runtime resolution.
IndexedDB v2 — pocket-db.ts is the only indexedDB.open in the repo, so the "two of them can never disagree about the version" claim holds. The create-what-is-absent upgrade keeps a v1 device-key record with its original out-of-line key. One cosmetic wrinkle: onblocked rejects, but the underlying open request stays live and can still fire onsuccess later, leaving an unclosed handle — the onversionchange handler set there closes it on the next bump, so it self-heals.
utf8Decode accepts overlong sequences and lone surrogates rather than rejecting them, which is a decoder-differential only against consumers that don't use this module. Since the encoder never emits those forms and control bodies are already Noise-authenticated, it isn't a finding today; worth remembering if a non-Dormouse speaker ever reads these bytes.
`Build & Test` died at the **Dependency disclosure is current** step, which sits ahead of `Test` and `Build` — so neither `pnpm test` nor `pnpm build` ran on this head at all. Stage 1 added `@noble/ciphers` as a prod dependency of `server-lib-common` without regenerating the snapshot the step diffs against (SECURITY.md, Dependency Supply Chain). Regenerated with `node website/scripts/generate-deps.js`; the only change is the one entry CI printed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
|
Fixed in 3abb214 — regenerated You were right that the PR body's claim was unconfirmed, since
On the two non-findings in your notes: agreed on both, and neither is changed here. The One caveat on the local run worth recording: the first full-suite attempt stalled with Chromatic's |
|
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
First of three stacked PRs for the e2e-client-host scope (
docs/specs/remote-security-model.md→## Future): moving pairing, connection, terminal traffic, and push to a mandatoryNoise_IK_25519_ChaChaPoly_SHA256channel the Server cannot read. This PR is infrastructure only; nothing user-visible changes and no ceremony, relay reader, or store is switched.What lands
remote-security-model.md## Future; QR grammar, relay envelope, routes, and state files underserver.md; phone flows and the worker build underpocket-app.md; pointers inremote-api.mdandalert.md.server-lib-common/src/security/noise.ts: exactly one suite, Noise rev 34, WebCrypto-only X25519/SHA-256/HMAC, Noise's HMAC-based HKDF, ChaChaPoly from@noble/cipherspinned at 2.4.0 with the release-versus-audit delta recorded in the module header. Conformance is byte-for-byte against the vendored Cacophony vector plus the RFC 7748 and RFC 8439 vectors; ephemeral injection is the only test hook.SecretStorage), imported non-extractably at start;probeNoiseSupport(not yet enforced); thedormouse/presence/v1challenge builder andPresenceBindingguards; Pocket IndexedDB v2 withknown-hostsandpending-deletionsbeside the legacydevice-keystore.e2erelay frames and bounds accepted and routed additively by the Server; the transport framing module (noise-transport.ts: kind byte, length-prefixed app stream with a 1 MiB reassembly cap, 4096-byte padded control messages, keepalives, permanent session poisoning); a fake Host and fake Client speaking Noise through the real relay, proving transcript binding, directional cipher states, counters, framing, teardown, relay opacity, and tamper rejection.Each stage had a
/simplifypass and a high-effort/code-reviewpass committed on top of it (the review commits are in the history). Specs were promoted above the fold per stage;pnpm lint:specsis green.Verification
Root
pnpm test(all lints,server-lib-common,server,lib,vscode-ext,website) passes on the head commit.Next
PR 2 (stacked) lands the atomic pairing/connection/push-re-keying cutover and the Host bounds; PR 3 lands sealed push, the built service worker, and the documentation/lint enforcement. Two iOS facts are still to be verified on a device before PR 2 ships: an X25519
CryptoKeysurvives IndexedDB structured clone, andgetUserMediaworks inside a Home Screen web app.🤖 Generated with Claude Code
https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj