diff --git a/.github/audit/application-security.md b/.github/audit/application-security.md index 71a69abc6..49ab208b8 100644 --- a/.github/audit/application-security.md +++ b/.github/audit/application-security.md @@ -25,8 +25,11 @@ in one and quietly absent from another is a finding. The end-to-end boundary is where the depth goes. Its modules are `remote-lib-common/src/security/noise.ts`, `noise-transport.ts`, `e2e-ceremony.ts`, `e2e-bounds.ts`, `token-bucket.ts`, `push-seal.ts`, -`pairing-invitation.ts`, `presence.ts` and `acl.ts`; +`pairing-invitation.ts`, `presence.ts`, `acl.ts` and `direct-path.ts`; `remote-lib-common/src/remote/wire.ts` (the frame shapes and their guards); +`lib/src/remote/direct/direct-endpoint.ts` and `direct-peer.ts` (the data +channel the same session may move onto, and the one switching policy both ends +run — `docs/specs/security-remote.md` -> "Direct path"); `lib/src/remote/burrow/burrow-runtime.ts` (both ceremonies, every Burrow bound); `lib/src/remote/burrow/push-delivery.ts`; `lib/src/remote/client/pocket-client.ts` and `lib/src/remote/pocket-app/sw.ts` (the phone, and the render sink); diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md index 10cd769c4..cc73fe3be 100644 --- a/docs/specs/deploy.md +++ b/docs/specs/deploy.md @@ -120,7 +120,7 @@ pnpm --dir standalone exec tauri signer generate # creates the Tauri update sig Two macOS packaging edge cases the script enforces; each would ship a release that fails only on the user's machine: -- **Never `--deep`-sign the outer `.app`** — it would re-sign the Node sidecar and drop the hardened-runtime entitlements it needs. Nested binaries (the Node sidecar, node-pty prebuilds, `spawn-helper`) are signed individually first, and the script then launches the signed sidecar and `require('node-pty')` from it. +- **Never `--deep`-sign the outer `.app`** — it would re-sign the Node sidecar and drop the hardened-runtime entitlements it needs. Nested binaries (the Node sidecar, the node-pty and node-datachannel prebuilds, `spawn-helper`) are signed individually first, and the script then launches the signed sidecar and requires both native addons from it. - **Build the `.tar.gz` with `COPYFILE_DISABLE=1`** and re-scan the result for `._*` entries — AppleDouble resource-fork files make the Tauri updater's extraction fail with `failed to unpack ._Dormouse.app`. ### Packaged app logging diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 68daa6148..cc419bfb5 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -454,6 +454,43 @@ nothing** — there is no frame to send — and this Client's relay socket is to Source of truth: `PocketClient.sendKeepalive` / `#reapedByBurrow` and the injected timer, clock, and visibility seams in `lib/src/remote/client/pocket-client.ts`. +## The path the session takes + +**Pocket offers a direct path once the connection outcome says `ok`**, over the +browser's own `RTCPeerConnection` with no ICE servers, and keeps the session on +the relay when the browser has none or the Burrow declines +([remote-api.md](./remote-api.md) → Direct path owns the whole protocol). + +**Must retire the previous session — its peer, its channel, and its pending +requests — immediately before the replacement's connection request goes out, and +never report burrow loss for it.** The Burrow closes the old channel at +promotion, and on a direct path that close travels peer-to-peer while the +outcome travels over the relay, so it can arrive first and fail the +replacement's own waiter. **Never earlier than that**: a presence proof the user +dismisses, or a handshake that fails, leaves a working session untouched, and a +replacement refused after the request has gone leaves none. Pinned by +`preserves a replacement connection when the old channel closes before its +outcome arrives` and `leaves a working session alone when the replacement never +reaches the Burrow` in `lib/src/remote/client/pocket-client.test.ts`. + +**The connected header names the live path** — `relay` or `direct`, captioned, +never coloured — so a relayed fallback is visible rather than silent, **with the +reason behind it in the hover text and never in the label**: an attempt that +quietly stayed relayed is still `relay`, and a third state for the common case +would read as a fault. **The transport hands up a `DirectRelayCause`, never its +failure text**, and Pocket owns the sentence for each: what an attempt fails +with includes a runtime's own exception message, which belongs in the operator's +log. **A +channel that dies after this session has switched is burrow loss**: the phone +leaves the wall exactly as it does for a `burrow-gone`, and returning costs a +fresh handshake and one WebAuthn prompt. Before the switch a failed channel +costs nothing. + +Source of truth: `PocketClient.connect` / `transportPath` / +`setOnTransportChanged` in `lib/src/remote/client/pocket-client.ts`, `TRANSPORT_PATH_LABELS` / +`TRANSPORT_RELAY_CAUSES` / `transportTitle` in +`lib/src/remote/pocket-app/App.tsx`. + ## An expired session drops to sign-in Sessions live only in the Relay's memory ([relay.md](./relay.md)), so they end diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 03e2a2841..1c4d9df7b 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -643,6 +643,9 @@ framed as application messages on the Noise session (below). reorder (which Noise's counter turns into a decrypt failure), or a framing violation destroys it and every later call throws — there is no resynchronization point in a stream cipher. +- **The control messages are the two ceremonies' outcomes and the direct path's + four signals** ([remote-api.md](./remote-api.md) → Direct path), which the + Relay routes without reading, like every other ciphertext. - **Prologues are `lengthPrefixedConcat`** of `dormouse/e2e/v1`, the ceremony kind, the `burrowId`, and — for a connection — the connection id; for a pairing, every field of its invitation in QR order ("Setup tokens and the pairing QR" diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 6a6a0ce96..57ca3f7ba 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -59,6 +59,140 @@ Source of truth: the surface model the wire shapes reuse — `dor/src/protocol.t Source of truth: `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/burrow-runtime.ts`. +### Direct path + +After authorization the same Noise session moves off the Relay onto a WebRTC +data channel. **The presence protocol is inherited unchanged and the Relay is +never trusted with authorization.** **The standalone Burrow answers**, over +`node-datachannel`'s W3C polyfill in the sidecar — **loaded at the first offer, +never at boot**, a load failure declining from then on +([standalone.md](./standalone.md) → "Burrow service"). **VS Code declines**: it +carries no addon ([Future](#future)). + +**Every signal rides inside the session**, as one of four control messages +([relay.md](./relay.md) → E2E framing) on the established session over the relay +path: `direct-offer` (Client→Burrow, SDP), `direct-answer` (Burrow→Client, SDP), +`direct-decline` (Burrow→Client), `direct-switch` (either direction) — each +`{ v: 1, t }` with exact keys and no other field. **The Relay never sees an SDP, +a candidate, or that a direct path exists.** **An unknown control shape on an +established session is ignored, never a session failure**, so a peer without +this stack simply stays relayed. + +**The Client offers once, after `ConnectionOutcomeV1 { ok: true }`, and never +retries**; it is always the offerer and creates the one ordered, reliable data +channel (`dormouse`, `arraybuffer`). **The Burrow answers at most one offer per +session**, and declines where it has no peer to build. **Each side sends its +whole description only after ICE gathering completes** — no trickle — bounded by +`DIRECT_GATHER_TIMEOUT_MS`, past which what it has is what travels. **The +answerer's setup budget is the shorter one** (`DIRECT_ANSWER_TIMEOUT_MS`, not +`DIRECT_SETUP_TIMEOUT_MS`), since it arms a relay hop later and must be the end +that gives up first. **An SDP +over `MAX_DIRECT_SDP_LENGTH` is never sent**: the Client skips the offer, the +Burrow declines. That bound derives from `CONTROL_PAYLOAD_SIZE`, so a maximal +signal always fits one control body. + +**No ICE servers**, and **never a public STUN or TURN default**: `iceServers: +[]` at both ends, host candidates only. (rationale) + +**The two shipped stacks are proven against each other by hand.** No CI job has +a browser, so `scripts/direct-interop/run.mjs` negotiates a real browser against +the real addon over the shipped `DirectPeer` — measuring the browser's offer +against `MAX_DIRECT_SDP_LENGTH`, which is a property of the host's interfaces +rather than of the code (rationale). + +**Every byte on the channel is a Noise transport message of the promoted +session**: one message per channel frame, raw bytes, the same two `CipherState`s +and counters. **Every inbound channel frame is bounded at +`NOISE_MAX_MESSAGE_LENGTH` before decryption**, and a frame over it — or a +non-binary channel message — disposes the session. (rationale) + +**The channel a session rides is reliable, ordered, and named +`DIRECT_CHANNEL_LABEL`**, and one whose association reports a per-message limit +under `NOISE_MAX_MESSAGE_LENGTH` is refused: both are checked before the open is +reported, so either abandons the attempt while the relay is still carrying the +session, and an answerer that refuses before it has answered declines rather +than leaving the offerer to wait out its setup budget. A limit the +implementation does not report is not treated as small. + +**Two limits of those checks are known and accepted.** The reliability flags +reach only as far as the implementation reports them, and `node-datachannel`'s +polyfill rebuilds an incoming channel with its own defaults — so on the +standalone Burrow only the label comparison is load-bearing (rationale). And the +message limit is the *remote's* advertised one, so it is per direction: where +the two ends disagree, a peer that has already switched loses the session rather +than staying relayed. + +**A sender bounds its own queue rather than the implementation's.** Past +`DIRECT_BUFFER_HIGH` of buffered channel data the ciphertext queues, draining at +`DIRECT_BUFFER_LOW`; once anything is queued everything queues, so nothing +overtakes a frame encrypted before it. **A frame is written once or not at all** — +the implementation's send either consumes a message or throws, and a retry would +put counted ciphertext on the wire twice. Overflowing +`MAX_DIRECT_OUTBOUND_FRAMES` / `MAX_DIRECT_OUTBOUND_BYTES` disposes the session, +as the receiver's hold does. **Each failure is reported in its own words**: this +end's queue overrunning and the channel refusing a write are opposite diagnoses, +and the reason is all an operator reading a burrow-loss log has to tell them +apart. + +**The switch preserves order per direction:** + +* A sender's `direct-switch` is its **last** message on the relay path; every + later message, keepalives included, goes on the channel. +* A receiver processes relay frames until it decrypts `direct-switch`, holding + channel frames meanwhile — at most `MAX_DIRECT_PENDING_FRAMES` / + `MAX_DIRECT_PENDING_BYTES`, **overflow disposing the session** — then drains + them in arrival order through the same decrypt path. +* **After inbound has switched, a relay `transport` frame disposes the + session**, refused before any decrypt, as does a `ct` that will not decode. +* **After either direction has switched, the channel closing or erroring + disposes the session**: the Client reports burrow loss exactly as a + `burrow-gone`, the Burrow disposes the established entry. **Before any switch + a channel failure only abandons the attempt** — including a channel not open + by `DIRECT_SETUP_TIMEOUT_MS` — and the session stays relayed. +* **A `direct-switch` arriving at an end that has abandoned its channel ends the + session** too: nothing that peer sends can arrive, and the alternative is a + session whose every request hangs unanswered. +* **A peer that does not switch back within `DIRECT_HANDOFF_TIMEOUT_MS` ends the + session.** From its own switch this end sends only on the channel, so the wait + is its own deadline rather than however long the hold takes to fill; an end + whose peer had already switched waits on nothing. +* **A connection reporting `failed` or `closed` ends the attempt at once, and + `disconnected` is waited out** for `DIRECT_DISCONNECTED_GRACE_MS` — ICE reports + it on gaps that recover, and after the switch ending one costs a fresh + handshake and a WebAuthn prompt. + +**The Relay stays the lifecycle authority.** `client-gone`, `burrow-gone`, and +either relay socket closing dispose the session, channel included, exactly as +they do relayed; the idle deadline, keepalives, and every Burrow bound are +path-agnostic — a keepalive decrypted off the channel refreshes the deadline +like any other ([remote-security-model.md](./remote-security-model.md) → Burrow +bounds). + +**One peer connection per session**, created at the offer, closed on every +disposal path, never existing before promotion. **Both ends build it through an +injected factory** — `PocketClientDeps.createDirectPeer`, +`BurrowOptions.createDirectPeer`, threaded through `BurrowServiceOptions` — +`null` where a runtime has none, so neither end reaches a WebRTC global. +**Pocket shows which path carries the session**, and where it stayed relayed +which of the three `DirectRelayCause`s it was — **a closed set, never an +attempt's failure text** ([pocket-app.md](./pocket-app.md)). + +Source of truth: `remote-lib-common/src/security/direct-path.ts` (the signals, +their guard, the constants, the `DirectFrameQueue` both queues are, and the +`DirectCutover` both ends run), +`lib/src/remote/direct/direct-peer.ts` (`DirectPeerLike` and the negotiation), +`DirectEndpoint` in `lib/src/remote/direct/direct-endpoint.ts` (the whole +direct-path policy, one per authorized session; `onRelayFrame` is both ends' only +way in from the relay and `send` their only way out; constructed at promotion by +`PocketClient.#directEndpoint` in `lib/src/remote/client/pocket-client.ts` and +`BurrowRuntime.#promoteConnection` in +`lib/src/remote/burrow/burrow-runtime.ts`); pinned by +`remote-lib-common/test/direct-path.test.mjs`, +`lib/src/remote/direct/direct-endpoint.test.ts`, +`lib/src/remote/direct/direct-peer.test.ts`, and the end-to-end cases in +`lib/src/remote/client/pocket-client.test.ts` and +`lib/src/remote/burrow/burrow-bounds.test.ts`. + ### Envelope Requests are correlated by `requestId`, events by `subId` (`RemoteRequest`, `RemoteResponse`, `RemoteEventMsg`). @@ -277,9 +411,14 @@ These are the methods the dor CLI speaks today; the remote API reuses their requ **Window lease.** A VR session may request `window.lease { windowRef }`, declaring itself that Window's primary display. Sizing needs no lease — last-attach-wins already hands VR the panes it displays — so the lease is presentational: that Window tethers wholesale instead of pane by pane, and panes created in it while the lease is held open tethered to the leaseholder. One lease per Window; the Burrow user can always reclaim it locally. Phones never need it. -### 8. WebRTC rendezvous +### 8. Direct path (WebRTC) + +**Scope: direct-path** — latency. The shipped half is [Transport → Direct path](#direct-path), which Pocket and the standalone Burrow speak today. What remains, in staged order: + +1. **VS Code Burrow** — platform-targeted VSIX builds carrying the addon per target (`docs/specs/deploy.md`). +2. **Dogfood** across a tailnet, keystroke round-trip measured relayed and direct into the rationale. -Latency. WebRTC replaces only the relay *transport* of the same Noise transport messages ([Transport](#transport)), and only after authorization: the Relay signals but is never trusted with authorization, and the presence protocol is inherited unless separately reviewed. +Relay-supplied ICE servers are unstaged (SaaS), as is a session surviving relay loss. ### 9. Audio @@ -292,4 +431,4 @@ Browser surfaces can produce audio; VR will want it (spatial, per-panel). ### Open questions -* **Browser media**: screencast frames over the WebSocket first; when WebRTC arrives, a video track would be smoother for VR. Possibly phone=frames, VR=track, negotiated in the hello. +* **Browser media**: screencast frames over the WebSocket first; once the direct path ships, a video track would be smoother for VR. Possibly phone=frames, VR=track, negotiated in the hello. diff --git a/docs/specs/remote-api.rationale.md b/docs/specs/remote-api.rationale.md index 70461f6d1..8eb5cd0b7 100644 --- a/docs/specs/remote-api.rationale.md +++ b/docs/specs/remote-api.rationale.md @@ -12,6 +12,20 @@ In September 2026, both production installations use `createAskSurfaceProvider`: resolution selects a routing key and applies the requested size, while `streamPty` separately owns the subscription. The former `SurfaceHandle.release` was a no-op in that shared constructor; a test-only release counter suggested a second resource lifetime that neither host had. +## Direct path + +**Why host candidates alone reach.** The shipped deployment is a tailnet: the Burrow's tailnet address is a host candidate the phone can route to, and the phone's own mDNS-obfuscated candidate is learned peer-reflexively from the first packet. A STUN server would buy a public reflexive candidate the deployment does not need, at the price of telling a third party both addresses. + +**What a browser and the addon actually negotiate.** `scripts/direct-interop/run.mjs` is the only fixture that puts the two shipped stacks on one channel — the in-process suites link two fakes or run the addon against itself, and no CI job has a browser. Run on macOS 26.0 with Chromium and node-datachannel 0.33.2 (libdatachannel 0.24.3), 2026-09-10: the browser's whole offer was 587 characters against `MAX_DIRECT_SDP_LENGTH`'s 2 000, one host candidate on a machine with one usable interface; the association reported `maxMessageSize` 262 144 at both ends; and 65 535-, 4 096-, and 33-byte frames crossed browser→addon→browser byte for byte and in order. + +**Why the SDP bound is measured rather than reasoned about.** 587 characters is 29% of the budget on a host with one interface, and each further candidate line costs roughly 80 more — so the headroom is real but it is a property of the machine, not of the code. A host with docker bridges, VMs, and several VPNs is the case that would spend it, and the failure there is silent by design: the attempt is skipped and the session stays relayed. Re-run the fixture on such a host before treating the bound as settled. + +**Why the answerer's reliability check is documented as reaching nothing on the Burrow.** Measured against node-datachannel 0.33.2, 2026-09-10: an offerer creating `{ordered: false, maxRetransmits: 0}` reaches the polyfill's answerer as `ordered: true, maxRetransmits: null, maxPacketLifeTime: null`, because `RTCPeerConnection` builds every incoming channel as `new RTCDataChannel(channel)` with no options and the constructor defaults them. A browser answerer reports what was negotiated, so the check bites there. Reading them some other way would mean parsing the offer's DCEP parameters, which neither the polyfill nor the addon exposes — so the limit is stated rather than closed, and the behaviour is pinned so an addon that starts reporting them is noticed rather than silently upgrading a documented gap into an enforced rule. + +**Why the message-limit check stays at open despite being per-direction.** Neither stack has a number before the association is up: measured against node-datachannel 0.33.2, 2026-09-10, `sctp` is a transport object from construction but its `maxMessageSize` reads null after `setLocalDescription` and `setRemoteDescription` at both ends, and 262 144 once the channel opens. So there is no earlier moment at which the number exists, and no way for a refusing end to decline before its peer may have switched. Both shipped stacks advertise 262 144, so the asymmetric case needs a peer that advertises under 65 535; the symmetric case abandons at both ends and stays relayed. + +**Why DTLS is not part of the trust model.** The channel is encrypted twice — DTLS underneath, Noise inside — and only the inner one is load-bearing. The DTLS fingerprints are authentic because the SDP carrying them was decrypted inside an authorized session, so DTLS adds transport hygiene rather than a second authority; a peer that broke it would still face the promoted session's ciphers. + ## Envelope **Why the clamp's upper bound is the security-relevant half.** A local resize is derived from element geometry and cannot be large, but `terminal.resize` carries a peer-supplied number straight into `term.resize` in the webview that owns the pane, and xterm bounds only the minimum before allocating `rows × cols` cells. Unbounded, one frame asking for a million by a million wedges every terminal in that window, reachable by any authorized Client (`docs/specs/security-remote.md` → "Trust boundary"). `MAX_TERMINAL_DIMENSION` is 2000 — far past any real display, since a 4K screen at an unreadably small font is on the order of 800 columns — while capping the worst a peer can request at a few million cells. diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 8dace5c46..a1b573566 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -340,6 +340,12 @@ omits `client-gone`, invents client IDs, or reorders frames. | `MAX_ESTABLISHED_E2E_SESSIONS` | 16 | `remote-lib-common/src/security/e2e-bounds.ts` | | `ESTABLISHED_E2E_IDLE_TIMEOUT_MS` | 120 000 | same | | `E2E_INIT_BURST` / `E2E_INIT_REFILL_INTERVAL_MS` | 8 / 1 000 | same | +| `DIRECT_SETUP_TIMEOUT_MS` / `DIRECT_ANSWER_TIMEOUT_MS` / `DIRECT_GATHER_TIMEOUT_MS` | 15 000 / 10 000 / 3 000 | `remote-lib-common/src/security/direct-path.ts` | +| `DIRECT_HANDOFF_TIMEOUT_MS` / `DIRECT_DISCONNECTED_GRACE_MS` | 5 000 / 5 000 | same | +| `MAX_DIRECT_SDP_LENGTH` | 2 000 characters | same | +| `MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` | 8 192 frames / 4 MiB, bytes binding first (rationale) | same | +| `MAX_DIRECT_OUTBOUND_FRAMES` / `MAX_DIRECT_OUTBOUND_BYTES` | the same pair, for what a sender holds | same | +| `DIRECT_BUFFER_HIGH` / `DIRECT_BUFFER_LOW` | 256 KiB / 64 KiB | same | - **Must bound waiting relay frames before enqueueing**, by count and cumulative received-string length; both `e2e` and `client-gone` share one FIFO and one @@ -403,6 +409,56 @@ admits Burrow enrollment with ([relay.md](./relay.md#http-api)). Pinned by `relay/test/malicious-relay.test.mjs` and `remote-lib-common/test/token-bucket.test.mjs`. +## Direct path + +**The direct path adds no layer to this model.** A WebRTC data channel replaces +the Relay as the carrier of an already-authorized session; every rule above +holds unchanged, because nothing about *what* is carried changes. +[remote-api.md](./remote-api.md) -> "Direct path" owns the design and is not +restated here: that the channel carries transport messages of the session +promoted at [Connection](#connection) on that `Split`'s own two `CipherState`s, +that every signal rides inside the ciphertext, that nothing is offered before +promotion, and that one peer connection per session is closed by every path +that ends one, are its rules. What this model adds is what is *underneath* them. + +- **DTLS beneath is transport hygiene this model does not rely on.** It protects + nothing the Noise session does not already protect, and **the fingerprints in + an SDP are authentic for exactly one reason — that SDP arrived inside the + session**. A DTLS peer is never an authenticated one. +- **Never an ICE server.** Both ends pass an empty list. (rationale) +- **What the channel may buffer is this side's bound, not the + implementation's**, in both directions ([remote-api.md](./remote-api.md) -> + "Direct path"): the same pair of numbers holds a sender's queue and a + receiver's, and overrunning either disposes the session. + +**The listener is UDP on every interface a candidate names, for the life of an +attempt.** The standalone Burrow's addon binds one socket on the unspecified +address and advertises each routable interface at that port; a browser binds per +interface. Either way the host answers UDP from anyone who can route to it on +any of those networks. (rationale) +**Two parsers sit behind it and both are attack surface**: before DTLS, ICE's +own STUN parser, which answers a binding request only under this attempt's +ufrag and password (RFC 8445 requires 24 and 128 bits of randomness), both +freshly generated and reaching the peer only inside the session; after DTLS, +the peer implementation's TLS stack — the browser's on the phone, the addon's +on a standalone Burrow (`docs/specs/security-supply-chain.md`). A memory-safety +bug in either is reachable by any stranger on those networks, and nothing above +it mitigates that. + +**A channel lost after a session has switched is burrow loss, and that is +accepted**: both ends end the session rather than resume on the Relay. (rationale) The Relay +still sees that the session exists and whether each end is online; it no longer +sees the traffic ([Residual metadata](#residual-metadata)). + +Source of truth: `remote-lib-common/src/security/direct-path.ts` (the signals, +their guard, and `DirectCutover`), `lib/src/remote/direct/direct-peer.ts` +(`DirectPeer`), `DirectEndpoint` in +`lib/src/remote/direct/direct-endpoint.ts` (the attempt, the peer, and the +switch, created at promotion by `BurrowRuntime.#promoteConnection` in +`lib/src/remote/burrow/burrow-runtime.ts` and `PocketClient.#directEndpoint` in +`lib/src/remote/client/pocket-client.ts`). The audited rows are +`docs/specs/security-remote.md` -> "Direct path". + ## Noise suite - **Exactly one suite: `Noise_IK_25519_ChaChaPoly_SHA256`, Noise revision 34.** @@ -525,9 +581,14 @@ such claim. **No traffic-analysis resistance, per-Burrow unlinkability, or metadata anonymity is claimed.** The Relay still observes account and passkey authentication data, IPs, Burrow IDs and online state, routing relationships, every session's reauth -exchange, push endpoints, timing, ciphertext sizes, and volume. Two leaks follow -and are accepted rather than closed (rationale): Client→Burrow timing exposes -inter-keystroke timing while keystroke *values* stay encrypted, and one +exchange, push endpoints, timing, ciphertext sizes, and volume — **the last +three only while the Relay is carrying the session**, since a session that has +switched to the [direct path](#direct-path) leaves it the fact of the session +and each end's liveness and nothing else. Two leaks follow and are accepted +rather than closed (rationale): Client→Burrow timing exposes inter-keystroke +timing on a relayed session while keystroke *values* stay encrypted, ending at +the switch — which in exchange shows each paired peer the other's addresses, +until then known only to the Relay — and one `PushSubscription` per worker scope lets a shared endpoint correlate every `deliveryId` one Pocket profile registers across Burrows. A push carries no counter, so a Relay that kept an envelope can re-deliver it diff --git a/docs/specs/remote-security-model.rationale.md b/docs/specs/remote-security-model.rationale.md index f81fafa7f..f3a039707 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -239,6 +239,81 @@ it arrives on a *new* socket, against a Burrow that no longer holds the private half, so no path completes. Keeping the entry leaves the same dead code on screen as the mint-straddle case under [Pairing](#pairing). +## Direct path + +*(2026-09; the path shipped on the Pocket side and the standalone Burrow.)* + +**Why the path needed no new trust layer, and how that was checked.** The +question asked of the design was which of the five layers a WebRTC channel +touches. The answer is none: the channel is a byte pipe under an already +promoted Noise session, so peer authenticity still comes from IK, presence from +the assertion the promotion consumed, authorization from the ACL conjunction, +and the final decision from the Burrow. Three things had to hold for that to be +true rather than merely plausible, and each is a rule above — the SDP travels +as a `control` message inside the session (so nothing about the channel is +Relay-supplied), the offer happens strictly after the outcome (so nothing is +built for an unauthorized party), and the bytes on the channel are the same +transport messages on the same counters (so no second cipher exists to get +wrong). A design that trickled candidates, or that negotiated on a side +channel, would have broken all three at once. + +**Why the holding queue is 4 MiB and 8,192 frames, and why bytes are the bound +that matters** *(2026-09)*. A receiver holds channel frames from the instant the +peer's channel opens until the peer's `direct-switch` decrypts off the relay, so +the window is exactly one relay one-way hop — 100–300 ms with a VPS relay and a +phone on cellular. What fills it is the Burrow's terminal stream, and that +stream is not coalesced: node-pty's `onData` becomes one `data` message per +chunk (`standalone/sidecar/pty-core.js`), one `terminalData` event +(`lib/src/remote/burrow/remote-api.ts`), and one `#sendApp` call, at the ~1 KiB +chunks a PTY emits. A single streaming pane is therefore of the order of a +thousand frames a second, and the original 64-frame cap overflowed in ~65 ms — +with overflow fatal, a `yes` running in one pane would have killed the session +at the moment it switched. Bytes are what the machine actually holds, so the +byte cap is the real one: 4 MiB is half a second of a 5 MB/s stream, well past +the worst hop. The frame cap is then placed above where 1 KiB frames can reach +it (8,192 × 1 KiB > 4 MiB) so that it constrains only a peer sending thousands +of frames too small to fill the byte cap. The worst case is per session, and +transient: `MAX_ESTABLISHED_E2E_SESSIONS` × 4 MiB = 64 MiB of held ciphertext if +all sixteen authorized phones cut over at once, on a machine that is already +running their terminals. + +**Why no ICE servers, stated as a hard rule rather than a default.** A STUN +server learns the client's public address and the fact of a session, from a +party neither endpoint chose; a TURN server learns the traffic pattern and +carries the ciphertext, which is exactly the position the Relay already holds +and the whole model treats as untrusted. The shipped deployment is a tailnet, +where host candidates reach on both ends, so the servers buy connectivity that +is already there. Relay-supplied ICE servers stay in `remote-api.md`'s +`## Future` for a SaaS deployment, where the trade is real and would need its +own analysis. + +**What the listener actually opens** *(measured 2026-09, standalone Burrow on +macOS)*: four host candidates sharing one port, no loopback and no link-local +address among them. One socket on the unspecified address, advertised once per +routable interface. + +**Why the UDP listener is named in the spec at all.** Everything else the +product opens to a network is TCP behind loopback or behind the Relay's HTTPS +origin, and the audit's listener sweep is written for that shape +(`docs/specs/security-local.md` -> "Loopback Listeners" and +`scripts/loopback-lint.mjs` both read bind spellings on loopback TCP). An ICE +socket matches neither, is bound on every interface, and appears in no source +file of ours — the addon and the browser bind it. Left unnamed it would be the +one network-facing thing no page of this suite mentions. What is behind it is +small but not nothing: an unauthenticated STUN parser gated only by a +per-attempt credential, and a DTLS stack. It exists between the offer and the +session's disposal, which is minutes, not the process's lifetime. + +**Why a lost channel after the switch ends the session instead of falling back.** +Falling back would mean resuming a `CipherState` at the counter the peer +believes it is on, across a gap of unknown length in an ordered stream — a +resynchronization the Noise transport deliberately has no construction for +([Noise suite](#noise-suite): any gap ends the session). Holding the relay path +warm as a standby would also mean the sender deciding per message which path a +frame took, which is the ordering bug the single `direct-switch` boundary +exists to make impossible. The cost of ending instead is one reconnect on the +phone, which the app already does for every other burrow loss. + ## Noise suite **Why X25519 is WebCrypto and ChaChaPoly is bundled.** Measured 2026-06 across diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index 0813fe21a..30e326c17 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -30,7 +30,7 @@ no plaintext relay route, and no reader for any of the pre-cutover frames. | Compromise | Buys | What still stands | | --- | --- | --- | -| Relay | account state, routing metadata | **no new authorization and no plaintext**. On an established session, availability only — drop, delay, reorder, or refuse, never read and never inject — and the first invalid ciphertext destroys the session. Web Push holds **confidentiality**, not **freshness**: a kept envelope re-delivers as current, accepted residual (rationale) | +| Relay | account state, routing metadata | **no new authorization and no plaintext**. On an established session, availability only — drop, delay, reorder, or refuse, never read and never inject — and the first invalid ciphertext destroys the session. Web Push holds **confidentiality**, not **freshness**: a kept envelope re-delivers as current, accepted residual (rationale). A session switched to the [direct path](#direct-path) leaves it the lifecycle levers alone — it can still end that session by dropping a socket, but sees, delays, and reorders none of its traffic | | Setup password | one endpoint, `/api/burrow/enroll`, and thence a `burrowToken` | it registers **no** passkey — `/api/setup/*` takes a Burrow-minted setup token and nothing else — so it reaches an owner passkey only via the next row. `/api/burrow/enroll` accepts one other credential, the installer's enrollment offer: owner-only *at rest*, the whole of what the file mode protects, checked by possession over HTTPS rather than local identity, so a leaked token redeems remotely — bounded single-use, 24-hour expiry, permanently disabled by the first Burrow enrollment. Still **no Burrow access** | | `burrowToken` | the Burrow's own relay traffic and, transitively, **account takeover**: it mints setup tokens at `/api/burrow/setup-token`, the only thing that registers an owner passkey | bounded three ways — single-use and dead 5 minutes after minting; revoking the Burrow (deleting its row from `burrows.json`) stops minting immediately *and* kills already-minted tokens, re-checked at both setup gates; a signed-in phone retires an unused token at `/api/setup/retire`. Still **no Burrow access**: pairing runs Noise IK against an invitation keypair the Burrow never sent anywhere (rationale) | | Synced or stolen passkey | sign-in, and the ability to *ask* | the paired Client static is missing, so `BurrowAcl` answers `client-not-paired` | @@ -161,6 +161,15 @@ Funnel publishes the same TLS origin and stays inside this analysis: public admi is owned by [The setup password](#the-setup-password), and a Client still reaches no Burrow without the Burrow-local authorization above. +**A direct path opens the one listener no loopback rule covers.** ICE gathering +binds a UDP socket per local address, so while an attempt is live the machine +answers UDP from anyone routing to it on any of those networks. +`docs/specs/security-local.md` -> "Loopback Listeners" is about loopback TCP and +`scripts/loopback-lint.mjs` reads bind spellings in our own source, so neither +reaches a socket the browser or the addon binds. It exists only between an +offer and that session's disposal, and what answers on it is +`docs/specs/remote-security-model.md` -> "Direct path". + **Must not make Funnel state an install or health verdict.** The installers configure Serve but neither inspect, warn about, enable, nor disable Funnel; `manage verify` checks the local TLS-to-loopback path, while CI and this audit check application @@ -201,6 +210,24 @@ a second unchecked resolution. - **FAIL IF** push text stops being bounded with the shared `boundedPushText` on the Burrow before sealing, or re-bounded with it in `lib/src/remote/pocket-app/sw.ts` before `showNotification`. The worker is the sanitization sink: a worker that renders what it decrypted without re-bounding it leaves the property with one enforcer instead of two (rationale). - **FAIL IF** the relay routes a Burrow-originated frame from a socket that is not the Client's current Burrow binding, or begins decoding, remembering, or acting on an `e2e` ciphertext. `relay/src/relay.ts` must route the `e2e` envelope and nothing else: it holds no gate, no challenge memory, and no notion of an authorized session (rationale). A Relay-side type import from the protocol-v1 half of `remote-lib-common/src/remote/wire.ts` is the leading indicator and fails the same way. +### Direct path + +**An authorized session may leave the Relay for a WebRTC data channel, carrying +what it already carried**: the same Noise session, the same counters, the same +bounds. `docs/specs/remote-api.md` -> "Direct path" owns the design and +`docs/specs/remote-security-model.md` -> "Direct path" owns why it adds no trust +layer; neither is restated below. + +- **FAIL IF** a `direct-offer` is accepted or sent before promotion, or a session runs a second attempt. Both halves of `DirectEndpoint` in `lib/src/remote/direct/direct-endpoint.ts` must pass `DirectCutover.begin`, which answers `true` once per session, before their first `await`; and the endpoint holding it must be built only at promotion — `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/burrow-runtime.ts`, the `ok: true` branch alone in `lib/src/remote/client/pocket-client.ts` — since a peer connection built earlier is one an unauthorized party steered. +- **FAIL IF** a byte crosses the channel that is not a Noise transport message of the promoted session: one message per frame, raw bytes, no second handshake, no plaintext, and no framing of ours beside it. **Every inbound frame is bounded at `NOISE_MAX_MESSAGE_LENGTH` before it reaches a cipher** — `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must refuse an over-cap frame and a non-binary message as violations rather than parse either. +- **FAIL IF** any signaling leaves the ciphertext. The four signals are `control` messages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP or a candidate: a negative search over `relay/src/` for `sdp`, the four signal names, and `RTCPeerConnection` must find nothing. `scripts/e2e-lint.mjs` holds it textually. +- **FAIL IF** any ICE server reaches shipped source — a `stun:`, `stuns:`, `turn:`, or `turns:` URL, or a non-empty `iceServers` array, anywhere under `remote-lib-common/src/`, `lib/src/`, or `relay/src/`. Both factories pass `iceServers: []`; a public default hands a third party the user's address. `scripts/e2e-lint.mjs` holds it textually. +- **FAIL IF** a peer connection can outlive its session. `DirectEndpoint.dispose` closes it and must run on every path that ends one: in `lib/src/remote/burrow/burrow-runtime.ts` `#disposeEstablished` — which `#disposeClient` reaches from `client-gone`, socket loss and `stop()` — and the session `#promoteConnection` replaces; in `lib/src/remote/client/pocket-client.ts` `#disposeCeremony`, on every teardown, an intentional `close()` and a dropped relay socket included. +- **FAIL IF** the direct path stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, and a sender's queue by `MAX_DIRECT_OUTBOUND_FRAMES` **and** `MAX_DIRECT_OUTBOUND_BYTES` — neither direction may hand the implementation unbounded data instead, and overflow disposes the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame: `onRelayFrame` decodes the `ct` there, so an undecodable one ends the session rather than escaping a socket handler. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. +- **FAIL IF** the standalone Burrow's native peer addon is loaded at sidecar boot rather than at the first offer, or its absence changes anything but a decline. `createNativeDirectPeerFactory` in `lib/src/host/remote/` must reach `node-datachannel` — declared in `standalone/sidecar/package.json` — only through a bare `require` performed inside an authorized session's first offer, and a load failure must answer `direct-decline` and leave that session relayed rather than fail the Burrow's start. +- **FAIL IF** a switched end waits on its peer without a deadline, or a channel this protocol did not ask for is adopted. `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must refuse a channel that is not `DIRECT_CHANNEL_LABEL`, one reported unordered or partially reliable, and one whose association reports a per-message limit below `NOISE_MAX_MESSAGE_LENGTH` — all before it reports the open, so each abandons the attempt while the relay still carries the session. **The reliability half is defence in depth against a paired Client, not a boundary control**, and reaches only as far as the implementation reports those flags: on the standalone Burrow it does not, which `lib/src/host/remote/native-direct-peer.test.ts` pins so a version that changes it is noticed (`docs/specs/remote-api.md` -> Transport -> "Direct path"). `DirectEndpoint` must arm `DIRECT_HANDOFF_TIMEOUT_MS` on its own switch, since from there it sends only on the channel. Pinned by `lib/src/remote/direct/direct-peer.test.ts` and `lib/src/remote/direct/direct-endpoint.test.ts`. +- **FAIL IF** a direct path survives `client-gone`, `burrow-gone`, or a lost relay socket: the Relay stays the lifecycle authority on both paths. Every Burrow bound is path-agnostic, and the idle deadline still moves only on a decrypted Client→Burrow transport message, whichever path carried it (`docs/specs/remote-security-model.md` -> "Burrow bounds"). + ### Revocation and the audit trail These are the two real gaps in the shipped model, and they are gaps rather than diff --git a/docs/specs/security-supply-chain.md b/docs/specs/security-supply-chain.md index e23f963bf..30cb0bb1a 100644 --- a/docs/specs/security-supply-chain.md +++ b/docs/specs/security-supply-chain.md @@ -37,13 +37,18 @@ The roots are `productDependencyFilters` in `website/scripts/generate-deps.js`. **Must reject unclassified workspaces and exclusions reachable from a product root before generating disclosure.** Runtime and optional edges count; development edges do not. `website/scripts/dependency-workspaces.test.js` pins coverage. +**An unresolvable dependency throws unless an optional-edge rule covers it.** `node-datachannel` — the sidecar's second native addon, beside `node-pty` — publishes one prebuilt package per platform, and pnpm installs only the host's. + +- **Optional, declared by an external package: skipped.** The bundle copies `standalone/sidecar/node_modules`, so a prebuild the addon alone declares (android, musl) reaches nobody. +- **Optional, declared by a product root: described from a sibling in the same `optionalDependencies` block at the same exact version string** — published in lockstep, so the disclosure is identical on every machine. No such sibling installed throws. + **Bundled themes are disclosed outside that lockfile walk.** The themes compiled into every build (`lib/src/lib/themes/bundled.json`) come from OpenVSX extensions, not npm, so `website/scripts/generate-deps.js` appends the checked-in `lib/src/lib/themes/bundled-extensions.json` to the npm table instead. The two come from one run of `lib/scripts/bundle-themes.mjs` but both are committed and can drift, which the CI gate below cannot see (rationale). `lib/src/lib/themes/bundled-extensions.test.ts` pins them, joining on the `extensionId` each disclosure record carries: a bundled theme whose extension has no record, or a record with no bundled theme left, fails. **The join is on the extension set only** — `bundled.json` carries no version or license, so nothing pins a hand-edit to those published fields. - **FAIL IF** `node website/scripts/generate-deps.js` changes `website/src/data/dependencies-npm.json`, `website/src/data/dependencies-cargo.json`, or `website/src/data/dependencies-runtime.json` when run against a clean working tree after `pnpm install --frozen-lockfile`. The install is a precondition: the generator walks real `node_modules` directories and throws rather than under-reporting if they are absent. - **FAIL IF** `.github/workflows/ci.yml` stops running that generator under that same install precondition, or stops failing on a diff (rationale). - **FAIL IF** the disclosure omits a shipped workspace's graph or excludes a shipped package. Derive shipping routes from `pnpm-workspace.yaml` and the builds, not the enumeration above; the generator enforces classification, but cannot establish whether an exclusion is justified (rationale). -Source of truth: `productDependencyFilters` / `excludedWorkspacePackages` in `website/scripts/generate-deps.js`; `assertWorkspaceCoverage` in `website/scripts/dependency-workspaces.js`. +Source of truth: `productDependencyFilters` / `excludedWorkspacePackages` / `optionalSiblingsAtSameVersion` in `website/scripts/generate-deps.js`; `assertWorkspaceCoverage` in `website/scripts/dependency-workspaces.js`. ## Bundled runtime diff --git a/docs/specs/security.md b/docs/specs/security.md index c29377532..23aae10a7 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -74,7 +74,10 @@ run this knows what they are taking on. artifact the origin serves ([Trust Model](./remote-security-model.md#trust-model)). - **Traffic analysis.** The Relay sees who talks to whom, when, how often, and how large each ciphertext is, and keystroke timing, never keystroke values - ([Residual metadata](./remote-security-model.md#residual-metadata)). + ([Residual metadata](./remote-security-model.md#residual-metadata)). An + authorized session may move onto a direct connection between the two devices, + after which the Relay sees that the session exists and nothing about its + traffic ([Direct path](./remote-security-model.md#direct-path)). - **Push replay, when push is enabled.** A push proves confidentiality, not freshness: a Relay that kept an envelope can re-deliver it ([Push sealing](./remote-security-model.md#push-sealing)). - **Per-Burrow unlinkability, when push is enabled.** One push endpoint per browser lets the Relay see diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 9c28836e0..8e2ca2314 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -137,6 +137,23 @@ realm**. Against the shared store contract (`docs/specs/relay.md` → "Burrow si browser dev harness is *not* this case: its per-run temp directory makes a dev enrollment live and die with the run. +**The direct path.** The sidecar is the one Burrow that answers a `direct-offer` +(`docs/specs/remote-api.md` → Transport → "Direct path"), over +`node-datachannel`'s W3C polyfill. **A sidecar package's transitive dependencies +do not ship** — the Tauri bundle copies `standalone/sidecar/node_modules` and +nothing else — so the addon's platform package and `detect-libc` are declared in +`standalone/sidecar/package.json` directly. **Every `dependencies` entry that +manifest declares stays `external` to `burrow.cjs`**, the `external` list being +derived from that key rather than listed beside it. The build fails if the +manifest stops declaring the addon, and asserts from esbuild's metafile that +none of those packages was inlined: the addon resolves its `.node` +relative to its own `__dirname`, and inlining would move that out of the +installed package. + +Source of truth: `standalone/sidecar/package.json`, +`lib/src/host/remote/native-direct-peer.ts`, `assertNothingInlined` in +`standalone/scripts/build-sidecar-proxy.mjs`. + **The bridge.** Webview → sidecar is one generic passthrough invoke, `burrow_command(payload)`, writing `{"event":"burrow:command", "data":payload}` to stdin for the dispatch table's `handleCommand`. Sidecar → diff --git a/lib/src/host/remote/native-direct-peer.test.ts b/lib/src/host/remote/native-direct-peer.test.ts new file mode 100644 index 000000000..26db905aa --- /dev/null +++ b/lib/src/host/remote/native-direct-peer.test.ts @@ -0,0 +1,386 @@ +// @vitest-environment node +/** + * The direct path over the **real** addon: two Node peers negotiating a live + * WebRTC data channel, with `node-datachannel`'s polyfill on both ends. + * + * Everything else in the direct path's suites runs on the in-memory pair in + * `remote/direct/test-fake-peer.ts`, which answers descriptions rather than + * parsing them and opens a channel because a test said to. That is the right + * shape for the cutover rules and the wrong one for the two questions this file + * asks: does the shipped `RTCPeerConnection` satisfy the `DirectPeerLike` seam + * as written, and does a real SCTP channel carry what the protocol puts on it. + * So the SDP here is a real description of real host candidates, the channel is + * real DTLS/SCTP, and the session riding it is the real Noise session — the + * whole loop from `test-e2e-harness.ts`, with only the addon swapped in. + * + * **The addon is resolved here rather than through the shipped factory.** It is + * installed under `standalone/sidecar`, which is where the shipped bundle finds + * it with a bare `require`; `lib` must not depend on it, because `lib` is a + * browser bundle root. So these cases inject a plain factory over the polyfill + * this file resolves, and the shipped factory is driven by the last case alone. + */ + +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { + DIRECT_CHANNEL_LABEL, + NOISE_MAX_MESSAGE_LENGTH, + type TerminalDataEvent, +} from 'remote-lib-common'; +import { DirectPeer, type DirectPeerLike } from '../../remote/direct/direct-peer'; +import { STREAMED_CHUNK, collect, makeE2eHarness, waitFor } from '../../remote/client/test-e2e-harness'; +import { createNativeDirectPeerFactory, disposeNativeDirectPeers } from './native-direct-peer'; + +/** The one call the reliability case needs that `DirectPeerLike` has no reason to. */ +interface AddsCandidates { + addIceCandidate(candidate: unknown): Promise; +} + +/** A file inside the package that declares the addon, to resolve it from. */ +const sidecarRequire = createRequire( + fileURLToPath(new URL('../../../../standalone/sidecar/package.json', import.meta.url)), +); + +interface NativePolyfill { + readonly RTCPeerConnection: new (config: { iceServers: [] }) => DirectPeerLike; +} + +const { RTCPeerConnection } = sidecarRequire('node-datachannel/polyfill') as NativePolyfill; + +/** + * `iceServers: []` as both shipped factories pass it: host candidates only, + * never a public STUN or TURN default. + */ +const buildPeer = (): DirectPeerLike => new RTCPeerConnection({ iceServers: [] }); + +/** Whether the last case already tore the addon down through the shipped path. */ +let disposedByFactory = false; + +afterAll(() => { + // The addon runs its own threads, which outlive every peer and would hold + // this worker open after the last assertion. One teardown, whichever path + // reached it — both resolve the same native module. + if (!disposedByFactory) (sidecarRequire('node-datachannel') as { cleanup: () => void }).cleanup(); +}); + +/** + * What one negotiation is given before it is written off. A local pair settles + * in single-digit milliseconds; the slack is for a machine whose interfaces are + * slow to answer, where each end waits out `DIRECT_GATHER_TIMEOUT_MS`. + */ +const ATTEMPT_BUDGET_MS = 5_000; +/** How many negotiations a case will spend before it fails; see {@link untilOpen}. */ +const NEGOTIATION_ATTEMPTS = 4; +/** Every attempt, plus the ceremonies in front of them — never vitest's default. */ +const CASE_BUDGET_MS = 45_000; + +interface Negotiation { + /** Whether the channel this attempt describes has come up. */ + open(): boolean; + /** Drop this attempt's peers, so a retry does not run beside them. */ + abandon(): void; +} + +/** + * Run `start` until the channel it negotiates comes up, abandoning an attempt + * that does not. + * + * **Retried on purpose.** Two agents in one process occasionally settle on a + * candidate pair that answers ICE and then swallows DTLS — every failure + * measured picked the same stray ULA IPv6 address, ~2% of attempts on macOS + * 26.0, 2026-09 — and staying relayed is precisely what the protocol does about + * that. The claim under test is that the addon carries the session when a + * channel comes up, not that ICE never loses one, so a lost attempt is retried + * on a fresh session rather than reported as a broken addon. + */ +async function untilOpen(start: () => Promise, what: string): Promise { + let lost: unknown; + for (let attempt = 1; attempt <= NEGOTIATION_ATTEMPTS; attempt += 1) { + const run = await start(); + try { + await waitFor(() => run.open(), what, ATTEMPT_BUDGET_MS); + return run; + } catch (error) { + lost = error; + run.abandon(); + } + } + throw lost; +} + +/** + * The whole loop — phone, relay, Burrow — with both ends on the native addon, + * paired, connected, and offered a direct path. + */ +async function startConnected() { + const clientPeers: DirectPeerLike[] = []; + const burrowPeers: DirectPeerLike[] = []; + const harness = await makeE2eHarness({ + deps: { createDirectPeer: collect(clientPeers, buildPeer) }, + burrowDirect: collect(burrowPeers, buildPeer), + }); + await harness.connectPaired(); + return { + harness, + clientPeers, + burrowPeers, + open: () => harness.client.transportPath === 'direct', + abandon: () => { + for (const peer of [...clientPeers, ...burrowPeers]) peer.close(); + }, + }; +} + +const connectedDirect = () => untilOpen(startConnected, 'the session to go direct'); + +/** As much of `RTCDataChannel` as the reliability probe reads off either end. */ +interface ChannelFacts { + readonly label: string; + readonly ordered: boolean; + readonly maxRetransmits: number | null; + readonly maxPacketLifeTime: number | null; +} + +/** + * One negotiation whose offerer asks for everything a Noise stream cannot ride, + * so the answerer's view of the channel can be compared against it. + * + * Unlike a browser, this stack raises `datachannel` when the association + * carries the channel rather than when the offer describes it, so the whole + * negotiation has to complete — which is why it goes through + * {@link untilOpen} like every other one here. + */ +async function startReliabilityProbe() { + const offerer = buildPeer(); + const answerer = buildPeer(); + let adopted: ChannelFacts | null = null; + answerer.addEventListener('datachannel', (ev) => { + adopted = (ev as { channel: ChannelFacts }).channel; + }); + for (const [from, to] of [ + [offerer, answerer], + [answerer, offerer], + ] as const) { + from.addEventListener('icecandidate', (ev) => { + const candidate = (ev as { candidate?: unknown }).candidate; + if (candidate) void (to as unknown as AddsCandidates).addIceCandidate(candidate); + }); + } + const asked = offerer.createDataChannel(DIRECT_CHANNEL_LABEL, { + ordered: false, + maxRetransmits: 0, + } as { ordered?: boolean }) as unknown as ChannelFacts; + const offer = await offerer.createOffer(); + await offerer.setLocalDescription(offer); + await answerer.setRemoteDescription(offerer.localDescription!); + const answer = await answerer.createAnswer(); + await answerer.setLocalDescription(answer); + await offerer.setRemoteDescription(answerer.localDescription!); + return { + asked, + get adopted() { + return adopted; + }, + open: () => adopted !== null, + abandon: () => { + offerer.close(); + answerer.close(); + }, + }; +} + +describe('the direct path over the native addon', () => { + it( + 'negotiates a channel and carries protocol-v1 on it, the relay silent after', + async () => { + const run = await connectedDirect(); + const { harness } = run; + + expect(run.clientPeers).toHaveLength(1); + expect(run.burrowPeers).toHaveLength(1); + expect(harness.client.transportPath).toBe('direct'); + // The two facts `DirectPeer` reads off a real connection before it lets a + // session ride one: the association's own per-message limit, and that the + // seam's `sctp` really is where the polyfill reports it. A cutover + // happened, so both were already good enough — this says which numbers. + const sctp = run.clientPeers[0]!.sctp; + expect(sctp).not.toBeNull(); + expect(sctp!.maxMessageSize).toBeGreaterThanOrEqual(NOISE_MAX_MESSAGE_LENGTH); + // Three Client→Burrow transport frames on this connection: the connection + // request the ceremony ended with, the offer, and the switch. Three back: + // the outcome, the answer, and the Burrow's own switch. The SDPs crossed + // inside the session, so the Relay saw only padded control bodies. + expect(harness.clientTransportFrames()).toHaveLength(3); + expect(harness.burrowTransportFrames()).toHaveLength(3); + + const clientBefore = harness.clientTransportFrames().length; + const burrowBefore = harness.burrowTransportFrames().length; + const chunks: TerminalDataEvent[] = []; + + expect(await harness.client.hello()).toMatchObject({ protocolVersion: 1 }); + await harness.client.watchDirectory(() => {}); + await harness.client.attach('surface-1', 80, 24, { onData: (e) => chunks.push(e) }); + await harness.client.write('surface-1', 'ls\n'); + + // Requests, answers, and the burrow→client stream all crossed the channel; + // the relay carried none of it, in either direction. + expect(harness.clientTransportFrames()).toHaveLength(clientBefore); + expect(harness.burrowTransportFrames()).toHaveLength(burrowBefore); + expect(chunks).toEqual([STREAMED_CHUNK]); + }, + CASE_BUDGET_MS, + ); + + /** + * The bound the protocol actually needs from the channel. One channel frame + * is one Noise transport message, so a real SCTP association has to carry + * `NOISE_MAX_MESSAGE_LENGTH` bytes in a single message — well past the 16 KB + * a data channel fragments at without a negotiated maximum, and the one + * property the in-memory pair cannot say anything about. + * + * Driven as a bare pair rather than through a session: what is being measured + * is the bytes, and a Noise message that large is not something the shipped + * protocol-v1 has a request for. + */ + it( + 'carries a full-size Noise transport message in one frame, intact', + async () => { + const run = await untilOpen(async () => { + let opened = 0; + const inbound: Uint8Array[] = []; + const lost: string[] = []; + const handlers = (onFrame: (frame: Uint8Array) => void) => ({ + onOpen: () => (opened += 1), + onFrame, + onClosed: (reason: string) => lost.push(reason), + onViolation: (reason: string) => lost.push(reason), + }); + const offerer = new DirectPeer({ peer: buildPeer(), handlers: handlers(() => {}) }); + const answerer = new DirectPeer({ + peer: buildPeer(), + handlers: handlers((frame) => inbound.push(frame)), + }); + const offer = await offerer.offer(); + expect(offer).not.toBeNull(); + const answer = await answerer.answer(offer!); + expect(answer).not.toBeNull(); + await offerer.acceptAnswer(answer!); + return { + offerer, + answerer, + inbound, + lost, + open: () => opened === 2, + abandon: () => { + offerer.close(); + answerer.close(); + }, + }; + }, 'both ends of the channel to open'); + + try { + const payload = new Uint8Array(NOISE_MAX_MESSAGE_LENGTH); + crypto.getRandomValues(payload); + run.offerer.send(payload); + + await waitFor(() => run.inbound.length === 1, 'the frame to arrive', ATTEMPT_BUDGET_MS); + expect(run.inbound[0]).toEqual(payload); + expect(run.lost).toEqual([]); + } finally { + run.abandon(); + } + }, + CASE_BUDGET_MS, + ); + + /** + * The channel is the session once both ends have switched, so losing it is + * losing the Burrow — there is no relay left to fall back to. + */ + it( + 'ends the session at both ends when the Burrow closes its peer', + async () => { + const run = await connectedDirect(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + run.burrowPeers[0]!.close(); + + await waitFor( + () => run.harness.burrow.establishedSessionCount === 0, + 'the Burrow to drop the session', + ATTEMPT_BUDGET_MS, + ); + await waitFor( + () => run.harness.client.connectedBurrowId === null, + 'the phone to report the Burrow gone', + ATTEMPT_BUDGET_MS, + ); + expect(gone).toHaveBeenCalledOnce(); + }, + CASE_BUDGET_MS, + ); + + /** + * The shipped factory, last because its teardown is the process's. + * + * **A teardown is terminal**: the native threads are gone, so a peer built on + * them is not one this process can use and every later offer declines, + * leaving that session relayed. The other half of the contract — a load that + * fails warns once and declines from then on — is not drivable here: vitest's + * `require` resolves from inside the store, where every workspace package is + * reachable, so the addon cannot be made to not load without replacing the + * module system this case exists to exercise. + */ + /** + * **What the reliability check does *not* reach on this stack.** `DirectPeer` + * refuses a channel that is unordered or partially reliable, and the answerer + * is the end where that could bite — it adopts a channel the peer created. A + * browser reports the parameters the offerer actually negotiated; this + * polyfill rebuilds every incoming channel with its own defaults, so the + * flags never survive the crossing and only the label comparison is + * load-bearing on a standalone Burrow. + * + * Pinned rather than left as prose: an addon version that starts reporting + * them turns a documented limitation into an enforced rule, and this is what + * says so (`docs/specs/remote-api.md` → Transport → "Direct path"). + */ + it( + 'does not carry a channel’s reliability across to the answerer', + async () => { + const run = await untilOpen(startReliabilityProbe, 'the answerer to adopt the channel'); + try { + const seen = run.adopted!; + expect(run.asked.ordered).toBe(false); + expect(seen.label).toBe(DIRECT_CHANNEL_LABEL); + expect(seen.ordered).toBe(true); + expect(seen.maxRetransmits).toBeNull(); + expect(seen.maxPacketLifeTime).toBeNull(); + } finally { + run.abandon(); + } + }, + CASE_BUDGET_MS, + ); + + it('builds a peer through a bare require, and declines once torn down', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const factory = createNativeDirectPeerFactory(); + const peer = factory(); + expect(peer).not.toBeNull(); + peer!.close(); + + disposeNativeDirectPeers(); + disposedByFactory = true; + + expect(factory()).toBeNull(); + expect(createNativeDirectPeerFactory()()).toBeNull(); + // Silent: only an installation the addon never loaded on warns, and once. + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/lib/src/host/remote/native-direct-peer.ts b/lib/src/host/remote/native-direct-peer.ts new file mode 100644 index 000000000..51f0aebcd --- /dev/null +++ b/lib/src/host/remote/native-direct-peer.ts @@ -0,0 +1,106 @@ +/** + * The standalone Burrow's direct-path peer: `node-datachannel`'s W3C polyfill, + * running next to the PTYs (`docs/specs/remote-api.md` → Transport → "Direct + * path", `docs/specs/standalone.md` → "Burrow service"). + * + * Host code, so nothing here imports the webview library — only the structural + * `DirectPeerLike` seam, as a type. The polyfill satisfies that seam without + * adaptation; the only thing this module adds is *when* the addon is loaded and + * what happens when it will not load. + * + * **The addon is never touched before the first offer.** It is a native library + * with its own thread pool: loading it at boot would cost every sidecar start, + * including the overwhelming majority that never see a Client. So the load + * happens inside the first factory call, once per process, and a Burrow that + * gets no `direct-offer` never opens it at all. + */ + +import type { DirectPeerFactory, DirectPeerLike } from '../../remote/direct/direct-peer'; + +/** The one polyfill export a direct path needs. */ +interface DirectPolyfill { + readonly RTCPeerConnection: new (config: { iceServers: [] }) => DirectPeerLike; +} + +/** The addon's own module, for the teardown the polyfill does not expose. */ +interface DirectAddon { + readonly cleanup: () => void; +} + +interface NativeDirect { + readonly polyfill: DirectPolyfill; + readonly addon: DirectAddon; +} + +/** + * The addon once some factory has loaded it. Process-wide rather than + * per-factory: one native library, one thread pool, one teardown. + */ +let native: NativeDirect | null = null; +/** + * Whether the addon is off the table for the rest of the process — a load that + * threw, or a teardown that has already run. Either way the factory answers + * `null` and the Burrow declines every offer, staying relayed. + */ +let declined = false; + +/** + * Both modules, required lazily. + * + * **The specifiers must survive bundling as bare `require` calls.** The addon's + * loader resolves its platform package and `detect-libc` relative to its own + * `__dirname`, so inlining the library into `burrow.cjs` would move that + * `__dirname` out of the installed package and leave nothing to find. The + * esbuild `external` entries in `standalone/scripts/build-sidecar-proxy.mjs` + * are what keeps them bare, and that build asserts it. + */ +function requireNative(): NativeDirect { + return { + polyfill: require('node-datachannel/polyfill') as DirectPolyfill, + addon: require('node-datachannel') as DirectAddon, + }; +} + +/** + * A {@link DirectPeerFactory} over the native polyfill. + * + * **A load failure is warned once and declines forever after.** A missing + * platform package or a wrong ABI is a property of the installation, not of the + * offer, so retrying it per session would warn on every connection and cost a + * native load attempt each time — and the Burrow's answer is the same either + * way: `direct-decline`, and the session stays on the relay. + */ +export function createNativeDirectPeerFactory(): DirectPeerFactory { + return () => { + if (!native && !declined) { + try { + native = requireNative(); + } catch (error) { + declined = true; + console.warn(`[burrow] no direct path: the WebRTC addon did not load: ${String(error)}`); + } + } + // `iceServers: []` here and nowhere else: host candidates only, never a + // public STUN or TURN default (`docs/specs/remote-api.md` → "Direct path"). + return native ? new native.polyfill.RTCPeerConnection({ iceServers: [] }) : null; + }; +} + +/** + * Tear the addon down, if some factory ever loaded it. + * + * The native side runs its own threads, which outlive every peer and would keep + * a process from exiting on their own. Terminal: a peer built on a cleaned-up + * addon is not one this process can use, so the factory declines afterwards. + */ +export function disposeNativeDirectPeers(): void { + const loaded = native; + native = null; + declined = true; + if (!loaded) return; + try { + loaded.addon.cleanup(); + } catch { + // Already down; nothing here can be reported to anyone useful. + } +} diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index 323acc36d..03e850353 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -37,6 +37,7 @@ import { type PairingOutcome, type WebSocketLike, } from '../../remote/burrow/burrow-runtime'; +import type { DirectPeerFactory } from '../../remote/direct/direct-peer'; import { originAllowedByConnectSrc } from './connect-src'; import { readEnrollmentOffer } from './enroll-offer'; import type { BurrowStateStore } from './burrow-state-store'; @@ -75,6 +76,13 @@ export interface BurrowServiceOptions { /** The CSP-shaped allowlist this build was compiled with (`connect-src.ts`). */ connectSrc: string; createWebSocket?: (url: string) => WebSocketLike; + /** + * How this host builds a peer connection for the direct path + * (`docs/specs/remote-api.md` → Transport → "Direct path"). Threaded rather + * than defaulted: the runtimes that have one differ per host, and a Burrow + * without it declines every offer and stays relayed. + */ + createDirectPeer?: DirectPeerFactory; fetch?: typeof globalThis.fetch; now?: () => number; /** @@ -156,6 +164,7 @@ export class BurrowService { readonly #connectSrc: string; readonly #kind: BurrowKind; readonly #createWebSocket?: (url: string) => WebSocketLike; + readonly #createDirectPeer?: DirectPeerFactory; readonly #fetch?: typeof globalThis.fetch; readonly #now: () => number; readonly #readOffer: () => Promise; @@ -192,6 +201,7 @@ export class BurrowService { this.#connectSrc = options.connectSrc; this.#kind = options.kind; this.#createWebSocket = options.createWebSocket; + this.#createDirectPeer = options.createDirectPeer; this.#fetch = options.fetch; this.#now = options.now ?? (() => Date.now()); this.#readOffer = options.readOffer ?? (() => readEnrollmentOffer()); @@ -607,6 +617,7 @@ export class BurrowService { this.#burrow = new BurrowRuntime({ enrollment, createWebSocket: this.#createWebSocket, + createDirectPeer: this.#createDirectPeer, createSession: (opts) => new RemoteApiSession({ burrowId: opts.burrowId, diff --git a/lib/src/host/remote/sidecar-entry.ts b/lib/src/host/remote/sidecar-entry.ts index 20af75092..42a46388d 100644 --- a/lib/src/host/remote/sidecar-entry.ts +++ b/lib/src/host/remote/sidecar-entry.ts @@ -25,6 +25,7 @@ import type { } from '../../remote/burrow/burrow-surface-provider'; import { createAskSurfaceProvider } from './ask-surface-provider'; import { bakedConnectSrc } from './connect-src'; +import { createNativeDirectPeerFactory, disposeNativeDirectPeers } from './native-direct-peer'; import { createEphemeralBurrowStateStore, FileBurrowStateStore, @@ -348,6 +349,10 @@ export function createSidecarBurrow(options: SidecarBurrowOptions): SidecarBurro kind: 'standalone', sendToUi: options.send, connectSrc: bakedConnectSrc(), + // The one host that answers a `direct-offer` today. Building the factory + // loads nothing: the addon is opened inside the first offer, if one ever + // comes (`native-direct-peer.ts`). + createDirectPeer: createNativeDirectPeerFactory(), }); void service.start().catch((error: unknown) => { console.error(`[burrow] failed to start: ${String(error)}`); @@ -369,6 +374,9 @@ export function createSidecarBurrow(options: SidecarBurrowOptions): SidecarBurro dispose() { service.dispose(); bridge.dispose(); + // After the service, so no session is still holding a channel: the addon's + // threads are what would otherwise keep the sidecar from exiting. + disposeNativeDirectPeers(); }, }; } diff --git a/lib/src/remote/burrow/burrow-bounds.test.ts b/lib/src/remote/burrow/burrow-bounds.test.ts index c4e95eaa6..d5e4928a2 100644 --- a/lib/src/remote/burrow/burrow-bounds.test.ts +++ b/lib/src/remote/burrow/burrow-bounds.test.ts @@ -19,6 +19,7 @@ import { E2E_INIT_BURST, ESTABLISHED_E2E_IDLE_TIMEOUT_MS, E2E_KEEPALIVE_INTERVAL_MS, + MAX_DIRECT_PENDING_FRAMES, MAX_ESTABLISHED_E2E_SESSIONS, MAX_E2E_CIPHERTEXT_LENGTH, MAX_CLIENT_ID_LENGTH, @@ -43,11 +44,14 @@ import { import type { BurrowEnrollment } from './enrollment'; import type { PendingPairing } from './pairing-approval'; import { FakeSocket } from '../test-fake-socket'; +import { FakeDirectNetwork } from '../direct/test-fake-peer'; +import type { DirectPeerFactory } from '../direct/direct-peer'; import { createTestAuthenticator, e2eFramesFor, flushUntil, openConnectionSession, + openDirectPath, openPairingSession, pairThroughSocket, presenceProofFor, @@ -192,10 +196,14 @@ describe('BurrowRuntime bounds', () => { crypto.restore(); }); - function makeBurrow(enrollmentOverrides?: Partial): BurrowRuntime { + function makeBurrow( + enrollmentOverrides?: Partial, + options: { createDirectPeer?: DirectPeerFactory } = {}, + ): BurrowRuntime { const created = new BurrowRuntime({ enrollment: { ...enrollment, ...enrollmentOverrides }, reconnect: false, + ...(options.createDirectPeer ? { createDirectPeer: options.createDirectPeer } : {}), createWebSocket: () => (socket = new FakeSocket()), loadAcl: () => [] as BurrowAclRecord[], saveAcl: () => {}, @@ -954,4 +962,99 @@ describe('BurrowRuntime bounds', () => { burrow.stop(); expect(clock.armed).toBe(0); }); + + // --- The direct path, which changes none of the bounds --------------------- + + /** A Burrow that can answer an offer, and the network holding both ends. */ + function directBurrow(): FakeDirectNetwork { + burrow.stop(); + const network = new FakeDirectNetwork(); + burrow = makeBurrow(undefined, { createDirectPeer: () => network.createAnswerer() }); + return network; + } + + it('extends the idle deadline on a keepalive that arrived off the channel', async () => { + // Every bound is path-agnostic: what refreshes the deadline is a decrypted + // Client message, not the transport that carried it + // (`docs/specs/remote-api.md` → Transport → "Direct path"). + const network = directBurrow(); + const live = await establish('c1'); + const path = await openDirectPath({ + socket, + burrowId: enrollment.burrowId, + clientId: 'c1', + connectionId: live.connectionId, + session: live.session, + network, + setTimer: clock.setTimer, + }); + + for (let i = 0; i < 6; i += 1) { + clock.advance(E2E_KEEPALIVE_INTERVAL_MS); + path.peer.send(live.session.sendKeepalive()); + await settle(); + } + expect(sessions[0]!.disposed).toBe(false); + expect(burrow.establishedSessionCount).toBe(1); + + // And silence on the channel reaps it exactly as silence on the relay does. + clock.advance(ESTABLISHED_E2E_IDLE_TIMEOUT_MS); + expect(sessions[0]!.disposed).toBe(true); + }); + + /** + * The Burrow's half of the same rule the Client keeps: a refused chunk + * disposes the session synchronously, from inside the loop still chunking the + * message, and the rest of it must not fall back onto the relay. + */ + it('stops a multi-chunk reply when the channel refuses its first chunk', async () => { + const network = directBurrow(); + const live = await establish('c1'); + await openDirectPath({ + socket, + burrowId: enrollment.burrowId, + clientId: 'c1', + connectionId: live.connectionId, + session: live.session, + network, + setTimer: clock.setTimer, + }); + const before = e2eFramesFor(socket, 'connection', live.connectionId).length; + // Closed under the session, which a radio gap does between two sends. + network.answererChannel!.close(); + + // Over one Noise message, so the transport chunks it into two ciphertexts. + sessions[0]!.send({ requestId: 'r1', ok: true, result: 'x'.repeat(70_000) }); + await settle(); + + expect(sessions[0]!.disposed).toBe(true); + expect(burrow.establishedSessionCount).toBe(0); + expect(e2eFramesFor(socket, 'connection', live.connectionId)).toHaveLength(before); + }); + + it('disposes a session whose held channel frames outrun the queue', async () => { + const network = directBurrow(); + const live = await establish('c1'); + // The Client never announces its switch, so every channel frame is held — + // which is what makes the cap the only thing bounding this. + const path = await openDirectPath({ + socket, + burrowId: enrollment.burrowId, + clientId: 'c1', + connectionId: live.connectionId, + session: live.session, + network, + switchOutbound: false, + setTimer: clock.setTimer, + }); + + for (let i = 0; i <= MAX_DIRECT_PENDING_FRAMES; i += 1) { + path.peer.send(live.session.sendKeepalive()); + } + await settle(); + + expect(sessions[0]!.disposed).toBe(true); + expect(burrow.establishedSessionCount).toBe(0); + expect(burrow.trackedClientCount).toBe(0); + }); }); diff --git a/lib/src/remote/burrow/burrow-runtime.test.ts b/lib/src/remote/burrow/burrow-runtime.test.ts index 22475eef3..7f80bcb5c 100644 --- a/lib/src/remote/burrow/burrow-runtime.test.ts +++ b/lib/src/remote/burrow/burrow-runtime.test.ts @@ -151,6 +151,11 @@ describe('BurrowRuntime end-to-end ceremonies', () => { return e2eFramesFor(socket, kind, id); } + /** The Burrow's transport frames on one connection; index 0 is the outcome. */ + function transportFrames(connectionId: string): Array> { + return e2eFramesFor(socket, 'connection', connectionId, 'transport'); + } + function sendE2e( clientId: string, kind: 'pairing' | 'connection', @@ -1340,4 +1345,102 @@ describe('BurrowRuntime end-to-end ceremonies', () => { expect(burrow.invitationState(invitation.inviteId)).toBe('live'); generateKey.mockRestore(); }); + + // --- The direct path ------------------------------------------------------- + + /** Pair, connect, and promote one client; the whole Client side is real. */ + async function establishSession(clientId = 'c1') { + const { authenticator, clientStatic } = await pairedClient(clientId); + const connectionId = testRoutingId(); + const { session, burrowChallenge } = await openConnection(clientId, clientStatic, connectionId); + const binding = connectionBinding( + connectionId, + burrowChallenge, + session, + authenticator.credentialId, + ); + sendE2e( + clientId, + 'connection', + connectionId, + 'transport', + toBase64Url(session.sendControl({ presence: await presenceProofFor(authenticator, binding) })), + ); + await settle(); + expect(await outcome(session, 'connection', connectionId)).toEqual({ + ok: true, + burrowLabel: BURROW_LABEL, + }); + return { session, connectionId, clientId }; + } + + /** + * Decrypt the transport frame at `index`, which must be a control message; + * index 0 is the connection outcome and the signals follow it. + */ + function controlAt( + session: NoiseTransportSession, + connectionId: string, + index: number, + ): Promise> { + return readOutcome(socket, session, 'connection', connectionId, index); + } + + /** One `direct-*` signal from the Client, on the established session. */ + function sendSignal( + clientId: string, + connectionId: string, + session: NoiseTransportSession, + signal: Record, + ): void { + sendE2e(clientId, 'connection', connectionId, 'transport', toBase64Url(session.sendControl(signal))); + } + + const OFFER_SDP = 'v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n'; + + it('declines an offer it has no way to answer, and answers a second one not at all', async () => { + // No `createDirectPeer`: the VS Code host, and any runtime whose native + // addon will not load (`docs/specs/remote-api.md` → Transport → + // "Direct path"). + makeBurrow(); + const { session, connectionId, clientId } = await establishSession(); + + sendSignal(clientId, connectionId, session, { v: 1, t: 'direct-offer', sdp: OFFER_SDP }); + + expect(await controlAt(session, connectionId, 1)).toEqual({ v: 1, t: 'direct-decline' }); + + // **One attempt per session.** A second offer allocates nothing and is not + // answered — not even with another decline. + sendSignal(clientId, connectionId, session, { v: 1, t: 'direct-offer', sdp: OFFER_SDP }); + await settle(); + expect(transportFrames(connectionId)).toHaveLength(2); + + // And the session is untouched: protocol-v1 still crosses it. + for (const ciphertext of session.sendApp( + utf8Encode(JSON.stringify({ requestId: 'r1', method: 'hello' })), + )) { + sendE2e(clientId, 'connection', connectionId, 'transport', toBase64Url(ciphertext)); + } + await settle(); + expect(sessions[0]!.handled).toEqual([{ requestId: 'r1', method: 'hello' }]); + expect(sessions[0]!.disposed).toBe(false); + }); + + it('ignores a control message that is not a signal, rather than failing the session', async () => { + // The compatibility rule the whole staging rests on: an established session + // carrying a control shape this peer does not know stays up. + makeBurrow(); + const { session, connectionId, clientId } = await establishSession(); + + sendSignal(clientId, connectionId, session, { v: 2, t: 'direct-offer', sdp: OFFER_SDP }); + sendSignal(clientId, connectionId, session, { t: 'something-else' }); + sendSignal(clientId, connectionId, session, { v: 1, t: 'direct-offer' }); + // A signal only the Burrow sends is ignored coming the other way. + sendSignal(clientId, connectionId, session, { v: 1, t: 'direct-decline' }); + await settle(); + + expect(transportFrames(connectionId)).toHaveLength(1); + expect(burrow.establishedSessionCount).toBe(1); + expect(sessions[0]!.disposed).toBe(false); + }); }); diff --git a/lib/src/remote/burrow/burrow-runtime.ts b/lib/src/remote/burrow/burrow-runtime.ts index d340c22fa..c084b5513 100644 --- a/lib/src/remote/burrow/burrow-runtime.ts +++ b/lib/src/remote/burrow/burrow-runtime.ts @@ -47,6 +47,7 @@ import { DELIVERY_ID_BYTE_LENGTH, type ConnectionOutcomeV1, type ConnectionPolicy, + type DirectSignalV1, type E2eRelayToBurrowFrame, type BurrowAclRecord, type BurrowFrame, @@ -57,9 +58,12 @@ import { type PresenceBinding, type SealedPushV1, type RelayToBurrowFrame, + type TransportReceipt, } from 'remote-lib-common'; import type { BurrowEnrollment } from './enrollment'; import { createSerialQueue } from '../../host/remote/serial-queue'; +import { DirectEndpoint } from '../direct/direct-endpoint'; +import type { DirectPeerFactory } from '../direct/direct-peer'; import { realTimer, type RemoteTimer, type RemoteWebSocket } from '../ws'; import { loadBurrowAcl } from './acl'; import type { PendingPairing } from './pairing-approval'; @@ -212,8 +216,17 @@ interface EstablishedSession { readonly api: RemoteApiSessionLike; /** The IK-authenticated Client static — what the session cap is keyed on. */ readonly clientStaticPublicKey: string; - /** When this Burrow last **decrypted** a Client→Burrow transport message here. */ + /** + * When this Burrow last **decrypted** a Client→Burrow transport message here, + * on either path: the idle deadline is path-agnostic. + */ lastClientActivityAt: number; + /** + * This session's direct path, as the answerer runs it. Created with the + * session and disposed with it, so a peer connection can neither precede + * authorization nor outlive it. + */ + readonly direct: DirectEndpoint; } /** Per-client lifecycle state tracked by the Burrow, keyed by clientId. */ @@ -281,6 +294,13 @@ export interface BurrowOptions { setTimer?: RemoteTimer; /** Auto-reconnect with backoff (default true; tests pass false). */ reconnect?: boolean; + /** + * How this host builds a peer connection for the direct path + * (`docs/specs/remote-api.md` → Transport → "Direct path"), or `null` where it + * has none. Absent, every `direct-offer` is declined and every session stays + * relayed. + */ + createDirectPeer?: DirectPeerFactory; } const INITIAL_BACKOFF_MS = 1_000; @@ -302,6 +322,7 @@ export class BurrowRuntime { readonly #now: () => number; readonly #setTimer: RemoteTimer; readonly #reconnect: boolean; + readonly #createDirectPeer: DirectPeerFactory | null; /** * Per-client lifecycle state keyed by clientId. Folding the three concerns @@ -418,6 +439,7 @@ export class BurrowRuntime { this.#onInvitationChanged = options.onInvitationChanged ?? (() => {}); this.#setTimer = options.setTimer ?? realTimer; this.#reconnect = options.reconnect ?? true; + this.#createDirectPeer = options.createDirectPeer ?? null; } get status(): BurrowStatus { @@ -1315,7 +1337,10 @@ export class BurrowRuntime { const state = this.#clients.get(frame.clientId); if (!state) return; if (state.established?.connectionId === frame.id) { - this.#onEstablishedFrame(frame.clientId, state.established, frame.ct); + // Every rule about a frame on an authorized session — which path may + // carry it, and that its `ct` must decode — is the endpoint's + // (`docs/specs/remote-api.md` → Transport → "Direct path"). + state.established.direct.onRelayFrame(frame.ct); return; } const pending = state.connection; @@ -1413,8 +1438,9 @@ export class BurrowRuntime { // Cleared with the dispose, not merely overwritten below: without a session // factory there is no replacement, and a leftover reference would route the // next frame on the old id into a handler that has already been disposed. - state.established?.api.dispose(); - state.established = undefined; + // Never `#disposeEstablished`, whose prune would detach the `state` this + // promotion is about to write into. + this.#clearEstablished(state); this.#sendControl(clientId, 'connection', pending.connectionId, pending.session, { ok: true, burrowLabel: boundedBurrowLabel(this.#enrollment.label), @@ -1425,23 +1451,41 @@ export class BurrowRuntime { this.#pruneClient(clientId); return; } - // Destructured, so the `send` closure retains only what an established + // Destructured, so the endpoint's closures retain only what an established // session is — the id and the two cipher states — and not the pending // record, whose handshake hash, Client static and challenge are spent. const { connectionId, session, clientStaticPublicKey } = pending; + // Declared first so the send path and the endpoint's liveness check can both + // name the session they belong to; assigned before any frame can reach it. + let established: EstablishedSession; const api = this.#createSession({ burrowId: this.#enrollment.burrowId, send: (payload) => { - this.#sendApp(clientId, connectionId, session, payload); + this.#sendApp(clientId, established, payload); }, }); - state.established = { + const direct = new DirectEndpoint('answerer', { + createPeer: this.#createDirectPeer, + sendSignal: (signal) => this.#sendControl(clientId, 'connection', connectionId, session, signal), + sendRelay: (ciphertext) => + this.#sendE2e(clientId, 'connection', connectionId, 'transport', ciphertext), + receive: (ciphertext) => this.#receiveOnSession(clientId, established, ciphertext), + fatal: (reason) => { + console.warn(`[burrow] the direct path ended this session: ${reason}`); + this.#disposeEstablished(clientId); + }, + isCurrent: () => this.#clients.get(clientId)?.established === established, + setTimer: this.#setTimer, + }); + established = { connectionId, session, api, clientStaticPublicKey, lastClientActivityAt: this.#now(), + direct, }; + state.established = established; this.#armReaper(); } @@ -1530,21 +1574,30 @@ export class BurrowRuntime { } } - /** One transport frame on an authorized session: protocol-v1, or a keepalive. */ - #onEstablishedFrame(clientId: string, established: EstablishedSession, ct: string): void { - let receipt; + /** + * Decrypt one transport ciphertext, whichever path carried it — protocol-v1, + * a keepalive, or one of the direct path's signals — and answer the receipt + * for the endpoint to read a signal out of. + */ + #receiveOnSession( + clientId: string, + established: EstablishedSession, + ciphertext: Uint8Array, + ): TransportReceipt | null { + let receipt: TransportReceipt; try { - receipt = established.session.receive(fromBase64Url(ct)); + receipt = established.session.receive(ciphertext); } catch { // A failed decrypt is not activity: it proves only that *something* - // reached the relay, and the session is dead either way. + // reached this Burrow, and the session is dead either way. this.#disposeEstablished(clientId); - return; + return null; } // The one thing that refreshes the idle deadline, keepalive or application - // data alike (`docs/specs/remote-security-model.md` → Burrow bounds). + // data alike, and on either path + // (`docs/specs/remote-security-model.md` → Burrow bounds). established.lastClientActivityAt = this.#now(); - if (receipt.kind !== 'app') return; + if (receipt.kind !== 'app') return receipt; for (const message of receipt.messages) { let payload: unknown; try { @@ -1561,25 +1614,31 @@ export class BurrowRuntime { // session from inside this loop. Handing the rest of the receipt to an // api that is already disposed would leave whatever it allocates with no // owner left to tear it down. - if (this.#clients.get(clientId)?.established !== established) return; + if (this.#clients.get(clientId)?.established !== established) return null; } + return receipt; } - #sendApp( - clientId: string, - connectionId: string, - session: NoiseTransportSession, - payload: unknown, - ): void { + /** + * One protocol-v1 message on an established session, chunked as it needs. + * **The endpoint routes every chunk**, relay or channel, so which path carries + * them is {@link DirectEndpoint.send}'s rule rather than this loop's. + */ + #sendApp(clientId: string, established: EstablishedSession, payload: unknown): void { + const { session, direct } = established; try { for (const ciphertext of session.sendApp(utf8Encode(JSON.stringify(payload)))) { - this.#sendE2e(clientId, 'connection', connectionId, 'transport', ciphertext); + // A channel that refuses a chunk disposes this session synchronously, + // and so does the promotion that replaces it: the rest of the message + // has no session left to belong to, and must reach neither path. + if (direct.disposed) return; + direct.send(ciphertext); } } catch { // **Only a poisoned session is burrow loss.** An over-cap message is // refused before the first `encryptWithAd`, so no ciphertext exists and // no counter moved; disposing there would turn a caller's size error into - // a re-handshake, re-entrantly from inside `#onEstablishedFrame`'s loop. + // a re-handshake, re-entrantly from inside `#receiveOnSession`'s loop. if (!session.isPoisoned) { console.warn('[burrow] discarding an application message the transport refused'); return; @@ -1591,32 +1650,45 @@ export class BurrowRuntime { #disposeEstablished(clientId: string): void { const state = this.#clients.get(clientId); if (!state?.established) return; + this.#clearEstablished(state); + this.#pruneClient(clientId); + } + + /** + * Tear one established session down and clear the slot, leaving the entry + * itself to the caller — a promotion is about to fill it, a disposal prunes. + */ + #clearEstablished(state: ClientState): void { + if (!state.established) return; + state.established.direct.dispose(); state.established.api.dispose(); state.established = undefined; - this.#pruneClient(clientId); } // --- Shared plumbing ----------------------------------------------------- /** - * One control message on a ceremony session; the transport pads every one to - * the same size (`docs/specs/relay.md` → E2E framing). + * One control message on a ceremony or established session — an outcome, or + * one of the direct path's signals, which ride the relay until the switch. + * The transport pads every one to the same size (`docs/specs/relay.md` → E2E + * framing). Answers `false` for a poisoned session, which has nothing to say. */ #sendControl( clientId: string, kind: 'pairing' | 'connection', id: string, session: NoiseTransportSession, - value: PairingOutcomeV1 | ConnectionOutcomeV1, - ): void { + value: PairingOutcomeV1 | ConnectionOutcomeV1 | DirectSignalV1, + ): boolean { let ciphertext: Uint8Array; try { ciphertext = session.sendControl({ ...value }); } catch { - // A poisoned session has nothing to say; the caller disposes it anyway. - return; + // Whatever poisoned it disposes it; the caller need not. + return false; } this.#sendE2e(clientId, kind, id, 'transport', ciphertext); + return true; } /** Forget a client that holds nothing, so a relay-chosen key cannot accumulate. */ diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index 3ed27940d..c723e6146 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -1,13 +1,10 @@ /** * The Pocket client's two end-to-end ceremonies, driven against the **real** - * `BurrowRuntime` through an in-memory relay (`../test-relay.ts`). + * `BurrowRuntime` through an in-memory relay. * - * **No ceremony step is stubbed.** The Noise handshakes are the shipped suite, - * the presence proofs are real ES256 assertions over the shared challenge - * builder — verified by the same `verifyPresenceProof` a Burrow runs — and the - * outcomes are decrypted on the session that produced them. Only the browser - * and network edges are faked: `fetch`, `WebSocket`, WebAuthn's two calls, and - * the two IndexedDB stores. + * The loop itself — client, relay, Burrow, and the account plane in front of + * them — is `./test-e2e-harness.ts`, shared with the suite that runs the same + * ceremonies over the native direct path. **No ceremony step is stubbed** there. * * The account-plane half (setup, sign-in, session expiry, push) drives a mocked * `fetch` alone; the relay is not involved in any of it. @@ -15,31 +12,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + CONTROL_PAYLOAD_SIZE, DEFAULT_PAIRING_TTL_MS, E2E_KEEPALIVE_INTERVAL_MS, ESTABLISHED_E2E_IDLE_TIMEOUT_MS, KEEPALIVE_BODY_SIZE, - REMOTE_EVENTS, - REMOTE_METHODS, SELFHOST_ACCOUNT_ID, SETUP_TOKEN_INVALID_ERROR, formatPairingInvitationUrl, fromBase64Url, generateNoiseKeyPair, hashPasskeyPublicKey, - mintNoiseStaticKeyPair, parsePairingInvitationUrl, - presenceChallenge, pushEndpointFingerprint, - randomBase64Url, toBase64Url, - type BurrowAclRecord, - type NoiseStaticKeyMaterial, - type PairingInvitation, type PasskeyAssertion, - type PresenceBinding, type TerminalDataEvent, - utf8Encode, } from 'remote-lib-common'; import { @@ -56,102 +44,44 @@ import { type PocketClientDeps, type PocketStorage, } from './pocket-client'; -import type { - KnownBurrowStore, - KnownBurrowV1, - PendingDeletionStore, - PendingDeliveryDeletionV1, -} from './pocket-db'; +import type { KnownBurrowV1 } from './pocket-db'; import { FakeSocket } from '../test-fake-socket'; -import { createTestRelay, type TestRelay } from '../test-relay'; -import { createTestAuthenticator, type TestAuthenticator } from '../test-e2e-client'; -import { BurrowRuntime } from '../burrow/burrow-runtime'; -import type { BurrowEnrollment } from '../burrow/enrollment'; -import type { PendingPairing } from '../burrow/pairing-approval'; +import { fakeTimers } from '../test-timers'; +import { + FakeDirectNetwork, + type FakeDirectNetworkOptions, + type FakePeer, +} from '../direct/test-fake-peer'; +import type { DirectPeerLike } from '../direct/direct-peer'; +import type { RemoteTimer } from '../ws'; +import { createTestAuthenticator, settle, type TestAuthenticator } from '../test-e2e-client'; import { PasskeyAlreadyRegisteredError, type WebAuthnClient } from './webauthn'; +import { + AUTH_ROUTES, + BURROW_LABEL, + CREDENTIAL_ID, + ORIGIN, + PASSKEY_PUBLIC_KEY, + RP_ID, + SESSION_TOKEN, + STREAMED_CHUNK, + collect, + makeE2eHarness, + makeFetch, + memoryKnownBurrows, + memoryPendingDeletions, + memoryStorage, + secret, + waitFor, + type E2eHarness, + type FetchCall, + type MemoryKnownBurrows, + type MemoryPendingDeletions, + type RouteHandler, +} from './test-e2e-harness'; // --- Fakes ----------------------------------------------------------------- -const ORIGIN = 'https://pocket.example'; -const RP_ID = 'pocket.example'; -const BURROW_LABEL = 'Ned’s laptop'; -const SESSION_TOKEN = 'tok-abc'; -/** What the stub Burrow streams on attach: a chunk whose two projections differ. */ -const STREAMED_CHUNK: TerminalDataEvent = { - bytes: toBase64Url(utf8Encode('pre\x1b]1337;File=inline=1:AAAA\x07post')), - text: toBase64Url(utf8Encode('prepost')), -}; - -/** A base64url string usable where a real 32-byte secret goes. */ -function secret(): string { - return randomBase64Url(32); -} - -interface FetchCall { - url: string; - method: string; - headers: Record; - body: unknown; -} - -type RouteHandler = ( - body: unknown, -) => { status?: number; json?: unknown } | Promise<{ status?: number; json?: unknown }>; - -/** A router-style fake `fetch` that records every call. */ -function makeFetch( - routes: Record, - /** Answers a path no exact route claims; without one, an unknown path throws. */ - fallback?: (path: string, method: string) => { status?: number; json?: unknown } | undefined, -) { - const calls: FetchCall[] = []; - const fetch = (async (url: string, init?: RequestInit) => { - const method = init?.method ?? 'POST'; - const headers = (init?.headers ?? {}) as Record; - const body = init?.body ? JSON.parse(init.body as string) : undefined; - calls.push({ url, method, headers, body }); - const path = new URL(url, 'http://test').pathname; - const handler = routes[path]; - const answered = handler ? await handler(body) : fallback?.(path, method); - if (!answered) throw new Error(`unexpected fetch: ${path}`); - const { status = 200, json } = answered; - return { - ok: status >= 200 && status < 300, - status, - json: async () => json ?? {}, - } as Response; - }) as unknown as typeof fetch; - return { fetch, calls }; -} - -function memoryStorage(): PocketStorage { - const passkeys = new Map(); - let pushEndpoint: string | null = null; - return { - getPasskeyPublicKey: (id) => passkeys.get(id) ?? null, - setPasskeyPublicKey: (id, pk) => void passkeys.set(id, pk), - forgetPasskeyPublicKey: (id) => void passkeys.delete(id), - knownCredentialIds: () => [...passkeys.keys()], - getRegisteredPushEndpoint: () => pushEndpoint, - setRegisteredPushEndpoint: (fingerprint) => void (pushEndpoint = fingerprint), - }; -} - -interface MemoryKnownBurrows extends KnownBurrowStore { - readonly records: Map; -} - -function memoryKnownBurrows(): MemoryKnownBurrows { - const records = new Map(); - return { - records, - get: async (burrowId) => records.get(burrowId) ?? null, - put: async (record) => void records.set(record.burrowId, record), - delete: async (burrowId) => void records.delete(burrowId), - list: async () => [...records.values()], - }; -} - /** The delivery id a paired record holds; throws if the record is not paired. */ function deliveryIdOf(store: MemoryKnownBurrows, burrowId: string): string { const authorization = store.records.get(burrowId)?.authorization; @@ -159,20 +89,6 @@ function deliveryIdOf(store: MemoryKnownBurrows, burrowId: string): string { return authorization.deliveryId; } -interface MemoryPendingDeletions extends PendingDeletionStore { - readonly records: Map; -} - -function memoryPendingDeletions(): MemoryPendingDeletions { - const records = new Map(); - return { - records, - put: async (record) => void records.set(`${record.burrowId}:${record.deliveryId}`, record), - delete: async (burrowId, deliveryId) => void records.delete(`${burrowId}:${deliveryId}`), - list: async () => [...records.values()], - }; -} - /** * A clock the test can make jump a full pairing TTL on every read, so the * deadline a ceremony sets is already due by the time its waiter is @@ -194,15 +110,6 @@ function expiringClock(): { now: () => number; expire: () => void } { }; } -/** Poll until `predicate` holds, so a Burrow awaiting WebCrypto can catch up. */ -async function waitFor(predicate: () => boolean, what = 'a condition'): Promise { - for (let i = 0; i < 400; i++) { - if (predicate()) return; - await new Promise((r) => setTimeout(r, 2)); - } - throw new Error(`timed out waiting for ${what}`); -} - // --- The account-plane harness --------------------------------------------- interface Harness { @@ -213,9 +120,6 @@ interface Harness { pendingDeletions: MemoryPendingDeletions; } -const CREDENTIAL_ID = 'cred-123'; -const PASSKEY_PUBLIC_KEY = 'pk-spki-b64u'; - const assertion: PasskeyAssertion = { credentialId: CREDENTIAL_ID, clientDataJSON: 'client-data', @@ -236,31 +140,6 @@ const fakeWebAuthn: WebAuthnClient = { }, }; -const AUTH_ROUTES: Record = { - '/api/setup/begin': () => ({ - json: { - challenge: secret(), - rpId: RP_ID, - accountId: SELFHOST_ACCOUNT_ID, - existingCredentialIds: [], - }, - }), - '/api/setup/finish': () => ({ - json: { accountId: SELFHOST_ACCOUNT_ID, credentialId: CREDENTIAL_ID }, - }), - '/api/setup/retire': () => ({ status: 204 }), - '/api/signin/begin': () => ({ json: { challenge: secret(), rpId: RP_ID } }), - '/api/signin/finish': () => ({ - json: { - sessionToken: SESSION_TOKEN, - accountId: SELFHOST_ACCOUNT_ID, - expiresAt: 1, - passkeyPublicKey: PASSKEY_PUBLIC_KEY, - }, - }), - '/api/burrows': () => ({ json: { burrows: [{ burrowId: 'h1', label: 'Laptop', online: true }] } }), -}; - function makeClient( routes: Record, overrides: Partial = {}, @@ -317,224 +196,6 @@ async function seedRecord( return record; } -// --- The end-to-end harness ------------------------------------------------- - -interface E2eHarness { - client: PocketClient; - burrow: BurrowRuntime; - relay: TestRelay; - burrowId: string; - authenticator: TestAuthenticator; - noiseStatic: NoiseStaticKeyMaterial; - knownBurrows: MemoryKnownBurrows; - pendingDeletions: MemoryPendingDeletions; - approvals: PendingPairing[]; - savedAcl: BurrowAclRecord[]; - calls: FetchCall[]; - /** The harness's own `fetch`, for a second client on the same fake Relay. */ - fetch: typeof fetch; - /** The Client's relay socket, once one is open — what a keepalive lands on. */ - clientSocket(): FakeSocket; - /** One live invitation, as `setupQr` would mint it. */ - mintInvitation(): Promise; - /** Run a pairing and confirm it on the Burrow with the digits the phone showed. */ - pairAndApprove( - invitation: PairingInvitation, - options?: { code?: (shown: string) => string }, - ): Promise>>; -} - -/** - * A real Burrow, a real relay, and a real client — the whole loop in memory. - * - * `/api/reauth/*` is faked, but faithfully: `begin` derives the challenge from - * the presented binding with the shared builder, exactly as the Relay does, so - * the assertion the authenticator produces is one `verifyPresenceProof` - * accepts. Nothing else about the proof is simulated. - */ -async function makeE2eHarness( - options: { - burrowId?: string; - knownBurrows?: MemoryKnownBurrows; - pendingDeletions?: MemoryPendingDeletions; - authenticator?: TestAuthenticator; - noiseStatic?: NoiseStaticKeyMaterial; - /** - * What the Burrow *announces* as its static, when that has to differ from the - * key it actually handshakes with. Nothing on the Burrow validates this - * string, so it is how a malformed pin reaches the Client at all. - */ - announcedStatic?: string; - loadAcl?: () => BurrowAclRecord[]; - now?: () => number; - /** Make every delivery-row deletion fail, as an offline phone's would. */ - pushDeleteFails?: boolean; - /** Extra `PocketClient` deps — the keepalive timer and visibility seams. */ - deps?: Partial; - } = {}, -): Promise { - const burrowId = options.burrowId ?? randomBase64Url(16); - const authenticator = - options.authenticator ?? (await createTestAuthenticator({ rpId: RP_ID, origin: ORIGIN })); - const noiseStatic = options.noiseStatic ?? (await mintNoiseStaticKeyPair()); - const knownBurrows = options.knownBurrows ?? memoryKnownBurrows(); - const pendingDeletions = options.pendingDeletions ?? memoryPendingDeletions(); - const approvals: PendingPairing[] = []; - let savedAcl: BurrowAclRecord[] = []; - - const enrollment: BurrowEnrollment = { - relayUrl: ORIGIN, - burrowId, - burrowToken: 'burrow-tok', - origin: ORIGIN, - rpId: RP_ID, - label: BURROW_LABEL, - noiseStaticPrivateKey: noiseStatic.privateKeyPkcs8, - noiseStaticPublicKey: options.announcedStatic ?? noiseStatic.publicKey, - }; - const burrowSocket = new FakeSocket(); - const burrow = new BurrowRuntime({ - enrollment, - reconnect: false, - createWebSocket: () => burrowSocket, - loadAcl: options.loadAcl ?? (() => []), - saveAcl: (_burrowId, records) => { - savedAcl = [...records]; - }, - requestApproval: (pending) => approvals.push(pending), - dismissApproval: () => {}, - createSession: ({ send }) => ({ - // Enough protocol-v1 to prove the byte stream: every request is answered - // with its own `requestId`, which is what `hello` correlates on. - handle: (data) => { - const request = data as { requestId?: unknown; method?: unknown }; - if (typeof request.requestId !== 'string') return; - send({ - requestId: request.requestId, - ok: true, - result: { protocolVersion: 1, burrowId, grants: { input: true, layout: false } }, - }); - // An attach opens its stream under the request's own id, so one canned - // event proves the subscription path as well as the request one. - if (request.method === REMOTE_METHODS.surfaceAttach) { - send({ - subId: request.requestId, - event: REMOTE_EVENTS.terminalData, - data: STREAMED_CHUNK, - }); - } - }, - dispose: () => {}, - }), - }); - burrow.start(); - burrowSocket.open(); - const relay = createTestRelay({ burrowId, burrowSocket }); - - // The presence routes, derived exactly as the Relay derives them. - const nonces = new Map(); - const routes: Record = { - ...AUTH_ROUTES, - // The account's real passkey, so the key the proof presents is the one the - // authenticator actually signs with. - '/api/signin/finish': () => ({ - json: { - sessionToken: SESSION_TOKEN, - accountId: SELFHOST_ACCOUNT_ID, - expiresAt: 1, - passkeyPublicKey: authenticator.publicKey, - }, - }), - '/api/reauth/begin': async (body) => { - const binding = (body as { binding: PresenceBinding }).binding; - const relayNonce = secret(); - nonces.set(relayNonce, binding); - return { - json: { - challenge: await presenceChallenge(binding, relayNonce), - rpId: RP_ID, - relayNonce, - allowCredentials: [binding.passkeyCredentialId], - }, - }; - }, - '/api/reauth/finish': (body) => { - const { relayNonce } = body as { relayNonce: string }; - if (!nonces.delete(relayNonce)) return { status: 400, json: { error: 'unknown nonce' } }; - return { json: { verifiedAt: 1 } }; - }, - }; - // The delivery ids a Burrow mints are random, so the deletion route is matched - // by shape rather than by an exact path. - const { fetch, calls } = makeFetch(routes, (path, method) => { - if (method !== 'DELETE' || !path.startsWith('/api/push/subscriptions/')) return undefined; - return options.pushDeleteFails ? { status: 503, json: { error: 'down' } } : { status: 204 }; - }); - - const storage = memoryStorage(); - const webauthn: WebAuthnClient = { - async registerPasskey() { - return { - credentialId: authenticator.credentialId, - publicKey: authenticator.publicKey, - clientDataJSON: 'create-client-data', - }; - }, - // The real thing: a signature this Burrow's own verifier accepts. - getAssertion: (challenge) => authenticator.assert(challenge, ORIGIN), - }; - let clientSocket: FakeSocket | null = null; - const client = new PocketClient({ - wsBase: 'ws://test', - fetch, - webauthn, - createWebSocket: () => (clientSocket = relay.openClientSocket()), - knownBurrows, - pendingDeletions, - storage, - ...(options.now ? { now: options.now } : {}), - ...options.deps, - }); - // Sign-in caches the asserted passkey's public key and names the credential - // every presence proof is built from, exactly as it does in the app. - await client.signin(); - - return { - client, - burrow, - relay, - burrowId, - authenticator, - noiseStatic, - knownBurrows, - pendingDeletions, - approvals, - get savedAcl() { - return savedAcl; - }, - calls, - fetch, - clientSocket: () => { - if (!clientSocket) throw new Error('the Client has not opened a relay socket'); - return clientSocket; - }, - mintInvitation: () => burrow.mintInvitation(secret(), Date.now() + DEFAULT_PAIRING_TTL_MS), - async pairAndApprove(invitation, { code } = {}) { - // Counted from here: a harness that pairs twice must confirm the *new* - // request rather than re-answering the one still in the log. - const before = approvals.length; - let shown: string | null = null; - const pairing = client.pair(invitation, 'iPhone Safari', (value) => { - shown = value; - }); - await waitFor(() => approvals.length > before, 'the Burrow to surface an approval'); - const pending = approvals[approvals.length - 1]!; - pending.approve(code ? code(shown!) : shown!); - return await pairing; - }, - }; -} - // --- Pairing ---------------------------------------------------------------- describe('pairing, end to end', () => { @@ -908,30 +569,6 @@ describe('connecting, end to end', () => { // --- Keepalives ------------------------------------------------------------- -/** One armed timer at a time, fired by hand — no test waits thirty seconds. */ -function fakeTimers() { - const armed: Array<{ run: () => void; delayMs: number; cancelled: boolean }> = []; - return { - setTimer(run: () => void, delayMs: number): () => void { - const timer = { run, delayMs, cancelled: false }; - armed.push(timer); - return () => { - timer.cancelled = true; - }; - }, - get live() { - return armed.filter((timer) => !timer.cancelled); - }, - /** Fire the armed timer, as its delay elapsing would. */ - fire(): void { - const timer = this.live.at(-1); - if (!timer) throw new Error('no keepalive timer is armed'); - timer.cancelled = true; - timer.run(); - }, - }; -} - /** `document.visibilityState`, as a seam a test can flip. */ function fakeVisibility() { let visible = true; @@ -1082,6 +719,416 @@ describe('keepalives on an established session', () => { }); }); +// --- The direct path -------------------------------------------------------- + +describe('the direct path, end to end', () => { + /** + * A connected phone and a real Burrow holding the two ends of one linked peer + * pair, with every timer on both sides the test's own. + * + * Nothing about the negotiation is stubbed: the signals are real control + * messages on the real session, and each side runs the shipped + * `DirectPeer` — only the `RTCPeerConnection` underneath is in memory. + */ + async function connectedDirect( + options: { + network?: FakeDirectNetworkOptions; + /** Give the Client no peer factory, as a browser without WebRTC has. */ + clientHasPeer?: boolean; + /** Give the Burrow none, as the VS Code host has. */ + burrowHasPeer?: boolean; + } = {}, + ) { + const network = new FakeDirectNetwork(options.network); + const timers = fakeTimers(); + const clientPeers: FakePeer[] = []; + const burrowPeers: FakePeer[] = []; + const harness = await makeE2eHarness({ + deps: { + setTimer: timers.setTimer, + ...(options.clientHasPeer === false + ? {} + : { createDirectPeer: collect(clientPeers, () => network.createOfferer()) }), + }, + ...(options.burrowHasPeer === false + ? {} + : { burrowDirect: collect(burrowPeers, () => network.createAnswerer()) }), + }); + await harness.connectPaired(); + return { + harness, + network, + timers, + clientPeers, + burrowPeers, + /** This session's routing id, read off the envelope the Client addressed. */ + connectionId: harness + .clientSocket() + .frames('e2e') + .find((frame) => frame.kind === 'connection')!.id as string, + /** Client→relay transport frames on the connection, which stop at the switch. */ + clientFrames: harness.clientTransportFrames, + /** Burrow→relay transport frames on this connection, which stop at its own switch. */ + burrowFrames: harness.burrowTransportFrames, + /** Wait for the Burrow's second transport frame: its answer, or its decline. */ + answered: () => + waitFor(() => harness.burrowTransportFrames().length === 2, 'the Burrow to answer'), + /** Wait until both directions have left the relay. */ + cutover: () => + waitFor(() => harness.client.transportPath === 'direct', 'the session to go direct'), + }; + } + + it('offers after the outcome and cuts over, all as control messages on the relay', async () => { + const run = await connectedDirect(); + await run.cutover(); + + // Three Client→Burrow transport frames on this connection: the connection + // request the ceremony ended with, the offer, and the switch. Nothing else + // rides the relay, and the Relay never sees an SDP — only a padded + // control body it cannot read. + expect(run.clientFrames()).toHaveLength(3); + for (const frame of run.clientFrames()) { + expect(fromBase64Url(frame.ct as string).length).toBe(1 + CONTROL_PAYLOAD_SIZE + 16); + } + // And three the other way: the outcome, the answer, and the Burrow's switch. + expect(run.burrowFrames()).toHaveLength(3); + // Signaling only so far: the channel has carried nothing. + expect(run.network.offererChannel!.sent).toEqual([]); + }); + + it('carries protocol-v1 on the channel afterwards, and nothing more on the relay', async () => { + const run = await connectedDirect(); + await run.cutover(); + const clientBefore = run.clientFrames().length; + const burrowBefore = run.burrowFrames().length; + const chunks: TerminalDataEvent[] = []; + + expect(await run.harness.client.hello()).toMatchObject({ protocolVersion: 1 }); + await run.harness.client.watchDirectory(() => {}); + await run.harness.client.attach('surface-1', 80, 24, { onData: (e) => chunks.push(e) }); + await run.harness.client.write('surface-1', 'ls\n'); + + // The relay carried none of it, in either direction. + expect(run.clientFrames()).toHaveLength(clientBefore); + expect(run.burrowFrames()).toHaveLength(burrowBefore); + // The channel carried all of it — including the burrow→client stream. + expect(run.network.offererChannel!.sent.length).toBe(4); + expect(run.network.answererChannel!.sent.length).toBeGreaterThanOrEqual(5); + expect(chunks).toEqual([STREAMED_CHUNK]); + }); + + it('keepalives ride the channel once the session has switched', async () => { + const run = await connectedDirect(); + await run.cutover(); + const clientBefore = run.clientFrames().length; + const sentBefore = run.network.offererChannel!.sent.length; + + run.timers.fireAt(E2E_KEEPALIVE_INTERVAL_MS); + + expect(run.clientFrames()).toHaveLength(clientBefore); + const sent = run.network.offererChannel!.sent.slice(sentBefore); + // The kind byte, 32 zero bytes, and the Poly1305 tag: the same fixed-size + // keepalive the relay would have carried. + expect(sent.map((frame) => frame.length)).toEqual([1 + KEEPALIVE_BODY_SIZE + 16]); + }); + + it('stays relayed and fully working against a Burrow that declines', async () => { + const run = await connectedDirect({ burrowHasPeer: false }); + + // The decline is the Burrow's second transport frame, after the outcome. + await run.answered(); + await waitFor(() => run.clientPeers[0]!.closed, 'the Client to close its peer'); + + expect(run.harness.client.transportPath).toBe('relay'); + const before = run.clientFrames().length; + expect(await run.harness.client.hello()).toMatchObject({ protocolVersion: 1 }); + expect(run.clientFrames().length).toBeGreaterThan(before); + }); + + it('ends both ends when the channel dies after the switch', async () => { + const run = await connectedDirect(); + await run.cutover(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + run.network.dropChannels(); + + await waitFor( + () => run.harness.burrow.establishedSessionCount === 0, + 'the Burrow to drop the session', + ); + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + expect(run.harness.client.transportPath).toBe('relay'); + }); + + it('disposes the Client’s session on a relay frame that arrives after the switch', async () => { + const run = await connectedDirect(); + await run.cutover(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + // Refused before any decrypt: the ciphertext is never even looked at. + run.harness.clientSocket().receive({ + t: 'e2e', + burrowId: run.harness.burrowId, + kind: 'connection', + id: run.connectionId, + step: 'transport', + ct: 'AAAA', + }); + + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + }); + + it('disposes the Burrow’s session on a relay frame that arrives after the switch', async () => { + const run = await connectedDirect(); + await run.cutover(); + + run.harness.relay.burrowSocket.receive({ + t: 'e2e', + clientId: run.harness.relay.clientId, + burrowId: run.harness.burrowId, + kind: 'connection', + id: run.connectionId, + step: 'transport', + ct: 'AAAA', + }); + + await waitFor( + () => run.harness.burrow.establishedSessionCount === 0, + 'the Burrow to drop the session', + ); + }); + + /** + * `isE2eCiphertext` bounds a `ct`'s alphabet and its length, not its padding, + * so a relay can put a well-shaped envelope on the wire whose ciphertext will + * not decode. Both ends must end the session on it, rather than throw out of + * the socket handler or warn and drop it. + */ + /** + * A refused chunk disposes the session synchronously, from inside the loop + * that is still chunking the message. The rest of it belongs nowhere: routing + * it onto the relay would put post-switch ciphertext there and kill the peer + * with a misleading reason. + */ + it('stops a multi-chunk message when the channel refuses its first chunk', async () => { + const run = await connectedDirect(); + await run.cutover(); + const clientBefore = run.clientFrames().length; + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + // Closed under the session, which a radio gap does between two sends. + run.network.offererChannel!.close(); + + // Over one Noise message, so the transport chunks it into two ciphertexts. + await expect(run.harness.client.write('surface-1', 'x'.repeat(70_000))).rejects.toThrow(); + + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + // Neither chunk reached the relay of a session that had just been torn down. + expect(run.clientFrames()).toHaveLength(clientBefore); + }); + + it('disposes the Client’s session on a relay frame that will not decode', async () => { + // Nothing switches, so the relay is still the path this frame belongs on + // and the decode is the only thing that can refuse it. + const run = await connectedDirect({ network: { opening: 'never' } }); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + expect(() => + run.harness.clientSocket().receive({ + t: 'e2e', + burrowId: run.harness.burrowId, + kind: 'connection', + id: run.connectionId, + step: 'transport', + // Two base64url characters: one byte, with nonzero trailing bits. + ct: 'AB', + }), + ).not.toThrow(); + + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + }); + + it('disposes the Burrow’s session on a relay frame that will not decode', async () => { + const run = await connectedDirect({ network: { opening: 'never' } }); + + run.harness.relay.burrowSocket.receive({ + t: 'e2e', + clientId: run.harness.relay.clientId, + burrowId: run.harness.burrowId, + kind: 'connection', + id: run.connectionId, + step: 'transport', + ct: 'AB', + }); + + await waitFor( + () => run.harness.burrow.establishedSessionCount === 0, + 'the Burrow to drop the session', + ); + }); + + /** + * What a failed decrypt kills is the end-to-end session; the relay socket is + * to the *Relay*, and reconnecting is a fresh handshake over the one already + * open. Nulling it without closing it would leave it live and unreferenced, + * with the app's next `openSocket()` opening a second beside it. + */ + it('keeps the relay socket open when the end-to-end session fails', async () => { + const run = await connectedDirect({ network: { opening: 'never' } }); + const socket = run.harness.clientSocket(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + // Decodes, and then fails to decrypt: 18 bytes that are not this session's. + socket.receive({ + t: 'e2e', + burrowId: run.harness.burrowId, + kind: 'connection', + id: run.connectionId, + step: 'transport', + ct: 'A'.repeat(24), + }); + + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + expect(run.harness.client.socketOpen).toBe(true); + // The same socket, still open: `openSocket()` would reuse it. + expect(run.harness.clientSocket()).toBe(socket); + expect(socket.readyState).toBe(1); + }); + + /** + * The race the holding queue exists for: the Burrow's answers overtake the + * `direct-switch` that precedes them on the relay. The in-memory relay routes + * synchronously, so the test holds that direction by hand. + */ + it('holds channel frames until the peer’s switch, then drains them in order', async () => { + const run = await connectedDirect({ network: { opening: 'manual' } }); + await run.answered(); + await settle(); + + run.harness.relay.holdToClient(); + run.network.openChannels(); + // The Client has switched its own sends; the Burrow's switch is held. + expect(run.harness.client.transportPath).toBe('relay'); + + const order: string[] = []; + const first = run.harness.client.hello().then(() => order.push('first')); + const second = run.harness.client.write('surface-1', 'ls').then(() => order.push('second')); + await settle(); + expect(order).toEqual([]); + + run.harness.relay.releaseToClient(); + + await Promise.all([first, second]); + expect(order).toEqual(['first', 'second']); + expect(run.harness.client.transportPath).toBe('direct'); + }); + + /** + * A Client that connects twice replaces its own session, and the peer and + * channel of the one it replaced go with it: left alive, the orphan's channel + * would still be reporting violations against the session that replaced it. + */ + it('closes the previous session’s peer when the Client connects again', async () => { + const run = await connectedDirect(); + await run.cutover(); + const firstPeer = run.clientPeers[0]!; + const firstChannel = run.network.offererChannel!; + + const second = await run.harness.client.connect(run.harness.burrowId); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + expect(second.ok).toBe(true); + expect(run.clientPeers).toHaveLength(2); + expect(firstPeer.closed).toBe(true); + + // Nothing arriving on the orphan can touch the session that replaced it. + firstChannel.receiveRaw('a text frame'); + + expect(gone).not.toHaveBeenCalled(); + expect(run.harness.client.connectedBurrowId).toBe(run.harness.burrowId); + }); + + it('preserves a replacement connection when the old channel closes before its outcome arrives', async () => { + const run = await connectedDirect(); + await run.cutover(); + const firstChannel = run.network.offererChannel!; + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + // Hold the replacement's outcome — anything on a connection that is not the + // live one — so the previous channel's close reaches the Client first. + run.harness.relay.holdToClientWhen( + (frame) => frame.id !== run.connectionId && frame.step === 'transport', + ); + const replacement = run.harness.client.connect(run.harness.burrowId); + await waitFor( + () => run.harness.relay.isHoldingToClient() && firstChannel.readyState === 'closed', + 'the old channel to close while the outcome is held', + ); + run.harness.relay.releaseToClient(); + + expect(await replacement).toEqual({ ok: true, burrowLabel: BURROW_LABEL }); + expect(gone).not.toHaveBeenCalled(); + // And the replacement is a working session, not just a resolved promise. + await run.cutover(); + expect((await run.harness.client.hello()).burrowId).toBe(run.harness.burrowId); + }); + + /** + * The retire destroys a live session, so it may not run until the only thing + * that can race it — the connection request — is about to be sent. A presence + * proof the user dismisses never reaches the Burrow. + */ + it('leaves a working session alone when the replacement never reaches the Burrow', async () => { + const run = await connectedDirect(); + await run.cutover(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + vi.spyOn(run.harness.authenticator, 'assert').mockRejectedValueOnce(new Error('dismissed')); + + await expect(run.harness.client.connect(run.harness.burrowId)).rejects.toThrow('dismissed'); + + expect(gone).not.toHaveBeenCalled(); + expect(run.harness.client.connectedBurrowId).toBe(run.harness.burrowId); + expect(run.harness.client.transportPath).toBe('direct'); + expect((await run.harness.client.hello()).burrowId).toBe(run.harness.burrowId); + }); + + it('closes the Burrow’s peer with the client the Relay says is gone', async () => { + const run = await connectedDirect(); + await run.cutover(); + + run.harness.relay.burrowSocket.receive({ + t: 'client-gone', + clientId: run.harness.relay.clientId, + }); + + await waitFor(() => run.burrowPeers[0]!.closed, 'the Burrow to close its peer'); + expect(run.harness.burrow.establishedSessionCount).toBe(0); + }); + + it('closes the Burrow’s peer when its own relay socket drops', async () => { + const run = await connectedDirect(); + await run.cutover(); + + run.harness.relay.burrowSocket.drop(); + + await waitFor(() => run.burrowPeers[0]!.closed, 'the Burrow to close its peer'); + expect(run.harness.burrow.establishedSessionCount).toBe(0); + }); +}); + // --- Setup, sign-in, and the token a scan carries --------------------------- describe('setup + signin', () => { diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index d43257d16..c045d7939 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -44,6 +44,9 @@ import { utf8Encode, type ConnectionDenialCode, type ConnectionRequestV1, + type DirectPath, + type DirectRelayCause, + type DirectSignalV1, type DirectoryEntry, type DirectorySnapshot, type E2eClientFrame, @@ -73,6 +76,7 @@ import { type TerminalAttachResult, type TerminalClosedEvent, type TerminalDataEvent, + type TransportReceipt, } from 'remote-lib-common'; import { PasskeyAlreadyRegisteredError, @@ -85,6 +89,8 @@ import { type KnownBurrowV1, type PendingDeletionStore, } from './pocket-db'; +import { DirectEndpoint } from '../direct/direct-endpoint'; +import type { DirectPeerFactory } from '../direct/direct-peer'; import { realTimer, type RemoteTimer, type RemoteWebSocket } from '../ws'; /** The slice of a WebSocket the client uses; a browser `WebSocket` satisfies it. */ @@ -146,6 +152,13 @@ export interface PocketClientDeps { /** The keepalive timer; see {@link RemoteTimer}. */ readonly setTimer?: RemoteTimer; readonly visibility?: PocketVisibility; + /** + * How this runtime builds a peer connection for the direct path + * (`docs/specs/remote-api.md` → Transport → "Direct path"), or `null` where it + * has none. Absent, this Client never offers one and every session stays + * relayed. + */ + readonly createDirectPeer?: DirectPeerFactory; } /** Terminal stream callbacks for {@link PocketClient.attach}. */ @@ -322,6 +335,14 @@ interface PendingRequest { interface EstablishedSession { readonly connectionId: string; readonly session: NoiseTransportSession; + /** Where this session's frames are addressed on the relay; fixed for its life. */ + readonly route: E2eRoute; + /** + * This session's direct path, created with it and disposed with it. **Every + * byte this Client sends goes through it**, relayed or not, so there is no + * moment at which a live session has no endpoint to route through. + */ + readonly direct: DirectEndpoint; /** * When this Client last put a byte on this session — the mirror of the * Burrow's `lastClientActivityAt`, because that is the clock the Burrow reaps on @@ -342,6 +363,7 @@ export class PocketClient { readonly #now: () => number; readonly #setTimer: RemoteTimer; readonly #visibility: PocketVisibility; + readonly #createDirectPeer: DirectPeerFactory | null; #ws: PocketSocket | null = null; #sessionToken: string | null = null; @@ -350,6 +372,9 @@ export class PocketClient { #established: EstablishedSession | null = null; #connectedBurrowId: string | null = null; #onBurrowGone: (() => void) | null = null; + #onTransportChanged: + | ((path: DirectPath, cause: DirectRelayCause | null) => void) + | null = null; /** Cancels the armed keepalive, and the visibility subscription behind it. */ #cancelKeepalive: (() => void) | null = null; #cancelVisibility: (() => void) | null = null; @@ -379,6 +404,7 @@ export class PocketClient { this.#now = deps.now ?? (() => Date.now()); this.#setTimer = deps.setTimer ?? realTimer; this.#visibility = deps.visibility ?? documentVisibility(); + this.#createDirectPeer = deps.createDirectPeer ?? null; } get sessionToken(): string | null { @@ -389,6 +415,26 @@ export class PocketClient { return this.#connectedBurrowId; } + /** + * Which path carries this session, for the indicator the connected chrome + * shows (`docs/specs/pocket-app.md`). `direct` only once **both** directions + * have left the relay — before that the relay is still carrying half of it. + */ + get transportPath(): DirectPath { + return this.#established?.direct.path ?? 'relay'; + } + + /** + * Notified whenever {@link transportPath} or the reason a session is still + * relayed changes — the indicator's one seam, since a cause is only ever read + * as it changes. + */ + setOnTransportChanged( + callback: ((path: DirectPath, cause: DirectRelayCause | null) => void) | null, + ): void { + this.#onTransportChanged = callback; + } + /** * Whether this browser has been used with Dormouse before, which decides * whether the auth screen offers sign-in at all @@ -858,6 +904,19 @@ export class PocketClient { handshakeHash: toBase64Url(session.handshakeHash), passkeyCredentialId: record.passkeyCredentialId, }); + // **A second Connect on one Client replaces the first**, the mirror of + // `BurrowRuntime.#promoteConnection`: its predecessor's endpoint, peer and + // channel go, and left alive the orphan's channel would report violations + // against *this* session. The Burrow closes that channel at promotion, and + // on a direct path that close travels peer-to-peer while the outcome + // travels over the relay — so it can arrive first, and `#rejectAll` would + // fail the waiter registered on the next line. + // + // **Here and no earlier.** Only the connection request can reach + // `#promoteConnection`, so nothing before this line can close the old + // channel — and a presence proof the user cancels, or a handshake that + // throws, must leave a working session exactly as it was. + if (this.#established) this.#endSession('connection replaced', { notifyGone: false }); let outcome: unknown; try { const request: ConnectionRequestV1 = { presence }; @@ -869,9 +928,22 @@ export class PocketClient { return { ok: false, message: CONNECTION_DENIAL_MESSAGES['burrow-error'], pairingRequired: false }; } if (outcome.ok) { - this.#established = { connectionId, session, lastSentAt: this.#now() }; + // Still load-bearing for a *concurrent* Connect to another Burrow, which + // the retire above cannot see: without it that session's endpoint and + // peer would be overwritten below rather than closed. + this.#disposeCeremony(); + // Declared first so the endpoint's deps can name the session they serve; + // assigned before anything can reach them. + let established: EstablishedSession; + const route: E2eRoute = { kind: 'connection', id: connectionId, burrowId }; + // After the outcome and never before: a peer connection that existed + // ahead of authorization would be one an unauthorized party had steered. + const direct = this.#directEndpoint(() => established, route); + established = { connectionId, session, route, direct, lastSentAt: this.#now() }; + this.#established = established; this.#connectedBurrowId = burrowId; this.#startKeepalives(); + void direct.offer(); return { ok: true, burrowLabel: outcome.burrowLabel }; } if (outcome.code === 'pairing-required') { @@ -1116,6 +1188,56 @@ export class PocketClient { } } + // --- The direct path ----------------------------------------------------- + + /** + * This session's direct path, as the offerer runs it + * (`docs/specs/remote-api.md` → Transport → "Direct path"). Every rule about + * the attempt, the channel, and the cutover is + * {@link DirectEndpoint}'s; what is injected here is how this Client puts a + * signal on the relay, decrypts a channel frame, and reports the session gone. + */ + #directEndpoint(current: () => EstablishedSession, route: E2eRoute): DirectEndpoint { + return new DirectEndpoint('offerer', { + createPeer: this.#createDirectPeer, + sendSignal: (signal) => this.#sendDirectSignal(current(), signal), + sendRelay: (ciphertext) => this.#sendE2e(route, 'transport', ciphertext), + receive: (ciphertext) => this.#receiveOnSession(current(), ciphertext), + // Reported exactly as a `burrow-gone` frame is: from here they are the + // same event, and the app must leave the wall either way. + fatal: (reason) => this.#loseBurrow(reason), + isCurrent: () => this.#established === current(), + onTransportChanged: (path, cause) => this.#onTransportChanged?.(path, cause), + setTimer: this.#setTimer, + }); + } + + /** + * One signal on the relay — the path that carries them until the switch. A + * poisoned session has nothing to say; whatever poisoned it ends it. + */ + #sendDirectSignal(established: EstablishedSession, signal: DirectSignalV1): boolean { + try { + this.#sendE2e(established.route, 'transport', established.session.sendControl({ ...signal })); + return true; + } catch { + return false; + } + } + + /** + * The end-to-end session is over while the relay socket is not: dispose it, + * fail everything in flight, and tell the app — the same three steps a + * `burrow-gone` frame takes, because from here they are the same event. + * + * **The socket is left alone**, which is the whole difference from + * {@link #teardown}: it is the socket to the *Relay*, and reconnecting is a + * fresh handshake over the one already open. + */ + #loseBurrow(reason: string): void { + this.#endSession(reason, { notifyGone: true }); + } + // --- Keepalives ---------------------------------------------------------- /** @@ -1125,15 +1247,12 @@ export class PocketClient { */ sendKeepalive(): void { const established = this.#established; - const burrowId = this.#connectedBurrowId; - if (!established || burrowId === null) return; + if (!established) return; if (this.#reapedByBurrow(established)) return; try { - this.#sendE2e( - { kind: 'connection', id: established.connectionId, burrowId }, - 'transport', - established.session.sendKeepalive(), - ); + // Path-agnostic: a keepalive off the channel refreshes the Burrow's idle + // deadline exactly as one off the relay does. + established.direct.send(established.session.sendKeepalive()); established.lastSentAt = this.#now(); } catch { // A closed socket or a poisoned session; both have their own teardown, @@ -1161,9 +1280,7 @@ export class PocketClient { */ #reapedByBurrow(established: EstablishedSession): boolean { if (this.#now() - established.lastSentAt < ESTABLISHED_E2E_IDLE_TIMEOUT_MS) return false; - this.#disposeCeremony(); - this.#rejectAll(new Error(BURROW_SESSION_REAPED_MESSAGE)); - this.#onBurrowGone?.(); + this.#loseBurrow(BURROW_SESSION_REAPED_MESSAGE); return true; } @@ -1216,16 +1333,21 @@ export class PocketClient { }); } - /** One protocol-v1 message on the established session, chunked as it needs. */ + /** + * One protocol-v1 message on the established session, chunked as it needs. + * **The endpoint routes every chunk**, relay or channel, so which path carries + * them is {@link DirectEndpoint.send}'s rule rather than this loop's. + */ #sendApp(payload: unknown): void { const established = this.#established; if (!established) throw new Error('not connected to a burrow'); - const burrowId = this.#connectedBurrowId; - if (burrowId === null) throw new Error('not connected to a burrow'); if (this.#reapedByBurrow(established)) throw new Error(BURROW_SESSION_REAPED_MESSAGE); - const route = { kind: 'connection', id: established.connectionId, burrowId } as const; for (const ciphertext of established.session.sendApp(utf8Encode(JSON.stringify(payload)))) { - this.#sendE2e(route, 'transport', ciphertext); + // A channel that refuses a chunk is burrow loss, taken synchronously: the + // rest of this message has no session left to belong to, and must reach + // neither path. + if (established.direct.disposed) return; + established.direct.send(ciphertext); } established.lastSentAt = this.#now(); } @@ -1288,9 +1410,7 @@ export class PocketClient { if (isE2eRelayToClientFrame(frame)) this.#onE2e(frame); return; case 'burrow-gone': - this.#disposeCeremony(); - this.#rejectAll(new Error('burrow disconnected')); - this.#onBurrowGone?.(); + this.#loseBurrow('burrow disconnected'); return; case 'error': // Fixed copy, for the reason the denial tables are: the text is the @@ -1315,7 +1435,11 @@ export class PocketClient { frame.id === established.connectionId && frame.step === 'transport' ) { - this.#onEstablishedFrame(established, frame.ct); + // Every rule about a frame on an authorized session — which path may + // carry it, and that its `ct` must decode — is the endpoint's, and it is + // created and dropped with `#established` (`docs/specs/remote-api.md` → + // Transport → "Direct path"). + established.direct.onRelayFrame(frame.ct); return; } const key = waiterKey(frame.kind, frame.id, frame.step); @@ -1326,21 +1450,28 @@ export class PocketClient { } /** - * One transport frame on an authorized session. **Any decrypt or framing - * failure ends it**: there is no resynchronization point in a stream cipher, - * so a poisoned session is burrow loss and the app must leave the wall. + * Decrypt one transport ciphertext, whichever path carried it, and answer the + * receipt for the endpoint to read a signal out of. **Any decrypt or framing + * failure ends the session**: there is no resynchronization point in a stream + * cipher, so a poisoned session is burrow loss and the app must leave the + * wall. */ - #onEstablishedFrame(established: EstablishedSession, ct: string): void { - let receipt; + #receiveOnSession( + established: EstablishedSession, + ciphertext: Uint8Array, + ): TransportReceipt | null { + let receipt: TransportReceipt; try { - receipt = established.session.receive(fromBase64Url(ct)); + receipt = established.session.receive(ciphertext); } catch { - this.#teardown('the end-to-end session failed', { notifyGone: true }); - return; + // The end-to-end session is what died, never the relay socket: it is to + // the *Relay*, and the app reconnects with a fresh handshake over it. + this.#loseBurrow('the end-to-end session failed'); + return null; } - // A keepalive is accepted and ignored; a control message on an established - // session is not part of protocol-v1 and says nothing this can act on. - if (receipt.kind !== 'app') return; + // A keepalive is accepted and ignored; a control message is one of the + // direct path's signals, which the endpoint reads off this receipt. + if (receipt.kind !== 'app') return receipt; for (const message of receipt.messages) { let payload: unknown; try { @@ -1350,6 +1481,7 @@ export class PocketClient { } this.#onMsg(payload); } + return receipt; } #onMsg(data: unknown): void { @@ -1388,6 +1520,11 @@ export class PocketClient { */ #teardown(reason: string, { notifyGone }: { notifyGone: boolean }): void { this.#ws = null; // never reuse a closed socket; openSocket() makes a fresh one + this.#endSession(reason, { notifyGone }); + } + + /** Everything a session's end does short of the socket; see {@link #teardown}. */ + #endSession(reason: string, { notifyGone }: { notifyGone: boolean }): void { this.#disposeCeremony(); this.#rejectAll(new Error(reason)); if (notifyGone) this.#onBurrowGone?.(); @@ -1396,6 +1533,15 @@ export class PocketClient { /** Erase every session's cipher state; a new ceremony starts from a handshake. */ #disposeCeremony(): void { this.#stopKeepalives(); + // The peer connection is this session's: every disposal path closes it, so + // none can outlive the session that authorized it. + const direct = this.#established?.direct ?? null; + direct?.dispose(); + // The endpoint's "announce only what changed" ends with the endpoint, while + // the subscriber outlives it and the next session's fresh endpoint announces + // nothing until something changes. Without this, the reason *this* session + // stayed relayed would sit in the indicator through the whole of the next. + if (direct?.relayCause) this.#onTransportChanged?.('relay', null); this.#connectedBurrowId = null; this.#established = null; } diff --git a/lib/src/remote/client/test-e2e-harness.ts b/lib/src/remote/client/test-e2e-harness.ts new file mode 100644 index 000000000..918b8edf4 --- /dev/null +++ b/lib/src/remote/client/test-e2e-harness.ts @@ -0,0 +1,449 @@ +/** + * The whole remote loop in memory — a real `PocketClient`, a real `TestRelay`, + * and a real `BurrowRuntime` — as a test drives it. + * + * Test-only, and shared for the reason `../test-relay.ts` and + * `../test-e2e-client.ts` are: every suite that runs a phone against a Burrow + * has to be driven against *the same* idea of what the account plane answers, + * and two copies would be two opinions about what a signed-in Client is. + * + * **No ceremony step is stubbed.** The Noise handshakes are the shipped suite, + * the presence proofs are real ES256 assertions over the shared challenge + * builder — verified by the same `verifyPresenceProof` a Burrow runs — and the + * outcomes are decrypted on the session that produced them. Only the browser + * and network edges are faked: `fetch`, `WebSocket`, WebAuthn's two calls, and + * the two IndexedDB stores. `/api/reauth/*` is faked faithfully rather than + * simulated: `begin` derives the challenge from the presented binding with the + * shared builder, exactly as the Relay does, so the assertion the authenticator + * produces is one the Burrow accepts. + */ + +import { + DEFAULT_PAIRING_TTL_MS, + REMOTE_EVENTS, + REMOTE_METHODS, + SELFHOST_ACCOUNT_ID, + mintNoiseStaticKeyPair, + presenceChallenge, + randomBase64Url, + toBase64Url, + utf8Encode, + type BurrowAclRecord, + type NoiseStaticKeyMaterial, + type PairingInvitation, + type PresenceBinding, + type TerminalDataEvent, +} from 'remote-lib-common'; + +import { PocketClient, type PocketClientDeps, type PocketStorage } from './pocket-client'; +import type { + KnownBurrowStore, + KnownBurrowV1, + PendingDeletionStore, + PendingDeliveryDeletionV1, +} from './pocket-db'; +import type { WebAuthnClient } from './webauthn'; +import { BurrowRuntime } from '../burrow/burrow-runtime'; +import type { BurrowEnrollment } from '../burrow/enrollment'; +import type { PendingPairing } from '../burrow/pairing-approval'; +import type { DirectPeerLike } from '../direct/direct-peer'; +import { FakeSocket } from '../test-fake-socket'; +import { createTestAuthenticator, pollFor, type TestAuthenticator } from '../test-e2e-client'; +import { createTestRelay, type TestRelay } from '../test-relay'; + +// --- Fakes ------------------------------------------------------------------ + +export const ORIGIN = 'https://pocket.example'; +export const RP_ID = 'pocket.example'; +export const BURROW_LABEL = 'Ned’s laptop'; +export const SESSION_TOKEN = 'tok-abc'; +/** What the stub Burrow streams on attach: a chunk whose two projections differ. */ +export const STREAMED_CHUNK: TerminalDataEvent = { + bytes: toBase64Url(utf8Encode('pre\x1b]1337;File=inline=1:AAAA\x07post')), + text: toBase64Url(utf8Encode('prepost')), +}; + +/** A base64url string usable where a real 32-byte secret goes. */ +export function secret(): string { + return randomBase64Url(32); +} + +export interface FetchCall { + url: string; + method: string; + headers: Record; + body: unknown; +} + +export type RouteHandler = ( + body: unknown, +) => { status?: number; json?: unknown } | Promise<{ status?: number; json?: unknown }>; + +/** A router-style fake `fetch` that records every call. */ +export function makeFetch( + routes: Record, + /** Answers a path no exact route claims; without one, an unknown path throws. */ + fallback?: (path: string, method: string) => { status?: number; json?: unknown } | undefined, +) { + const calls: FetchCall[] = []; + const fetch = (async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'POST'; + const headers = (init?.headers ?? {}) as Record; + const body = init?.body ? JSON.parse(init.body as string) : undefined; + calls.push({ url, method, headers, body }); + const path = new URL(url, 'http://test').pathname; + const handler = routes[path]; + const answered = handler ? await handler(body) : fallback?.(path, method); + if (!answered) throw new Error(`unexpected fetch: ${path}`); + const { status = 200, json } = answered; + return { + ok: status >= 200 && status < 300, + status, + json: async () => json ?? {}, + } as Response; + }) as unknown as typeof globalThis.fetch; + return { fetch, calls }; +} + +export function memoryStorage(): PocketStorage { + const passkeys = new Map(); + let pushEndpoint: string | null = null; + return { + getPasskeyPublicKey: (id) => passkeys.get(id) ?? null, + setPasskeyPublicKey: (id, pk) => void passkeys.set(id, pk), + forgetPasskeyPublicKey: (id) => void passkeys.delete(id), + knownCredentialIds: () => [...passkeys.keys()], + getRegisteredPushEndpoint: () => pushEndpoint, + setRegisteredPushEndpoint: (fingerprint) => void (pushEndpoint = fingerprint), + }; +} + +export interface MemoryKnownBurrows extends KnownBurrowStore { + readonly records: Map; +} + +export function memoryKnownBurrows(): MemoryKnownBurrows { + const records = new Map(); + return { + records, + get: async (burrowId) => records.get(burrowId) ?? null, + put: async (record) => void records.set(record.burrowId, record), + delete: async (burrowId) => void records.delete(burrowId), + list: async () => [...records.values()], + }; +} + +export interface MemoryPendingDeletions extends PendingDeletionStore { + readonly records: Map; +} + +export function memoryPendingDeletions(): MemoryPendingDeletions { + const records = new Map(); + return { + records, + put: async (record) => void records.set(`${record.burrowId}:${record.deliveryId}`, record), + delete: async (burrowId, deliveryId) => void records.delete(`${burrowId}:${deliveryId}`), + list: async () => [...records.values()], + }; +} + +/** + * Poll until `predicate` holds, so a Burrow awaiting WebCrypto can catch up. + * + * The default budget covers a ceremony step, which is a run of awaited + * WebCrypto calls and nothing else. A caller waiting on something with a + * network in it — a real ICE negotiation — names its own. + */ +export async function waitFor( + predicate: () => boolean, + what = 'a condition', + timeoutMs = 800, +): Promise { + const held = await pollFor(() => predicate() || undefined, timeoutMs); + if (!held) throw new Error(`timed out waiting for ${what}`); +} + +/** + * A peer factory that keeps what it builds, so a case can close or inspect the + * far end by hand. Both ends of a session get their own array. + */ +export function collect(into: T[], build: () => T): () => T { + return () => { + const peer = build(); + into.push(peer); + return peer; + }; +} + +export const CREDENTIAL_ID = 'cred-123'; +export const PASSKEY_PUBLIC_KEY = 'pk-spki-b64u'; + +export const AUTH_ROUTES: Record = { + '/api/setup/begin': () => ({ + json: { + challenge: secret(), + rpId: RP_ID, + accountId: SELFHOST_ACCOUNT_ID, + existingCredentialIds: [], + }, + }), + '/api/setup/finish': () => ({ + json: { accountId: SELFHOST_ACCOUNT_ID, credentialId: CREDENTIAL_ID }, + }), + '/api/setup/retire': () => ({ status: 204 }), + '/api/signin/begin': () => ({ json: { challenge: secret(), rpId: RP_ID } }), + '/api/signin/finish': () => ({ + json: { + sessionToken: SESSION_TOKEN, + accountId: SELFHOST_ACCOUNT_ID, + expiresAt: 1, + passkeyPublicKey: PASSKEY_PUBLIC_KEY, + }, + }), + '/api/burrows': () => ({ json: { burrows: [{ burrowId: 'h1', label: 'Laptop', online: true }] } }), +}; + +/** One socket's `e2e` frames on an established connection, in order. */ +function transportFrames(frames: Array>): Array> { + return frames.filter((frame) => frame.kind === 'connection' && frame.step === 'transport'); +} + +export interface E2eHarness { + client: PocketClient; + burrow: BurrowRuntime; + relay: TestRelay; + burrowId: string; + authenticator: TestAuthenticator; + noiseStatic: NoiseStaticKeyMaterial; + knownBurrows: MemoryKnownBurrows; + pendingDeletions: MemoryPendingDeletions; + approvals: PendingPairing[]; + savedAcl: BurrowAclRecord[]; + calls: FetchCall[]; + /** The harness's own `fetch`, for a second client on the same fake Relay. */ + fetch: typeof globalThis.fetch; + /** The Client's relay socket, once one is open — what a keepalive lands on. */ + clientSocket(): FakeSocket; + /** + * Client→relay `transport` frames on the established connection, which stop + * at this end's `direct-switch`. + */ + clientTransportFrames(): Array>; + /** The same, the other way: the Burrow's, which stop at its own switch. */ + burrowTransportFrames(): Array>; + /** One live invitation, as `setupQr` would mint it. */ + mintInvitation(): Promise; + /** Pair, approve, and connect — the whole ceremony every session case starts with. */ + connectPaired(): Promise; + /** Run a pairing and confirm it on the Burrow with the digits the phone showed. */ + pairAndApprove( + invitation: PairingInvitation, + options?: { code?: (shown: string) => string }, + ): Promise>>; +} + +/** + * A real Burrow, a real relay, and a real client — the whole loop in memory. + * + * `/api/reauth/*` is faked, but faithfully: `begin` derives the challenge from + * the presented binding with the shared builder, exactly as the Relay does, so + * the assertion the authenticator produces is one `verifyPresenceProof` + * accepts. Nothing else about the proof is simulated. + */ +export async function makeE2eHarness( + options: { + burrowId?: string; + knownBurrows?: MemoryKnownBurrows; + pendingDeletions?: MemoryPendingDeletions; + authenticator?: TestAuthenticator; + noiseStatic?: NoiseStaticKeyMaterial; + /** + * What the Burrow *announces* as its static, when that has to differ from the + * key it actually handshakes with. Nothing on the Burrow validates this + * string, so it is how a malformed pin reaches the Client at all. + */ + announcedStatic?: string; + loadAcl?: () => BurrowAclRecord[]; + now?: () => number; + /** Make every delivery-row deletion fail, as an offline phone's would. */ + pushDeleteFails?: boolean; + /** Extra `PocketClient` deps — the keepalive timer and visibility seams. */ + deps?: Partial; + /** How this Burrow builds a peer for the direct path; absent, it declines. */ + burrowDirect?: () => DirectPeerLike | null; + } = {}, +): Promise { + const burrowId = options.burrowId ?? randomBase64Url(16); + const authenticator = + options.authenticator ?? (await createTestAuthenticator({ rpId: RP_ID, origin: ORIGIN })); + const noiseStatic = options.noiseStatic ?? (await mintNoiseStaticKeyPair()); + const knownBurrows = options.knownBurrows ?? memoryKnownBurrows(); + const pendingDeletions = options.pendingDeletions ?? memoryPendingDeletions(); + const approvals: PendingPairing[] = []; + let savedAcl: BurrowAclRecord[] = []; + + const enrollment: BurrowEnrollment = { + relayUrl: ORIGIN, + burrowId, + burrowToken: 'burrow-tok', + origin: ORIGIN, + rpId: RP_ID, + label: BURROW_LABEL, + noiseStaticPrivateKey: noiseStatic.privateKeyPkcs8, + noiseStaticPublicKey: options.announcedStatic ?? noiseStatic.publicKey, + }; + const burrowSocket = new FakeSocket(); + const burrow = new BurrowRuntime({ + enrollment, + reconnect: false, + createWebSocket: () => burrowSocket, + ...(options.burrowDirect ? { createDirectPeer: options.burrowDirect } : {}), + loadAcl: options.loadAcl ?? (() => []), + saveAcl: (_burrowId, records) => { + savedAcl = [...records]; + }, + requestApproval: (pending) => approvals.push(pending), + dismissApproval: () => {}, + createSession: ({ send }) => ({ + // Enough protocol-v1 to prove the byte stream: every request is answered + // with its own `requestId`, which is what `hello` correlates on. + handle: (data) => { + const request = data as { requestId?: unknown; method?: unknown }; + if (typeof request.requestId !== 'string') return; + send({ + requestId: request.requestId, + ok: true, + result: { protocolVersion: 1, burrowId, grants: { input: true, layout: false } }, + }); + // An attach opens its stream under the request's own id, so one canned + // event proves the subscription path as well as the request one. + if (request.method === REMOTE_METHODS.surfaceAttach) { + send({ + subId: request.requestId, + event: REMOTE_EVENTS.terminalData, + data: STREAMED_CHUNK, + }); + } + }, + dispose: () => {}, + }), + }); + burrow.start(); + burrowSocket.open(); + const relay = createTestRelay({ burrowId, burrowSocket }); + + // The presence routes, derived exactly as the Relay derives them. + const nonces = new Map(); + const routes: Record = { + ...AUTH_ROUTES, + // The account's real passkey, so the key the proof presents is the one the + // authenticator actually signs with. + '/api/signin/finish': () => ({ + json: { + sessionToken: SESSION_TOKEN, + accountId: SELFHOST_ACCOUNT_ID, + expiresAt: 1, + passkeyPublicKey: authenticator.publicKey, + }, + }), + '/api/reauth/begin': async (body) => { + const binding = (body as { binding: PresenceBinding }).binding; + const relayNonce = secret(); + nonces.set(relayNonce, binding); + return { + json: { + challenge: await presenceChallenge(binding, relayNonce), + rpId: RP_ID, + relayNonce, + allowCredentials: [binding.passkeyCredentialId], + }, + }; + }, + '/api/reauth/finish': (body) => { + const { relayNonce } = body as { relayNonce: string }; + if (!nonces.delete(relayNonce)) return { status: 400, json: { error: 'unknown nonce' } }; + return { json: { verifiedAt: 1 } }; + }, + }; + // The delivery ids a Burrow mints are random, so the deletion route is matched + // by shape rather than by an exact path. + const { fetch, calls } = makeFetch(routes, (path, method) => { + if (method !== 'DELETE' || !path.startsWith('/api/push/subscriptions/')) return undefined; + return options.pushDeleteFails ? { status: 503, json: { error: 'down' } } : { status: 204 }; + }); + + const storage = memoryStorage(); + const webauthn: WebAuthnClient = { + async registerPasskey() { + return { + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: 'create-client-data', + }; + }, + // The real thing: a signature this Burrow's own verifier accepts. + getAssertion: (challenge) => authenticator.assert(challenge, ORIGIN), + }; + let clientSocket: FakeSocket | null = null; + const requireClientSocket = (): FakeSocket => { + if (!clientSocket) throw new Error('the Client has not opened a relay socket'); + return clientSocket; + }; + const client = new PocketClient({ + wsBase: 'ws://test', + fetch, + webauthn, + createWebSocket: () => (clientSocket = relay.openClientSocket()), + knownBurrows, + pendingDeletions, + storage, + ...(options.now ? { now: options.now } : {}), + ...options.deps, + }); + // Sign-in caches the asserted passkey's public key and names the credential + // every presence proof is built from, exactly as it does in the app. + await client.signin(); + + const mintInvitation: E2eHarness['mintInvitation'] = () => + burrow.mintInvitation(secret(), Date.now() + DEFAULT_PAIRING_TTL_MS); + const pairAndApprove: E2eHarness['pairAndApprove'] = async (invitation, { code } = {}) => { + // Counted from here: a harness that pairs twice must confirm the *new* + // request rather than re-answering the one still in the log. + const before = approvals.length; + let shown: string | null = null; + const pairing = client.pair(invitation, 'iPhone Safari', (value) => { + shown = value; + }); + await waitFor(() => approvals.length > before, 'the Burrow to surface an approval'); + const pending = approvals[approvals.length - 1]!; + pending.approve(code ? code(shown!) : shown!); + return await pairing; + }; + + return { + client, + burrow, + relay, + burrowId, + authenticator, + noiseStatic, + knownBurrows, + pendingDeletions, + approvals, + get savedAcl() { + return savedAcl; + }, + calls, + fetch, + clientSocket: requireClientSocket, + clientTransportFrames: () => transportFrames(requireClientSocket().frames('e2e')), + burrowTransportFrames: () => transportFrames(relay.burrowSocket.frames('e2e')), + mintInvitation, + pairAndApprove, + async connectPaired() { + await pairAndApprove(await mintInvitation()); + const outcome = await client.connect(burrowId); + if (!outcome.ok) throw new Error(`the connect was denied: ${outcome.message}`); + }, + }; +} diff --git a/lib/src/remote/direct/direct-endpoint.test.ts b/lib/src/remote/direct/direct-endpoint.test.ts new file mode 100644 index 000000000..6ad1f929d --- /dev/null +++ b/lib/src/remote/direct/direct-endpoint.test.ts @@ -0,0 +1,585 @@ +/** + * The cutover policy both ends run (`docs/specs/remote-api.md` -> Transport -> + * "Direct path"), driven as two endpoints over the linked fake pair. + * + * This is where the rules live now: what an attempt may do, what each signal + * means to each role, and what a channel event costs the session. The suites in + * `../client/pocket-client.test.ts` and `../burrow/burrow-runtime.test.ts` keep + * only the cases that prove the wiring — that the signals really are control + * messages on a real session, and that a loss really disposes what the runtime + * holds. + * + * Signals ride a stand-in for the relay here: they are plain objects, delivered + * to the far endpoint, with one direction holdable so a case can reproduce the + * race the holding queue exists for. + */ + +import { describe, expect, it } from 'vitest'; +import { + DIRECT_ANSWER_TIMEOUT_MS, + DIRECT_GATHER_TIMEOUT_MS, + DIRECT_HANDOFF_TIMEOUT_MS, + DIRECT_SETUP_TIMEOUT_MS, + MAX_DIRECT_PENDING_FRAMES, + toBase64Url, + type DirectPath, + type DirectRelayCause, + type DirectSignalV1, +} from 'remote-lib-common'; + +import { DirectEndpoint } from './direct-endpoint'; +import { + FakeDirectNetwork, + flushMicrotasks, + type FakeDirectNetworkOptions, + type FakePeer, +} from './test-fake-peer'; +import { fakeTimers } from '../test-timers'; + +interface Side { + endpoint: DirectEndpoint; + /** Every peer connection this side's factory built. */ + readonly peers: FakePeer[]; + /** Every ciphertext this side decrypted, in the order it did. */ + readonly received: Uint8Array[]; + /** Every reason this side's session was declared unrecoverable. */ + readonly fatals: string[]; + /** Every path change announced. */ + readonly paths: DirectPath[]; + /** Every cause announced beside a path; see `DirectEndpoint.relayCause`. */ + readonly causes: Array; + /** Every signal this side put on the relay. */ + readonly sent: DirectSignalV1[]; + /** Every transport ciphertext this side put on the relay. */ + readonly relayed: Uint8Array[]; + /** What `isCurrent()` answers; a promotion or teardown flips it. */ + live: boolean; + /** Whether the session can still encrypt a signal. */ + sendable: boolean; + /** Hold this side's outbound signals instead of delivering them. */ + hold(): void; + /** Deliver everything held, in order. */ + release(): void; +} + +interface Options extends FakeDirectNetworkOptions { + /** Give the offerer no peer factory, as a browser without WebRTC has. */ + offererHasPeer?: boolean; + /** Give the answerer none, as the VS Code host has. */ + answererHasPeer?: boolean; +} + +/** Both endpoints of one session, linked by a relay a case can hold. */ +function pair(options: Options = {}) { + const { offererHasPeer = true, answererHasPeer = true, ...network } = options; + const fake = new FakeDirectNetwork(network); + const timers = fakeTimers(); + const sides = new Map<'offerer' | 'answerer', Side>(); + + const build = (role: 'offerer' | 'answerer', hasPeer: boolean): Side => { + const peers: FakePeer[] = []; + const queued: DirectSignalV1[] = []; + const side: Side = { + peers, + received: [], + fatals: [], + relayed: [], + paths: [], + causes: [], + sent: [], + live: true, + sendable: true, + hold: () => void (holding = true), + release: () => { + holding = false; + for (const signal of queued.splice(0)) deliver(signal); + }, + endpoint: undefined as unknown as DirectEndpoint, + }; + let holding = false; + const deliver = (signal: DirectSignalV1): void => { + sides.get(role === 'offerer' ? 'answerer' : 'offerer')?.endpoint.onSignal({ ...signal }); + }; + side.endpoint = new DirectEndpoint(role, { + createPeer: hasPeer + ? () => { + const peer = role === 'offerer' ? fake.createOfferer() : fake.createAnswerer(); + peers.push(peer); + return peer; + } + : null, + sendSignal: (signal) => { + if (!side.sendable) return false; + side.sent.push(signal); + if (holding) queued.push(signal); + else deliver(signal); + return true; + }, + sendRelay: (ciphertext) => void side.relayed.push(ciphertext), + receive: (ciphertext) => { + side.received.push(ciphertext); + // Signals ride the stand-in relay here rather than a real session, so + // nothing a frame decrypts to is a control message. + return { kind: 'keepalive' } as const; + }, + fatal: (reason) => void side.fatals.push(reason), + isCurrent: () => side.live, + onTransportChanged: (path, cause) => { + side.paths.push(path); + side.causes.push(cause); + }, + setTimer: timers.setTimer, + }); + sides.set(role, side); + return side; + }; + + const offerer = build('offerer', offererHasPeer); + const answerer = build('answerer', answererHasPeer); + return { fake, timers, offerer, answerer }; +} + +/** Offer, answer, and let the channel open at both ends. */ +async function cutover(run: ReturnType): Promise { + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + await flushMicrotasks(); +} + +const frame = (n: number, size = 4) => new Uint8Array(size).fill(n); + +describe('DirectEndpoint', () => { + it('offers, answers, and switches both directions onto the channel', async () => { + const run = pair(); + + await cutover(run); + + expect(run.offerer.sent.map((s) => s.t)).toEqual(['direct-offer', 'direct-switch']); + expect(run.answerer.sent.map((s) => s.t)).toEqual(['direct-answer', 'direct-switch']); + expect(run.offerer.endpoint.path).toBe('direct'); + expect(run.answerer.endpoint.path).toBe('direct'); + // Announced once, and only once both directions had left the relay. + expect(run.offerer.paths).toEqual(['direct']); + expect(run.offerer.fatals).toEqual([]); + expect(run.answerer.fatals).toEqual([]); + }); + + it('spends the session’s one attempt, whichever end asks twice', async () => { + const run = pair(); + + await cutover(run); + const offer = run.offerer.sent[0]!; + if (offer.t !== 'direct-offer') throw new Error(`expected an offer, got ${offer.t}`); + await run.offerer.endpoint.offer(); + run.answerer.endpoint.onSignal({ ...offer }); + + // A second offer allocates nothing and is not answered — not even with a + // decline — at either end. + expect(run.offerer.peers).toHaveLength(1); + expect(run.answerer.peers).toHaveLength(1); + expect(run.offerer.sent.map((s) => s.t)).toEqual(['direct-offer', 'direct-switch']); + expect(run.answerer.sent.map((s) => s.t)).toEqual(['direct-answer', 'direct-switch']); + }); + + it('declines an offer it has no way to answer, and stays relayed', async () => { + const run = pair({ answererHasPeer: false }); + + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + + expect(run.answerer.sent.map((s) => s.t)).toEqual(['direct-decline']); + // The decline abandons the offerer's attempt: its peer is closed and the + // session is exactly as relayed as it was. + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.offerer.endpoint.path).toBe('relay'); + expect(run.offerer.fatals).toEqual([]); + }); + + it('never offers from a runtime with no peer connection', async () => { + const run = pair({ offererHasPeer: false }); + + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + + expect(run.offerer.sent).toEqual([]); + expect(run.answerer.peers).toEqual([]); + // And the attempt is spent: a signal arriving later cannot start one. + expect(run.offerer.endpoint.path).toBe('relay'); + }); + + it('skips a description too large to travel inside the session', async () => { + const offering = pair({ oversize: 'offer' }); + await offering.offerer.endpoint.offer(); + expect(offering.offerer.sent).toEqual([]); + + const answering = pair({ oversize: 'answer' }); + await answering.offerer.endpoint.offer(); + await flushMicrotasks(); + expect(answering.answerer.sent.map((s) => s.t)).toEqual(['direct-decline']); + }); + + it('declines rather than leaving the offerer to wait, when its channel is refused', async () => { + const run = pair({ channel: 'unordered', channelSide: 'answerer' }); + + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + + // The refusal reports from inside `answer()`, before there is any + // description to send — the offerer still has to hear about it. + expect(run.answerer.sent.map((s) => s.t)).toEqual(['direct-decline']); + // And the answerer's setup deadline, armed inside `answer()`, went with it. + expect(run.timers.live).toEqual([]); + }); + + /** + * A decline is a message on the relay, so it only means anything while there + * is still a relay to send it on. A peer that put its own `direct-switch` + * across before this end had answered has already taken the session with it — + * `DirectCutover` accepts that switch while the attempt is merely + * `attempting` — and a decline sent after would reach a Client that reads it + * as a refusal rather than as the channel that never opened. + */ + it('never declines onto a session its own give-up has already ended', async () => { + // Gathering is held open so the answerer is still suspended inside + // `answer()` when the switch lands. + const run = pair({ oversize: 'answer', gathering: 'pending' }); + const offering = run.offerer.endpoint.offer(); + await flushMicrotasks(); + run.timers.fireAt(DIRECT_GATHER_TIMEOUT_MS); + await offering; + await flushMicrotasks(); + + // The peer switches before it has been answered, and only then does this + // end find it has no description it can send. + run.answerer.endpoint.onSignal({ v: 1, t: 'direct-switch' }); + run.timers.fireAt(DIRECT_GATHER_TIMEOUT_MS); + await flushMicrotasks(); + + expect(run.answerer.sent.map((s) => s.t)).toEqual([]); + expect(run.answerer.fatals).toHaveLength(1); + }); + + it('abandons the attempt when the session cannot carry a signal', async () => { + const run = pair(); + run.offerer.sendable = false; + + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.answerer.peers).toEqual([]); + expect(run.offerer.fatals).toEqual([]); + }); + + it('closes a peer whose session was replaced while it described itself', async () => { + const run = pair(); + // Flipped while `offer()` is awaiting its description, which is exactly what + // a replacement promotion or a teardown does to the session under it. + const offering = run.offerer.endpoint.offer(); + run.offerer.live = false; + await offering; + + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.offerer.sent).toEqual([]); + }); + + it('ignores every signal that is the other role’s to send, and every unknown shape', async () => { + const run = pair(); + await cutover(run); + const offererSent = run.offerer.sent.length; + + // The compatibility rule the whole staging rests on: a control shape this + // peer does not know leaves the session up. + run.offerer.endpoint.onSignal({ v: 2, t: 'direct-decline' }); + run.offerer.endpoint.onSignal({ t: 'something-else' }); + run.offerer.endpoint.onSignal({ v: 1, t: 'direct-offer', sdp: 'v=0\r\n' }); + run.answerer.endpoint.onSignal({ v: 1, t: 'direct-answer', sdp: 'v=0\r\n' }); + run.answerer.endpoint.onSignal({ v: 1, t: 'direct-decline' }); + + expect(run.offerer.sent).toHaveLength(offererSent); + expect(run.offerer.fatals).toEqual([]); + expect(run.answerer.fatals).toEqual([]); + }); + + it('stays relayed when the channel never opens', async () => { + const run = pair({ opening: 'never' }); + await cutover(run); + + run.timers.fireAt(DIRECT_SETUP_TIMEOUT_MS); + + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.offerer.endpoint.path).toBe('relay'); + // Never switched, so this is an abandoned attempt rather than burrow loss. + expect(run.offerer.fatals).toEqual([]); + run.offerer.endpoint.send(frame(1)); + expect(run.offerer.relayed).toEqual([frame(1)]); + }); + + it('ends the session when the channel dies after the switch', async () => { + const run = pair(); + await cutover(run); + + run.fake.dropChannels(); + + expect(run.offerer.fatals).toEqual(['the direct channel closed']); + expect(run.answerer.fatals).toEqual(['the direct channel closed']); + }); + + it('ends the session on a channel message this protocol has no reading for', async () => { + const run = pair(); + await cutover(run); + + run.fake.offererChannel!.receiveRaw('a text frame'); + + expect(run.offerer.fatals).toEqual(['a direct channel message was not binary']); + }); + + it('ignores a channel violation once its session is no longer the live one', async () => { + const run = pair(); + await cutover(run); + // What a replacement promotion does to the session under an endpoint: the + // channel is still there and still reporting, and none of it is this + // session's business any more. + run.offerer.live = false; + + run.fake.offererChannel!.receiveRaw('a text frame'); + + expect(run.offerer.fatals).toEqual([]); + }); + + it('holds channel frames until the peer’s switch, then drains them in order', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + + // The race the queue exists for: the answerer's frames overtake the + // `direct-switch` that precedes them on the relay. + run.answerer.hold(); + run.fake.openChannels(); + expect(run.offerer.endpoint.path).toBe('relay'); + + run.answerer.endpoint.send(frame(1)); + run.answerer.endpoint.send(frame(2)); + await flushMicrotasks(); + expect(run.offerer.received).toEqual([]); + + run.answerer.release(); + + expect(run.offerer.received).toEqual([frame(1), frame(2)]); + expect(run.offerer.endpoint.path).toBe('direct'); + }); + + it('ends the session when held frames outrun the queue', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + run.answerer.hold(); + run.fake.openChannels(); + + for (let i = 0; i <= MAX_DIRECT_PENDING_FRAMES; i += 1) run.answerer.endpoint.send(frame(i)); + await flushMicrotasks(); + + expect(run.offerer.fatals).toEqual(['the direct path outran what can be held in order']); + expect(run.offerer.received).toEqual([]); + }); + + /** + * The two deadlines are armed on different clocks — the offerer's at `offer()` + * and the answerer's a relay hop later, at `answer()` — so a channel that + * comes up near the end of the budget must not have the answerer opening, + * switching, and landing its `direct-switch` on an offerer that has just + * abandoned. Giving the answerer the shorter budget makes its channel closing + * the event that reaches the offerer, and both ends abandon. + */ + it('gives the answerer the deadline that fires first, so a slow channel only abandons', async () => { + const run = pair({ opening: 'never' }); + await cutover(run); + + run.timers.fireAt(DIRECT_ANSWER_TIMEOUT_MS); + await flushMicrotasks(); + + expect(run.answerer.peers[0]!.closed).toBe(true); + // The answerer's channel closing is what the offerer sees, while it is + // still unswitched and can give the attempt up. + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.offerer.fatals).toEqual([]); + expect(run.answerer.fatals).toEqual([]); + expect(run.offerer.endpoint.path).toBe('relay'); + expect(run.answerer.endpoint.path).toBe('relay'); + // And the offerer's own budget is gone with its peer, so nothing is left + // armed to fire on a session that has moved on. + expect(run.timers.live).toEqual([]); + }); + + /** + * From its own switch this end sends only on the channel, so a peer that + * never switches back leaves it talking into one nothing reads. Without a + * deadline of its own the wait would end only when the held frames overran + * their bound — a function of how chatty the session happens to be. + */ + it('ends the session when the peer never follows onto the channel', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + + // The answerer's own `direct-switch` never leaves it, so this end is + // outbound-direct with the relay still carrying the other half. + run.answerer.hold(); + run.fake.openChannels(); + expect(run.offerer.endpoint.path).toBe('relay'); + expect(run.offerer.fatals).toEqual([]); + + run.timers.fireAt(DIRECT_HANDOFF_TIMEOUT_MS); + + expect(run.offerer.fatals).toEqual(['the peer did not follow onto the direct path']); + }); + + it('leaves nothing armed once the peer has followed', async () => { + const run = pair(); + + await cutover(run); + + // Both setup budgets are gone with the opens, and both handoff deadlines + // with the switches that answered them. + expect(run.timers.live).toEqual([]); + }); + + /** + * **A closed set, never the failure text.** The strings an attempt gives up + * with include a runtime's own exception message; what the peer is told is + * which of the three answers a person can act on it was. + */ + describe('why a session stayed relayed', () => { + it('says an attempt was never possible, beside the unchanged path', async () => { + const run = pair({ offererHasPeer: false }); + + await run.offerer.endpoint.offer(); + + expect(run.offerer.endpoint.relayCause).toBe('unsupported'); + expect(run.offerer.causes).toEqual(['unsupported']); + // The path never changed; the announcement is the cause alone. + expect(run.offerer.paths).toEqual(['relay']); + }); + + it('tells the offerer it was declined, and the answerer why it declined', async () => { + const run = pair({ answererHasPeer: false }); + + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + + expect(run.offerer.endpoint.relayCause).toBe('declined'); + expect(run.answerer.endpoint.relayCause).toBe('unsupported'); + }); + + it('calls a channel that never opened a failure, not a refusal', async () => { + const run = pair({ opening: 'never' }); + await cutover(run); + + run.timers.fireAt(DIRECT_ANSWER_TIMEOUT_MS); + await flushMicrotasks(); + + expect(run.offerer.endpoint.relayCause).toBe('failed'); + expect(run.answerer.endpoint.relayCause).toBe('failed'); + }); + + it('has nothing to say once the session is on the channel', async () => { + const run = pair(); + + await cutover(run); + + expect(run.offerer.endpoint.relayCause).toBeNull(); + expect(run.answerer.endpoint.relayCause).toBeNull(); + expect(run.offerer.causes).toEqual([null]); + }); + }); + + it('ends the session when the peer switches onto a channel this end abandoned', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + + run.timers.fireAt(DIRECT_SETUP_TIMEOUT_MS); + expect(run.offerer.fatals).toEqual([]); + // And only now does the peer's channel come up, so its switch lands on an + // end that has already closed its own. + run.offerer.endpoint.onSignal({ v: 1, t: 'direct-switch' }); + + expect(run.offerer.fatals).toEqual([ + 'the peer moved to a direct path this end had abandoned', + ]); + }); + + it('refuses a relay transport frame once the peer has switched', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + + // Before the peer's switch the relay is still the path it sends on. + run.offerer.endpoint.onRelayFrame(toBase64Url(frame(1))); + expect(run.offerer.received).toEqual([frame(1)]); + + run.fake.openChannels(); + run.offerer.endpoint.onRelayFrame(toBase64Url(frame(2))); + + expect(run.offerer.fatals).toEqual(['a relay frame arrived after the direct switch']); + // Refused before any decrypt: the ciphertext is never even looked at. + expect(run.offerer.received).toEqual([frame(1)]); + }); + + /** + * The wire guard bounds a `ct`'s alphabet and length, not its padding, so a + * peer can put a well-shaped envelope on the relay whose ciphertext will not + * decode. That is the session's failure, not an exception out of a socket + * handler. + */ + it('ends the session on a relay frame whose ciphertext will not decode', async () => { + const run = pair({ opening: 'never' }); + await cutover(run); + + run.offerer.endpoint.onRelayFrame('AB'); + + expect(run.offerer.fatals).toEqual(['a relay frame was not a ciphertext']); + expect(run.offerer.received).toEqual([]); + }); + + it('routes a ciphertext onto the channel only after this end has switched', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + // Nothing on the channel yet: it goes on the relay. + run.offerer.endpoint.send(frame(1)); + expect(run.offerer.relayed).toEqual([frame(1)]); + + run.fake.openChannels(); + run.offerer.endpoint.send(frame(2)); + // Switched, so the channel is the only path left for it. + expect(run.offerer.relayed).toEqual([frame(1)]); + await flushMicrotasks(); + expect(run.answerer.received).toEqual([frame(2)]); + }); + + it('ends the session when a switched channel refuses a send', async () => { + const run = pair(); + await cutover(run); + // Closed under the endpoint, which a radio gap does between two sends. + run.fake.offererChannel!.close(); + + run.offerer.endpoint.send(frame(1)); + expect(run.offerer.fatals).toEqual(['the direct channel refused a message']); + // And never onto the relay of a session the refusal has just ended. + expect(run.offerer.relayed).toEqual([]); + }); + + it('disposes idempotently, closing the peer and reporting the relay', async () => { + const run = pair(); + await cutover(run); + + run.offerer.endpoint.dispose(); + run.offerer.endpoint.dispose(); + + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.offerer.endpoint.path).toBe('relay'); + expect(run.offerer.paths).toEqual(['direct', 'relay']); + // Inert afterwards: nothing it is told does anything to a dead session, and + // a send is dropped rather than falling back onto the relay. + run.offerer.endpoint.send(frame(1)); + expect(run.fake.offererChannel!.sent).toEqual([]); + expect(run.offerer.relayed).toEqual([]); + run.offerer.endpoint.onSignal({ v: 1, t: 'direct-switch' }); + expect(run.offerer.fatals).toEqual([]); + }); +}); diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts new file mode 100644 index 000000000..4e99f4783 --- /dev/null +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -0,0 +1,452 @@ +/** + * One authorized session's direct path, as both ends run it + * (`docs/specs/remote-api.md` -> Transport -> "Direct path"). + * + * **One policy, two ends.** The Client and the Burrow differ in exactly four + * things — who offers, how a control message is put on the relay, how a + * ciphertext is decrypted, and what "the session is over" does — so those are + * injected ({@link DirectEndpointDeps}) and everything else is here: the + * attempt, the peer, the cutover, and every rule about what a channel event + * means. Two copies of that were two chances to disagree about a switch. + * + * One per authorized session, created at promotion and disposed with it, so a + * peer connection can neither precede authorization nor outlive it. + */ + +import { + DIRECT_HANDOFF_TIMEOUT_MS, + DirectCutover, + fromBase64Url, + isDirectSignalV1, + type DirectPath, + type DirectRelayCause, + type DirectSignalV1, + type TransportReceipt, +} from 'remote-lib-common'; + +import { DirectPeer, type DirectPeerFactory } from './direct-peer'; +import { realTimer, type RemoteTimer } from '../ws'; + +/** + * Which half of the negotiation this end plays. The Client offers and the + * Burrow answers, and each ignores the signals that are the other's to send. + */ +export type DirectRole = 'offerer' | 'answerer'; + +export interface DirectEndpointDeps { + /** How this runtime builds a peer connection, or `null` where it has none. */ + readonly createPeer: DirectPeerFactory | null; + /** + * Encrypt one signal as a control message and put it on the relay path; + * `false` if the session could not send it. + */ + sendSignal(signal: DirectSignalV1): boolean; + /** + * Put one transport ciphertext on the relay path — the half of + * {@link DirectEndpoint.send}'s routing that carries a session until it has + * switched, and every session that never does. + */ + sendRelay(ciphertext: Uint8Array): void; + /** + * Decrypt one transport ciphertext, whichever path carried it, process + * everything that is not a signal, and answer the receipt. `null` where the + * decrypt failed — a poisoned session, which the owner has already disposed. + * + * A signal is left in the receipt rather than dispatched by the owner: the + * endpoint is the only thing that knows what one means. + */ + receive(ciphertext: Uint8Array): TransportReceipt | null; + /** + * The session is unrecoverable: the endpoint's owner disposes it (the Burrow + * through `#disposeEstablished`, the Client through `#loseBurrow`). + */ + fatal(reason: string): void; + /** + * Whether this endpoint's session is still the live one. Both ends re-check + * it after every await, because a promotion or a teardown can replace the + * session while a description is being built. + */ + isCurrent(): boolean; + /** + * Notified whenever {@link DirectEndpoint.path} or {@link + * DirectEndpoint.relayCause} changes; the Client's indicator. + */ + onTransportChanged?(path: DirectPath, cause: DirectRelayCause | null): void; + /** Every deadline the peer arms; see {@link RemoteTimer}. */ + readonly setTimer?: RemoteTimer; +} + +export class DirectEndpoint { + readonly #role: DirectRole; + readonly #deps: DirectEndpointDeps; + readonly #cutover = new DirectCutover(); + readonly #setTimer: RemoteTimer; + #peer: DirectPeer | null = null; + #disposed = false; + #cause: DirectRelayCause | null = null; + /** Whether an offer has been taken up and not yet answered or declined. */ + #owesAnswer = false; + /** Cancels the wait for the peer's switch; see {@link #settle}. */ + #cancelHandoff: (() => void) | null = null; + /** The last pair announced, so an unchanged one is not announced twice. */ + #announced: { path: DirectPath; cause: DirectRelayCause | null } = { path: 'relay', cause: null }; + + constructor(role: DirectRole, deps: DirectEndpointDeps) { + this.#role = role; + this.#deps = deps; + this.#setTimer = deps.setTimer ?? realTimer; + } + + /** What carries this session; `direct` only once **both** directions have switched. */ + get path(): DirectPath { + return this.#disposed ? 'relay' : this.#cutover.path; + } + + /** + * Whether the session this endpoint carries is over. **Its owner's one test + * for a session that is no longer live**: every route that replaces or tears + * one down disposes its endpoint first, so a send loop mid-message needs no + * second look at where the session was registered. + */ + get disposed(): boolean { + return this.#disposed; + } + + /** + * Why the session is still relayed, or `null` where there is nothing to say. + * Set by every route that gives an attempt up, so a session that stayed + * relayed can say which of the three {@link DirectRelayCause}s it was. + */ + get relayCause(): DirectRelayCause | null { + return this.#cause; + } + + /** + * Offer a direct path, which is the offerer's alone to do: **once per session, + * never retried**. The whole description travels inside the session, so the + * Relay never sees an SDP, a candidate, or that a direct path exists. + * + * Every failure is silent and terminal for the attempt alone — no factory, a + * description too large to fit one control message, a negotiation that threw — + * and the session keeps running on the relay. + */ + async offer(): Promise { + if (this.#role !== 'offerer' || !this.#cutover.begin()) return; + const peer = this.#build(); + if (!peer) { + this.#giveUp('this runtime builds no peer connection', 'unsupported'); + return; + } + const sdp = await peer.offer(); + if (!this.#stillOurs(peer)) return; + if (sdp === null || !this.#deps.sendSignal({ v: 1, t: 'direct-offer', sdp })) this.#giveUp(); + } + + /** + * One decrypted control message on this session. **An unknown control shape is + * ignored, never a session failure**, which is what lets a peer without this + * stack simply stay relayed — as is a signal that is the other role's to send. + */ + onSignal(value: Record): void { + if (!isDirectSignalV1(value) || !this.#alive()) return; + switch (value.t) { + case 'direct-offer': + if (this.#role === 'answerer') void this.#answer(value.sdp); + return; + case 'direct-answer': + if (this.#role === 'offerer') void this.#peer?.acceptAnswer(value.sdp); + return; + case 'direct-decline': + if (this.#role === 'offerer') this.#giveUp('the peer declined a direct path', 'declined'); + return; + case 'direct-switch': { + const outcome = this.#cutover.onSwitchDecrypted(); + if (outcome.kind === 'fatal') { + this.#deps.fatal('the peer moved to a direct path this end had abandoned'); + return; + } + this.#settle(); + // In arrival order, through the same decrypt path the relay's frames + // take: what was held is exactly what was sent after the switch. + for (const frame of outcome.frames) { + if (!this.#alive()) return; + this.#deliver(frame); + } + return; + } + } + } + + /** + * One transport frame arriving on the relay, as the envelope carried it. + * **Both ends read a relay frame through here**, so every rule about what may + * arrive on which path is stated once. + * + * **After the peer has switched there is nothing left for it to send there**, + * so a frame that arrives anyway is a peer whose two paths this end can no + * longer keep in order. **A `ct` that will not decode ends the session too**: + * the wire guard bounds the alphabet and the length, not the padding, so the + * decode belongs inside the session's own failure path rather than thrown out + * of a socket handler. + */ + onRelayFrame(ct: string): void { + if (!this.#alive()) return; + if (this.#cutover.onRelayTransport() !== 'process') { + this.#deps.fatal('a relay frame arrived after the direct switch'); + return; + } + let ciphertext: Uint8Array; + try { + ciphertext = fromBase64Url(ct); + } catch { + this.#deps.fatal('a relay frame was not a ciphertext'); + return; + } + this.#deliver(ciphertext); + } + + /** + * One transport ciphertext onto whichever path this session has. **Both ends + * send every byte of an established session through here**, so "after the + * switch, nothing on the relay" is one rule in one place rather than one each + * caller keeps — the mirror of {@link onRelayFrame} on the way in. + * + * **A disposed endpoint drops it.** A refused send disposes the session + * synchronously, and the caller's loop is mid-message: the remaining chunks + * belong nowhere, least of all on the relay of a session that is over. + */ + send(ciphertext: Uint8Array): void { + if (this.#disposed) return; + if (this.#cutover.outbound !== 'direct') { + this.#deps.sendRelay(ciphertext); + return; + } + // Switched, so the channel is the only path this may take — there is no + // relay left to re-route onto. A peer that cannot carry it says why through + // `onClosed`, and this end's rule about a channel lost after a switch does + // the rest. + this.#peer?.send(ciphertext); + } + + /** + * Close the peer and release what the cutover held. Idempotent, and called on + * every path that ends the session, so no peer connection outlives the session + * that authorized it. + */ + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#peer?.close(); + this.#peer = null; + this.#cutover.clear(); + this.#settle(); + } + + // --- Internals ------------------------------------------------------------- + + /** + * Answer one offer, or decline it. A runtime with no peer factory — or one + * whose answer would not fit a signal — declines rather than leaving the + * offerer waiting out the setup deadline. + */ + async #answer(offerSdp: string): Promise { + if (!this.#cutover.begin()) return; + this.#owesAnswer = true; + const peer = this.#build(); + if (!peer) { + this.#decline('this runtime builds no peer connection', 'unsupported'); + return; + } + const sdp = await peer.answer(offerSdp); + if (!this.#stillOurs(peer)) return; + if (sdp === null) { + this.#decline('this end could not describe a direct connection'); + return; + } + this.#owesAnswer = false; + if (!this.#deps.sendSignal({ v: 1, t: 'direct-answer', sdp })) this.#giveUp(); + } + + /** + * Whether `peer` is still this endpoint's own live attempt. A promotion or a + * teardown can replace the session while a description is being built, and a + * peer left over from one is not this one's — it is closed here. + */ + #stillOurs(peer: DirectPeer): boolean { + if (this.#alive() && this.#peer === peer) return true; + peer.close(); + return false; + } + + /** + * Give the attempt up and say so, rather than leaving the offerer to wait out + * its setup deadline for a channel that is never coming. + * + * **Only where the attempt was merely abandoned.** A peer that put its own + * `direct-switch` on the relay before this end had answered leaves + * {@link #giveUp} taking its fatal branch instead — and a decline encrypted + * onto a session its owner has just disposed would reach a Client that reads + * it as a refusal rather than as the channel that never opened. + */ + #decline(reason: string, cause: DirectRelayCause = 'failed'): void { + this.#owesAnswer = false; + if (this.#giveUp(reason, cause)) this.#deps.sendSignal({ v: 1, t: 'direct-decline' }); + } + + /** This attempt's peer, wired to the four channel events, or null if there is none. */ + #build(): DirectPeer | null { + const factory = this.#deps.createPeer; + if (!factory) return null; + let connection; + try { + connection = factory(); + } catch (error) { + console.warn('[direct] could not build a peer connection', error); + return null; + } + if (!connection) return null; + this.#peer = new DirectPeer({ + peer: connection, + setTimer: this.#setTimer, + handlers: { + onOpen: () => this.#onOpen(), + onFrame: (frame) => this.#onFrame(frame), + onClosed: (reason) => this.#onClosed(reason), + onViolation: (reason) => this.#onViolation(reason), + }, + }); + return this.#peer; + } + + /** + * The channel is open. **The `direct-switch` is this end's last message on the + * relay** — everything after it goes on the channel, which is what keeps order + * per direction. + */ + #onOpen(): void { + if (!this.#alive()) return; + if (!this.#deps.sendSignal({ v: 1, t: 'direct-switch' })) { + this.#giveUp(); + return; + } + this.#cutover.switchOutbound(); + this.#cause = null; + this.#settle(); + } + + /** One frame off the channel: processed, held until the peer's switch, or fatal. */ + #onFrame(frame: Uint8Array): void { + if (!this.#alive()) return; + switch (this.#cutover.onChannelFrame(frame)) { + case 'process': + this.#deliver(frame); + return; + case 'held': + return; + case 'overflow': + this.#deps.fatal('the direct path outran what can be held in order'); + return; + } + } + + /** + * One transport ciphertext into the session, whichever path carried it. **A + * control message on an established session is one of this path's signals**, + * and reading it here is what keeps the two ends' receive paths identical. + */ + #deliver(ciphertext: Uint8Array): void { + const receipt = this.#deps.receive(ciphertext); + if (receipt?.kind === 'control') this.onSignal(receipt.value); + } + + /** + * The channel went away. **An answerer that has not answered yet owes the + * offerer a decline**: the peer has heard nothing back and would otherwise + * wait out its whole setup budget for a channel that is never coming. The + * route matters because this can run *inside* `peer.answer()` — a channel + * refused on adopt reports here before the description is even built — and + * `#answer` then finds the peer already replaced and returns without a word. + */ + #onClosed(reason: string): void { + if (!this.#alive()) return; + if (this.#owesAnswer) this.#decline(reason); + else this.#giveUp(reason); + } + + /** + * A peer speaking something else on the channel is not one this session's + * counters can stay synchronized with, switched or not — but only *this* + * session's, so it is gated like every other channel event: a leftover + * channel must never end the session that replaced it. + */ + #onViolation(reason: string): void { + if (!this.#alive()) return; + this.#deps.fatal(reason); + } + + /** + * The attempt is over. **Before either direction has switched that is merely an + * abandoned attempt** and the session carries on relayed; afterwards what was + * riding the channel is gone and a stream cipher has no resynchronization + * point, so the session is over. + * + * Answers whether the attempt was abandoned — `false` means the session went + * with it, and the caller has nothing left to say on it. + */ + #giveUp(reason = 'the direct path was abandoned', cause: DirectRelayCause = 'failed'): boolean { + if (this.#cutover.switched) { + this.#deps.fatal(reason); + return false; + } + // **The abandoned half of the reason, which the user never sees.** A cause + // is three buckets wide; this is the sentence that says which failure it + // actually was, and staying relayed is the outcome an operator most often + // has to explain. Once per session at most: the attempt is not retried. + console.warn(`[direct] staying on the relay: ${reason}`); + this.#peer?.close(); + this.#peer = null; + this.#cutover.abandon(); + this.#cause = cause; + this.#settle(); + return true; + } + + /** Whether this endpoint still belongs to the session the caller is serving. */ + #alive(): boolean { + return !this.#disposed && this.#deps.isCurrent(); + } + + /** + * Reconcile the handoff deadline with the cutover, then announce. + * + * **The deadline is derived, never remembered.** It is armed exactly while + * this end has switched and its peer has not — the one window in which this + * end sends only on the channel while nothing can reach it back — so every + * route that moves the cutover reconciles it by calling this, rather than by + * remembering to arm or clear. + * + * Its expiry is burrow loss, since there is no relay left to fall back to. + * Without it the wait would end only when the held frames overran their + * bound, which is a function of how chatty the session happens to be rather + * than of anything going wrong. + */ + #settle(): void { + const waiting = + !this.#disposed && this.#cutover.outbound === 'direct' && this.#cutover.inbound === 'relay'; + if (waiting && !this.#cancelHandoff) { + this.#cancelHandoff = this.#setTimer(() => { + this.#cancelHandoff = null; + if (!this.#alive()) return; + this.#deps.fatal('the peer did not follow onto the direct path'); + }, DIRECT_HANDOFF_TIMEOUT_MS); + } else if (!waiting && this.#cancelHandoff) { + this.#cancelHandoff(); + this.#cancelHandoff = null; + } + const path = this.path; + const cause = this.#cause; + if (path === this.#announced.path && cause === this.#announced.cause) return; + this.#announced = { path, cause }; + this.#deps.onTransportChanged?.(path, cause); + } +} diff --git a/lib/src/remote/direct/direct-peer.test.ts b/lib/src/remote/direct/direct-peer.test.ts new file mode 100644 index 000000000..220421c7a --- /dev/null +++ b/lib/src/remote/direct/direct-peer.test.ts @@ -0,0 +1,372 @@ +/** + * The peer wrapper over the linked fake pair (`docs/specs/remote-api.md` -> + * Transport -> "Direct path"): one negotiation, one channel, and the four ways + * it can end. The end-to-end cases that drive it inside a real session are in + * `../client/pocket-client.test.ts` and `../burrow/burrow-runtime.test.ts`. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + DIRECT_BUFFER_HIGH, + DIRECT_CHANNEL_LABEL, + DIRECT_DISCONNECTED_GRACE_MS, + DIRECT_GATHER_TIMEOUT_MS, + DIRECT_SETUP_TIMEOUT_MS, + MAX_DIRECT_OUTBOUND_BYTES, + MAX_DIRECT_OUTBOUND_FRAMES, + NOISE_MAX_MESSAGE_LENGTH, +} from 'remote-lib-common'; + +import { DirectPeer, type DirectPeerHandlers } from './direct-peer'; +import { FakeDirectNetwork, flushMicrotasks, type FakeDirectNetworkOptions } from './test-fake-peer'; +import { fakeTimers } from '../test-timers'; + +function handlers(): DirectPeerHandlers & { + frames: Uint8Array[]; + opens: number; + closes: string[]; + violations: string[]; +} { + const record = { + frames: [] as Uint8Array[], + opens: 0, + closes: [] as string[], + violations: [] as string[], + onOpen: () => void (record.opens += 1), + onFrame: (frame: Uint8Array) => void record.frames.push(frame), + onClosed: (reason: string) => void record.closes.push(reason), + onViolation: (reason: string) => void record.violations.push(reason), + }; + return record; +} + +/** Both ends of one negotiation, sharing a clock the test drives. */ +function pair(options: FakeDirectNetworkOptions = {}) { + const network = new FakeDirectNetwork(options); + const timers = fakeTimers(); + const client = handlers(); + const burrow = handlers(); + const offerer = network.createOfferer(); + const answerer = network.createAnswerer(); + return { + network, + timers, + client, + burrow, + /** The connections themselves, for the cases that move their state. */ + offerer, + answerer, + clientPeer: new DirectPeer({ peer: offerer, handlers: client, setTimer: timers.setTimer }), + burrowPeer: new DirectPeer({ peer: answerer, handlers: burrow, setTimer: timers.setTimer }), + }; +} + +/** One negotiated pair with both channels open, as most cases start. */ +async function connected(options: FakeDirectNetworkOptions = {}) { + const run = pair(options); + const offer = await run.clientPeer.offer(); + await run.clientPeer.acceptAnswer((await run.burrowPeer.answer(offer!))!); + await flushMicrotasks(); + return run; +} + +describe('DirectPeer', () => { + it('negotiates one ordered channel and carries raw bytes both ways', async () => { + const { network, clientPeer, burrowPeer, client, burrow } = pair(); + + const offer = await clientPeer.offer(); + expect(offer).toContain('m=application'); + const answer = await burrowPeer.answer(offer!); + expect(answer).toContain('a=setup:active'); + await clientPeer.acceptAnswer(answer!); + await flushMicrotasks(); + + expect(client.opens).toBe(1); + expect(burrow.opens).toBe(1); + expect(clientPeer.isOpen).toBe(true); + // The label is fixed: one channel per session, named the same at both ends. + expect(network.offererChannel!.label).toBe(DIRECT_CHANNEL_LABEL); + // Set before a frame can arrive, so every message is bytes. + expect(network.offererChannel!.binaryType).toBe('arraybuffer'); + + clientPeer.send(Uint8Array.of(1, 2, 3)); + burrowPeer.send(Uint8Array.of(4, 5)); + await flushMicrotasks(); + + expect(burrow.frames).toEqual([Uint8Array.of(1, 2, 3)]); + expect(client.frames).toEqual([Uint8Array.of(4, 5)]); + }); + + it('sends the description it has when gathering never completes', async () => { + const { clientPeer, timers } = pair({ gathering: 'pending' }); + + const offering = clientPeer.offer(); + let settled = false; + void offering.then(() => (settled = true)); + await flushMicrotasks(); + // Nothing to send yet: there is no trickle path, so the SDP waits. + expect(settled).toBe(false); + + timers.fireAt(DIRECT_GATHER_TIMEOUT_MS); + expect(await offering).toContain('m=application'); + }); + + it('sends as soon as gathering completes, without waiting out the deadline', async () => { + const { network, clientPeer, timers } = pair({ gathering: 'pending' }); + + const offering = clientPeer.offer(); + await flushMicrotasks(); + network.completeGathering(); + + expect(await offering).toContain('m=application'); + // The gathering deadline is cancelled; only the setup one is still armed. + expect(timers.live.map((timer) => timer.delayMs)).toEqual([DIRECT_SETUP_TIMEOUT_MS]); + }); + + it('abandons a channel that never opens, and closes the connection', async () => { + const { clientPeer, burrowPeer, client, timers } = pair({ opening: 'never' }); + + const offer = await clientPeer.offer(); + await clientPeer.acceptAnswer((await burrowPeer.answer(offer!))!); + await flushMicrotasks(); + expect(client.opens).toBe(0); + + timers.fireAt(DIRECT_SETUP_TIMEOUT_MS); + + expect(client.closes).toEqual(['the direct channel did not open in time']); + expect(clientPeer.isOpen).toBe(false); + // And nothing may be sent on it afterwards: the channel is gone, and a + // closed peer reports nothing twice. + clientPeer.send(Uint8Array.of(1)); + expect(client.closes).toEqual(['the direct channel did not open in time']); + }); + + it('reports a channel that dies under a live session, at both ends', async () => { + const { network, clientPeer, burrowPeer, client, burrow } = pair(); + const offer = await clientPeer.offer(); + await clientPeer.acceptAnswer((await burrowPeer.answer(offer!))!); + await flushMicrotasks(); + + network.dropChannels(); + + expect(client.closes).toEqual(['the direct channel closed']); + expect(burrow.closes).toEqual(['the direct channel closed']); + }); + + it('reports a channel message this protocol has no reading for', async () => { + const { network, clientPeer, burrowPeer, client } = pair(); + const offer = await clientPeer.offer(); + await clientPeer.acceptAnswer((await burrowPeer.answer(offer!))!); + await flushMicrotasks(); + + network.offererChannel!.receiveRaw('a text frame'); + // Bounded before it reaches a cipher, exactly as a relay ciphertext is. + network.offererChannel!.receiveRaw(new ArrayBuffer(NOISE_MAX_MESSAGE_LENGTH + 1)); + + expect(client.violations).toEqual([ + 'a direct channel message was not binary', + 'a direct channel frame exceeds one Noise message', + ]); + // A violation is the endpoint's to act on: the channel is not torn down here. + expect(client.frames).toEqual([]); + }); + + it('skips a description too large to travel inside the session', async () => { + const offering = pair({ oversize: 'offer' }); + expect(await offering.clientPeer.offer()).toBeNull(); + + const answering = pair({ oversize: 'answer' }); + const offer = await answering.clientPeer.offer(); + expect(await answering.burrowPeer.answer(offer!)).toBeNull(); + }); + + it('answers null rather than throwing when the negotiation fails', async () => { + const timers = fakeTimers(); + const record = handlers(); + const peer = new FakeDirectNetwork().createOfferer(); + vi.spyOn(peer, 'createOffer').mockRejectedValue(new Error('no transport')); + const wrapper = new DirectPeer({ peer, handlers: record, setTimer: timers.setTimer }); + + expect(await wrapper.offer()).toBeNull(); + expect(record.closes).toHaveLength(1); + expect(peer.closed).toBe(true); + }); + + /** + * `RTCPeerConnection.close()` fires no `icegatheringstatechange`, so nothing + * but `close()` can ever settle this wait — and until it does, the suspended + * negotiation holds the endpoint and its session alive behind a timer that is + * not `unref`ed. + */ + it('cancels the gathering deadline when it is closed mid-negotiation', async () => { + const { clientPeer, timers } = pair({ gathering: 'pending' }); + + const offering = clientPeer.offer(); + await flushMicrotasks(); + expect(timers.live.map((timer) => timer.delayMs)).toEqual([ + DIRECT_SETUP_TIMEOUT_MS, + DIRECT_GATHER_TIMEOUT_MS, + ]); + + clientPeer.close(); + + expect(await offering).toBeNull(); + expect(timers.live).toEqual([]); + }); + + it('closes idempotently, reporting nothing', () => { + const { clientPeer, client } = pair(); + clientPeer.close(); + clientPeer.close(); + expect(client.closes).toEqual([]); + }); + + describe('what a sender may hold', () => { + it('queues past the high-water mark and drains when the channel catches up', async () => { + const { network, clientPeer, burrow } = await connected(); + const channel = network.offererChannel!; + channel.bufferedAmount = DIRECT_BUFFER_HIGH; + + clientPeer.send(Uint8Array.of(1)); + clientPeer.send(Uint8Array.of(2)); + await flushMicrotasks(); + // Held here rather than handed to a buffer that would refuse them. + expect(channel.sent).toEqual([]); + expect(burrow.frames).toEqual([]); + + channel.drained(); + await flushMicrotasks(); + + expect(burrow.frames).toEqual([Uint8Array.of(1), Uint8Array.of(2)]); + }); + + it('holds a later frame behind an earlier one, whatever the buffer says', async () => { + const { network, clientPeer } = await connected(); + const channel = network.offererChannel!; + channel.bufferedAmount = DIRECT_BUFFER_HIGH; + clientPeer.send(Uint8Array.of(1)); + + // The buffer drains without the event that says so: a frame that jumped + // the queue here would reach the peer ahead of the one before it. + channel.bufferedAmount = 0; + clientPeer.send(Uint8Array.of(2)); + await flushMicrotasks(); + + expect(channel.sent).toEqual([]); + }); + + it('reports the path gone when a send would outrun the queue, in bytes', async () => { + const { network, clientPeer, client } = await connected(); + network.offererChannel!.bufferedAmount = DIRECT_BUFFER_HIGH; + const frame = new Uint8Array(NOISE_MAX_MESSAGE_LENGTH); + + let held = 0; + while (client.closes.length === 0 && held <= MAX_DIRECT_OUTBOUND_FRAMES) { + clientPeer.send(frame); + held += 1; + } + + // **Our bound, not the peer's stack**: an operator reading a burrow-loss + // log has only the reason to tell those two apart. + expect(client.closes).toEqual(['the direct path outran what a sender can hold in order']); + // Bytes bind first: the frame cap is far above what this many reaches. + expect((held - 1) * frame.length).toBeLessThanOrEqual(MAX_DIRECT_OUTBOUND_BYTES); + expect(held * frame.length).toBeGreaterThan(MAX_DIRECT_OUTBOUND_BYTES); + }); + + it('reports the channel gone when a queued frame will not go out', async () => { + const { network, clientPeer, client } = await connected(); + const channel = network.offererChannel!; + channel.bufferedAmount = DIRECT_BUFFER_HIGH; + clientPeer.send(Uint8Array.of(1)); + + // Closed under the queue: the drain finds a channel that will not take it. + channel.readyState = 'closed'; + channel.drained(); + + expect(client.closes).toEqual(['the direct channel refused a message']); + }); + }); + + describe('what the channel has to be', () => { + it.each(['unordered', 'lossy', 'expiring', 'mislabeled'] as const)( + 'refuses a %s channel, before anything rides it', + async (defect) => { + const { clientPeer, client } = pair({ channel: defect }); + + expect(await clientPeer.offer()).toBeNull(); + + expect(client.opens).toBe(0); + expect(client.closes).toEqual([ + 'the direct channel is not the reliable ordered one this session opens', + ]); + }, + ); + + it('refuses one the peer created, which is where the check can fail', async () => { + // The offerer only re-reads the channel it asked for; the answerer is + // handed one by a peer it has no reason to trust to have asked for the + // same thing. + const { clientPeer, burrowPeer, burrow } = pair({ + channel: 'unordered', + channelSide: 'answerer', + }); + + const offer = await clientPeer.offer(); + expect(await burrowPeer.answer(offer!)).toBeNull(); + + expect(burrow.closes).toEqual([ + 'the direct channel is not the reliable ordered one this session opens', + ]); + }); + + it('abandons a channel that cannot carry one Noise message', async () => { + const { clientPeer, client } = await connected({ maxMessageSize: 16_384 }); + + expect(client.opens).toBe(0); + expect(client.closes).toEqual(['the direct channel carries only 16384 bytes per message']); + expect(clientPeer.isOpen).toBe(false); + }); + + it('opens where the association reports no limit to check', async () => { + const { clientPeer, client } = await connected({ maxMessageSize: null }); + + expect(client.opens).toBe(1); + expect(clientPeer.isOpen).toBe(true); + }); + }); + + describe('the connection under the channel', () => { + it('ends the attempt at once when the connection fails', async () => { + const { offerer, clientPeer, client } = await connected(); + + offerer.setConnectionState('failed'); + + expect(client.closes).toEqual(['the direct connection failed']); + expect(clientPeer.isOpen).toBe(false); + }); + + it('waits a disconnected connection out, then gives up on one that stays down', async () => { + const { offerer, client, timers } = await connected(); + + offerer.setConnectionState('disconnected'); + // A gap ICE often recovers from: nothing is reported while it may. + expect(client.closes).toEqual([]); + + timers.fireAt(DIRECT_DISCONNECTED_GRACE_MS); + + expect(client.closes).toEqual(['the direct connection stayed disconnected']); + }); + + it('keeps a connection that comes back inside the grace', async () => { + const { offerer, clientPeer, client, timers } = await connected(); + + offerer.setConnectionState('disconnected'); + offerer.setConnectionState('connected'); + + expect(timers.live).toEqual([]); + expect(client.closes).toEqual([]); + expect(clientPeer.isOpen).toBe(true); + }); + }); +}); diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts new file mode 100644 index 000000000..420dff704 --- /dev/null +++ b/lib/src/remote/direct/direct-peer.ts @@ -0,0 +1,513 @@ +/** + * The `RTCPeerConnection`-shaped seam both ends of the direct path share + * (`docs/specs/remote-api.md` -> Transport -> "Direct path"). + * + * **Structural, never the DOM globals.** The interfaces below are the subset of + * the W3C API this stack calls, so the browser's `RTCPeerConnection`, the + * sidecar's native polyfill, and the in-memory fake all satisfy the same shape + * and neither endpoint reaches for a global. Constructing one is the injected + * factory's job (`PocketClientDeps.createDirectPeer`, + * `BurrowOptions.createDirectPeer`), which is also where `iceServers: []` is + * set — nothing here knows what an ICE server is. + * + * The wrapper owns the two halves of one negotiation and the channel's four + * events. It owns no policy: what a closed channel *means* depends on whether + * the session has already switched, which is the endpoint's question. + */ + +import { + DIRECT_ANSWER_TIMEOUT_MS, + DIRECT_BUFFER_HIGH, + DIRECT_CHANNEL_LABEL, + DIRECT_BUFFER_LOW, + DIRECT_DISCONNECTED_GRACE_MS, + DIRECT_GATHER_TIMEOUT_MS, + DIRECT_SETUP_TIMEOUT_MS, + DirectFrameQueue, + MAX_DIRECT_OUTBOUND_BYTES, + MAX_DIRECT_OUTBOUND_FRAMES, + NOISE_MAX_MESSAGE_LENGTH, + isDirectSdp, +} from 'remote-lib-common'; +import { realTimer, type RemoteTimer } from '../ws'; + +/** The four `RTCSdpType` values, so a real description assigns to ours. */ +export type DirectSdpType = 'offer' | 'answer' | 'pranswer' | 'rollback'; + +/** As much of `RTCSessionDescriptionInit` as one negotiation needs. */ +export interface DirectSessionDescription { + readonly type: DirectSdpType; + readonly sdp?: string; +} + +/** The subset of `RTCDataChannel` a Noise transport rides on. */ +export interface DirectChannelLike { + binaryType: string; + /** The four properties {@link DirectPeer} checks before it adopts a channel. */ + readonly label: string; + readonly ordered: boolean; + readonly maxRetransmits: number | null; + readonly maxPacketLifeTime: number | null; + /** What the implementation is still holding; see {@link DIRECT_BUFFER_HIGH}. */ + readonly bufferedAmount: number; + bufferedAmountLowThreshold: number; + send(data: ArrayBuffer | ArrayBufferView): void; + close(): void; + addEventListener(type: string, handler: (ev: unknown) => void): void; +} + +/** + * As much of `RTCSctpTransport` as the size check needs. The association's own + * limit, negotiated from both ends' `a=max-message-size`, so it is knowable only + * once the channel is open — `node-datachannel`'s polyfill exposes the transport + * from construction and leaves this null until then, which is why it is nullable + * here even though the W3C type is not. + */ +export interface DirectSctpLike { + readonly maxMessageSize: number | null; +} + +/** The subset of `RTCPeerConnection` one negotiation needs. */ +export interface DirectPeerLike { + createDataChannel(label: string, init?: { ordered?: boolean }): DirectChannelLike; + createOffer(): Promise; + createAnswer(): Promise; + setLocalDescription(description: DirectSessionDescription): Promise; + setRemoteDescription(description: DirectSessionDescription): Promise; + readonly localDescription: DirectSessionDescription | null; + readonly iceGatheringState: string; + /** Null until the association exists; see {@link DirectSctpLike}. */ + readonly sctp: DirectSctpLike | null; + readonly connectionState: string; + addEventListener(type: string, handler: (ev: unknown) => void): void; + close(): void; +} + +/** How one endpoint constructs a peer, or answers that it has none. */ +export type DirectPeerFactory = () => DirectPeerLike | null; + +export interface DirectPeerHandlers { + /** The channel is open: this end may switch its sends onto it. */ + onOpen(): void; + /** One inbound channel frame, already bounded at `NOISE_MAX_MESSAGE_LENGTH`. */ + onFrame(frame: Uint8Array): void; + /** + * The channel went away — closed, errored, or never opened in time. Whether + * that is burrow loss or an abandoned attempt is the endpoint's call. + */ + onClosed(reason: string): void; + /** + * The peer put something on the channel this protocol has no reading for: a + * non-binary message, or one too large to be a Noise transport message. The + * session dies whichever direction has switched, because a peer speaking a + * different protocol on the channel is not one the counters can be kept + * synchronized with. + */ + onViolation(reason: string): void; +} + +/** What a closed peer reports to: nothing, so it retains nothing either. */ +const SILENT_HANDLERS: DirectPeerHandlers = { + onOpen: () => {}, + onFrame: () => {}, + onClosed: () => {}, + onViolation: () => {}, +}; + +export interface DirectPeerDeps { + readonly peer: DirectPeerLike; + readonly handlers: DirectPeerHandlers; + /** Every deadline below; see {@link RemoteTimer}. */ + readonly setTimer?: RemoteTimer; +} + +/** + * One session's peer connection and its single ordered, reliable data channel. + * + * **One negotiation, no trickle**: each side sends its whole description once + * ICE gathering has completed (or `DIRECT_GATHER_TIMEOUT_MS` has passed), so + * the candidates travel inside the session with the SDP and the relay never + * sees either. **The channel must be open by `DIRECT_SETUP_TIMEOUT_MS`** — by + * `DIRECT_ANSWER_TIMEOUT_MS` on the answering side, which arms later — or the + * attempt is abandoned and the session stays relayed. + * + * **Sends are bounded here, not left to the implementation.** Past + * `DIRECT_BUFFER_HIGH` the ciphertext queues instead, draining on the channel's + * own low-water event, so a burst of terminal output waits in a queue with a + * stated bound rather than in a runtime buffer whose refusal would kill the + * session. + */ +export class DirectPeer { + readonly #peer: DirectPeerLike; + #handlers: DirectPeerHandlers; + readonly #setTimer: RemoteTimer; + #channel: DirectChannelLike | null = null; + /** Ciphertext waiting on the channel to drain; see {@link send}. */ + readonly #outbound = new DirectFrameQueue(MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_OUTBOUND_BYTES); + #cancelSetup: (() => void) | null = null; + /** Cancels the grace a `disconnected` connection is given, if one is running. */ + #cancelDisconnected: (() => void) | null = null; + /** + * Settles the gathering wait — cancelling its deadline with it — or null when + * none is outstanding. Held on the instance because {@link close} has to + * reach it: `RTCPeerConnection.close()` fires no `icegatheringstatechange`, + * so nothing else would. + */ + #endGathering: (() => void) | null = null; + #open = false; + #closed = false; + + constructor(deps: DirectPeerDeps) { + this.#peer = deps.peer; + this.#handlers = deps.handlers; + this.#setTimer = deps.setTimer ?? realTimer; + // Registered before either half of the negotiation runs: a connection that + // fails while a description is still being built has nothing else watching. + this.#peer.addEventListener('connectionstatechange', () => this.#onConnectionState()); + } + + /** Whether the channel has opened and not since gone. */ + get isOpen(): boolean { + return this.#open && !this.#closed; + } + + /** + * The offerer's half: create the channel, describe it, and answer with the + * SDP to put in a `direct-offer`. + * + * Answers `null` where there is nothing to send — a description this peer + * would not accept back, or a negotiation that threw — and the caller simply + * never offers. + */ + async offer(): Promise { + this.#armSetupTimeout(DIRECT_SETUP_TIMEOUT_MS); + try { + this.#adopt(this.#peer.createDataChannel(DIRECT_CHANNEL_LABEL, { ordered: true })); + const offer = await this.#peer.createOffer(); + await this.#peer.setLocalDescription(offer); + return await this.#gatheredSdp(); + } catch (error) { + this.#fail(`could not describe a direct path: ${String(error)}`); + return null; + } + } + + /** + * The answerer's half: take the offer, wait for the channel it describes, and + * answer with the SDP to put in a `direct-answer`. `null` means decline. + */ + async answer(offerSdp: string): Promise { + // The shorter budget, because this end arms it a relay hop after the + // offerer armed its own; see {@link DIRECT_ANSWER_TIMEOUT_MS}. + this.#armSetupTimeout(DIRECT_ANSWER_TIMEOUT_MS); + try { + // Registered before the remote description is set: the channel event can + // fire inside `setRemoteDescription`, and a listener added after it would + // miss the only one this negotiation sends. + this.#peer.addEventListener('datachannel', (ev) => { + const channel = (ev as { channel?: DirectChannelLike } | null)?.channel; + if (channel) this.#adopt(channel); + }); + await this.#peer.setRemoteDescription({ type: 'offer', sdp: offerSdp }); + const answer = await this.#peer.createAnswer(); + await this.#peer.setLocalDescription(answer); + return await this.#gatheredSdp(); + } catch (error) { + this.#fail(`could not answer a direct path: ${String(error)}`); + return null; + } + } + + /** The offerer's second half, once `direct-answer` has decrypted. */ + async acceptAnswer(answerSdp: string): Promise { + try { + await this.#peer.setRemoteDescription({ type: 'answer', sdp: answerSdp }); + } catch (error) { + this.#fail(`could not accept a direct answer: ${String(error)}`); + } + } + + /** + * One Noise transport message as one channel frame — raw bytes, never base64 + * or JSON, so the channel carries exactly what the relay would have. + * + * **Every way this can fail is reported here, in its own words**, through + * `onClosed`: the channel refusing a write and this end's own queue + * overrunning are opposite diagnoses — one is the peer's stack, one is our + * bound — and an operator reading a burrow-loss log has only the reason to + * tell them apart. What a report *costs* is still the endpoint's question. + * + * The frame is kept by reference: it is ciphertext the session just minted + * and nothing else holds ({@link DirectFrameQueue}). + */ + send(ciphertext: Uint8Array): void { + const channel = this.#channel; + if (!channel || !this.isOpen) return; + // **Once anything is queued, everything queues.** A frame handed straight to + // the channel while others wait would reach the peer ahead of ciphertext + // encrypted before it, and a Noise stream has no way back from a counter + // read out of order. + if (this.#outbound.length === 0 && channel.bufferedAmount < DIRECT_BUFFER_HIGH) { + if (!this.#write(channel, ciphertext)) this.#fail('the direct channel refused a message'); + return; + } + if (!this.#outbound.push(ciphertext)) { + this.#fail('the direct path outran what a sender can hold in order'); + } + } + + /** Close the channel and the connection. Idempotent, and reports nothing. */ + close(): void { + this.#closed = true; + this.#clearSetupTimeout(); + this.#clearDisconnectedGrace(); + this.#outbound.clear(); + // The suspended `offer()`/`answer()` finishes here rather than in three + // seconds' time: it sees `#closed`, answers `null`, and releases the + // endpoint and the session it was still holding open. + this.#endGathering?.(); + try { + this.#channel?.close(); + } catch { + // Already closing. + } + try { + this.#peer.close(); + } catch { + // Already closed. + } + // Dropped rather than merely flagged: the channel's own listeners still + // point here, and a closed peer must retain neither the endpoint that owned + // these handlers nor whatever the channel is still holding. + this.#channel = null; + this.#handlers = SILENT_HANDLERS; + } + + // --- Internals ------------------------------------------------------------- + + /** + * Wire one channel's events, whichever side created it. + * + * **Reliable and ordered, or not at all.** A Noise stream is one counter per + * direction with no resynchronization point, so a channel that may drop or + * reorder a frame is one the session would die on at the first gap rather + * than the first byte — and dying here, before any switch, only abandons the + * attempt. The label is checked alongside them: this negotiation creates + * exactly one channel and calls it {@link DIRECT_CHANNEL_LABEL}. + * + * **The reliability half reaches only as far as the implementation reports + * it.** A browser hands an answerer the parameters the offerer actually + * negotiated, so there the check bites. `node-datachannel`'s polyfill builds + * every incoming channel with its own defaults instead — measured against + * 0.33.2, an offerer's `{ordered: false, maxRetransmits: 0}` reaches the + * answerer as `ordered: true, maxRetransmits: null` — so on the standalone + * Burrow only the label comparison is load-bearing, and a paired Client that + * opened an unordered channel would be adopted. Pinned, so a version that + * starts reporting them is noticed, by + * `lib/src/host/remote/native-direct-peer.test.ts`. + */ + #adopt(channel: DirectChannelLike): void { + if (this.#channel) return; + if ( + channel.label !== DIRECT_CHANNEL_LABEL || + !channel.ordered || + channel.maxRetransmits !== null || + channel.maxPacketLifeTime !== null + ) { + try { + channel.close(); + } catch { + // Never adopted, so nothing here depends on it closing cleanly. + } + this.#fail('the direct channel is not the reliable ordered one this session opens'); + return; + } + this.#channel = channel; + // Set before any message can arrive, so every frame is bytes rather than a + // `Blob` this stack has no synchronous way to read. + channel.binaryType = 'arraybuffer'; + channel.bufferedAmountLowThreshold = DIRECT_BUFFER_LOW; + channel.addEventListener('open', () => this.#onOpen()); + channel.addEventListener('message', (ev) => this.#onMessage(ev)); + channel.addEventListener('bufferedamountlow', () => this.#drain()); + channel.addEventListener('close', () => this.#fail('the direct channel closed')); + channel.addEventListener('error', () => this.#fail('the direct channel failed')); + } + + /** + * The channel reported open. + * + * **The association's message limit is checked here**, the first moment it is + * knowable — until the association is up neither stack has a number to give. + * One Noise transport message is one channel frame and may be + * {@link NOISE_MAX_MESSAGE_LENGTH} bytes, so an association that would refuse + * one is a session that dies on its first large paste instead. + * + * **The number is the *remote's* advertised limit, so it is per direction.** + * Where both ends advertise the same — as both shipped stacks do, at 262 144 + * — they abandon together and the session stays relayed. Where they disagree + * and only one end refuses, a peer that had already switched has no relay + * left to fall back to and loses the session. That is accepted: the + * alternative is carrying a session that dies on its first large frame + * anyway (`docs/specs/remote-api.md` -> Transport -> "Direct path"). + */ + #onOpen(): void { + if (this.#closed || this.#open) return; + // Unknown is not small: an implementation reporting no association yet, or + // no usable number, is one this cannot rule out either way — and every + // inbound frame is bounded again in `#onMessage` regardless. + const limit = this.#peer.sctp?.maxMessageSize ?? 0; + if (limit > 0 && limit < NOISE_MAX_MESSAGE_LENGTH) { + this.#fail(`the direct channel carries only ${limit} bytes per message`); + return; + } + this.#open = true; + this.#clearSetupTimeout(); + this.#handlers.onOpen(); + } + + /** + * Hand the channel as much of the queue as it will take. + * + * **A frame is written once or not at all**: `send` either consumes the + * message or throws, so retrying one here would put ciphertext the peer has + * already counted on the wire twice. + */ + #drain(): void { + const channel = this.#channel; + if (!channel || !this.isOpen) return; + while (channel.bufferedAmount < DIRECT_BUFFER_HIGH) { + const frame = this.#outbound.shift(); + if (!frame) return; + if (this.#write(channel, frame)) continue; + // Accepted into the queue and refused now: the channel is gone. + this.#fail('the direct channel refused a message'); + return; + } + } + + #write(channel: DirectChannelLike, frame: Uint8Array): boolean { + try { + channel.send(frame); + return true; + } catch { + return false; + } + } + + /** + * The connection's own state, which reaches here before the channel's does. + * + * **`disconnected` is waited out** ({@link DIRECT_DISCONNECTED_GRACE_MS}): + * ICE reports it on a gap that often recovers, so it is not a report of loss + * until it persists. `failed` and `closed` are terminal and end the attempt + * at once. + */ + #onConnectionState(): void { + if (this.#closed) return; + const state = this.#peer.connectionState; + if (state === 'failed' || state === 'closed') { + this.#fail(`the direct connection ${state}`); + return; + } + if (state !== 'disconnected') { + this.#clearDisconnectedGrace(); + return; + } + if (this.#cancelDisconnected) return; + this.#cancelDisconnected = this.#setTimer(() => { + this.#cancelDisconnected = null; + if (this.#closed || this.#peer.connectionState !== 'disconnected') return; + this.#fail('the direct connection stayed disconnected'); + }, DIRECT_DISCONNECTED_GRACE_MS); + } + + #onMessage(ev: unknown): void { + if (this.#closed) return; + const data = (ev as { data?: unknown } | null)?.data; + let frame: Uint8Array; + // A view either way, never a copy: everything downstream reads the frame + // inside this call, except the one the cutover holds until the peer's + // switch decrypts — and `DirectCutover.onChannelFrame` copies that one, + // which is the only place that knows a frame is about to outlive its + // buffer. + if (data instanceof ArrayBuffer) { + frame = new Uint8Array(data); + } else if (ArrayBuffer.isView(data)) { + frame = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } else { + this.#handlers.onViolation('a direct channel message was not binary'); + return; + } + // Bounded before it reaches a cipher, exactly as a relay ciphertext is: one + // channel frame is one Noise transport message and can be no larger. + if (frame.length > NOISE_MAX_MESSAGE_LENGTH) { + this.#handlers.onViolation('a direct channel frame exceeds one Noise message'); + return; + } + this.#handlers.onFrame(frame); + } + + /** + * The local description once gathering has settled, or `null` if it is not one + * that fits a signal. Bounded here rather than at the caller so both halves + * refuse the same descriptions. + */ + async #gatheredSdp(): Promise { + await this.#awaitGathering(); + if (this.#closed) return null; + const sdp = this.#peer.localDescription?.sdp; + return isDirectSdp(sdp) ? sdp : null; + } + + /** + * Wait for `iceGatheringState === 'complete'`, bounded by + * {@link DIRECT_GATHER_TIMEOUT_MS} — after which the description as it stands + * is what gets sent, candidates gathered so far included. + */ + #awaitGathering(): Promise { + if (this.#peer.iceGatheringState === 'complete') return Promise.resolve(); + return new Promise((resolve) => { + let cancel: (() => void) | null = null; + const finish = (): void => { + if (this.#endGathering !== finish) return; + this.#endGathering = null; + cancel?.(); + resolve(); + }; + this.#endGathering = finish; + cancel = this.#setTimer(finish, DIRECT_GATHER_TIMEOUT_MS); + this.#peer.addEventListener('icegatheringstatechange', () => { + if (this.#peer.iceGatheringState === 'complete') finish(); + }); + }); + } + + #armSetupTimeout(budgetMs: number): void { + this.#clearSetupTimeout(); + this.#cancelSetup = this.#setTimer(() => { + this.#cancelSetup = null; + if (this.#open || this.#closed) return; + this.#fail('the direct channel did not open in time'); + }, budgetMs); + } + + #clearSetupTimeout(): void { + this.#cancelSetup?.(); + this.#cancelSetup = null; + } + + #clearDisconnectedGrace(): void { + this.#cancelDisconnected?.(); + this.#cancelDisconnected = null; + } + + /** Report the channel gone, once, and take the connection down with it. */ + #fail(reason: string): void { + if (this.#closed) return; + // Read before the close silences them: this is the one report a close owes. + const handlers = this.#handlers; + this.close(); + handlers.onClosed(reason); + } +} diff --git a/lib/src/remote/direct/test-fake-peer.ts b/lib/src/remote/direct/test-fake-peer.ts new file mode 100644 index 000000000..3a3377b5d --- /dev/null +++ b/lib/src/remote/direct/test-fake-peer.ts @@ -0,0 +1,358 @@ +/** + * Two {@link DirectPeerLike}s linked in memory, as the direct path's tests drive + * them. + * + * Test-only, and shared for the reason `../test-relay.ts` is: the Client and the + * Burrow both negotiate against *the same* idea of what a peer connection does, + * and two private copies would be two opinions about when a channel opens. + * Nothing here is a `RTCPeerConnection` — it answers descriptions, links the one + * data channel, and delivers frames in order — which is exactly the surface + * `direct-peer.ts` declares, so a case that passes here exercises the shipped + * wrapper rather than a stub of it. + * + * **The one thing it deliberately cannot do is reorder against the relay.** A + * channel frame overtaking the peer's `direct-switch` is a race between two + * transports, and the in-memory relay delivers synchronously; the knob for it is + * `TestRelay.holdToClient()`, on the side that is actually slow. + */ + +import { DIRECT_CHANNEL_LABEL, NOISE_MAX_MESSAGE_LENGTH } from 'remote-lib-common'; +import { + type DirectChannelLike, + type DirectPeerLike, + type DirectSctpLike, + type DirectSessionDescription, +} from './direct-peer'; +import { FakeEventTarget } from '../test-fake-socket'; + +/** When the linked channel reports itself open. */ +export type FakeChannelOpening = + /** As soon as the offerer has accepted the answer, on a microtask. */ + | 'auto' + /** Only when {@link FakeDirectNetwork.openChannels} is called. */ + | 'manual' + /** Never — what a peer behind a symmetric NAT looks like from here. */ + | 'never'; + +export interface FakeDirectNetworkOptions { + readonly opening?: FakeChannelOpening; + /** + * Leave `iceGatheringState` at `gathering` forever, so the wrapper falls back + * on its own gathering deadline instead of an event. + */ + readonly gathering?: 'complete' | 'pending'; + /** Which side describes itself with an SDP over the signal's bound. */ + readonly oversize?: 'offer' | 'answer'; + /** + * What the association reports as its per-message limit, for the case where + * it is too small to carry one Noise transport message. `null` models an + * implementation that reports no association at all. + */ + readonly maxMessageSize?: number | null; + /** A channel a Noise stream cannot ride; see {@link ChannelDefect}. */ + readonly channel?: ChannelDefect; + /** + * Which end's channel carries that defect; both by default. The answerer is + * the end where the check can fail in production — it validates a channel the + * *peer* created, while the offerer only re-reads its own request. + */ + readonly channelSide?: FakePeerRole; +} + +/** One end of the linked pair; the offerer creates the channel. */ +export type FakePeerRole = 'offerer' | 'answerer'; + +/** One way a channel can be something a Noise stream cannot ride. */ +export type ChannelDefect = 'unordered' | 'lossy' | 'expiring' | 'mislabeled'; + +/** + * Let this file's queued microtasks run: every fake channel event is delivered + * through `queueMicrotask`, so a case that has just opened or sent needs one + * turn of the loop before it can read what happened. + */ +export const flushMicrotasks = (): Promise => + new Promise((resolve) => setTimeout(resolve, 0)); + +export class FakeDirectNetwork { + readonly #options: FakeDirectNetworkOptions; + readonly #peers = new Map(); + #offererChannel: FakeChannel | null = null; + #answererChannel: FakeChannel | null = null; + + constructor(options: FakeDirectNetworkOptions = {}) { + this.#options = options; + } + + /** The Client's side: it creates the channel and describes it first. */ + createOfferer(): FakePeer { + return this.#create('offerer'); + } + + /** The Burrow's side: it learns of the channel through `datachannel`. */ + createAnswerer(): FakePeer { + return this.#create('answerer'); + } + + get offererChannel(): FakeChannel | null { + return this.#offererChannel; + } + + get answererChannel(): FakeChannel | null { + return this.#answererChannel; + } + + /** Both ends report open, in the order a linked pair would settle. */ + openChannels(): void { + this.#offererChannel?.open(); + this.#answererChannel?.open(); + } + + /** The channel goes away under a live session — a radio gap, a peer that quit. */ + dropChannels(): void { + this.#offererChannel?.drop(); + this.#answererChannel?.drop(); + } + + /** Let both peers finish gathering, for a run constructed with `pending`. */ + completeGathering(): void { + for (const peer of this.#peers.values()) peer.completeGathering(); + } + + #create(role: FakePeerRole): FakePeer { + const peer = new FakePeer(role, this.#options, this); + this.#peers.set(role, peer); + return peer; + } + + /** Called by a peer that has just created or received the channel. */ + registerChannel(role: FakePeerRole, channel: FakeChannel): void { + if (role === 'offerer') this.#offererChannel = channel; + else this.#answererChannel = channel; + const offerer = this.#offererChannel; + const answerer = this.#answererChannel; + if (offerer && answerer) { + offerer.link(answerer); + answerer.link(offerer); + } + } + + /** The offerer accepted the answer: the negotiation is complete. */ + negotiated(): void { + if ((this.#options.opening ?? 'auto') !== 'auto') return; + queueMicrotask(() => this.openChannels()); + } +} + +/** + * One end of the pair. Descriptions are plausible rather than parsed: nothing + * reads them but the signal guard, and the pairing is done by the network. + */ +export class FakePeer implements DirectPeerLike { + readonly #role: FakePeerRole; + readonly #options: FakeDirectNetworkOptions; + readonly #network: FakeDirectNetwork; + readonly #events = new FakeEventTarget(); + #local: DirectSessionDescription | null = null; + #gathering: string; + #connectionState = 'connecting'; + closed = false; + + constructor( + role: FakePeerRole, + options: FakeDirectNetworkOptions, + network: FakeDirectNetwork, + ) { + this.#role = role; + this.#options = options; + this.#network = network; + this.#gathering = options.gathering === 'pending' ? 'gathering' : 'complete'; + } + + get iceGatheringState(): string { + return this.#gathering; + } + + get localDescription(): DirectSessionDescription | null { + return this.#local; + } + + get sctp(): DirectSctpLike | null { + const limit = this.#options.maxMessageSize; + if (limit === null) return null; + return { maxMessageSize: limit ?? NOISE_MAX_MESSAGE_LENGTH }; + } + + get connectionState(): string { + return this.#connectionState; + } + + /** Move the connection, firing the event a real one does. */ + setConnectionState(state: string): void { + this.#connectionState = state; + this.#emit('connectionstatechange', {}); + } + + /** This end's channel defect, if the run put one on this side. */ + get #defect(): ChannelDefect | undefined { + const { channel, channelSide } = this.#options; + return !channelSide || channelSide === this.#role ? channel : undefined; + } + + createDataChannel(label: string): DirectChannelLike { + const channel = new FakeChannel(label, this.#defect); + this.#network.registerChannel(this.#role, channel); + return channel; + } + + async createOffer(): Promise { + return { type: 'offer', sdp: this.#describe('offer') }; + } + + async createAnswer(): Promise { + return { type: 'answer', sdp: this.#describe('answer') }; + } + + async setLocalDescription(description: DirectSessionDescription): Promise { + this.#local = description; + } + + async setRemoteDescription(description: DirectSessionDescription): Promise { + if (description.type === 'offer') { + // The answerer learns of the channel here, exactly as a real one does. + const channel = new FakeChannel(DIRECT_CHANNEL_LABEL, this.#defect); + this.#network.registerChannel(this.#role, channel); + this.#emit('datachannel', { channel }); + return; + } + this.#network.negotiated(); + } + + addEventListener(type: string, handler: (ev: unknown) => void): void { + this.#events.addEventListener(type, handler); + } + + close(): void { + this.closed = true; + } + + /** Finish gathering late, for the run that starts `pending`. */ + completeGathering(): void { + if (this.#gathering === 'complete') return; + this.#gathering = 'complete'; + this.#emit('icegatheringstatechange', {}); + } + + #describe(kind: 'offer' | 'answer'): string { + const body = + 'v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n' + + 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\n' + + `a=setup:${kind === 'offer' ? 'actpass' : 'active'}\r\na=mid:0\r\na=sctp-port:5000\r\n`; + // A machine with many interfaces describes itself in more candidates than a + // signal can carry; the attempt is skipped or declined rather than sent. + return this.#options.oversize === kind ? `${body}a=x:${'c'.repeat(4000)}\r\n` : body; + } + + #emit(type: string, ev: unknown): void { + this.#events.emit(type, ev); + } +} + +/** One end of the linked data channel. */ +export class FakeChannel implements DirectChannelLike { + readonly label: string; + binaryType = 'blob'; + readyState = 'connecting'; + /** The three reliability facts `DirectPeer` checks before it adopts one. */ + readonly ordered: boolean; + readonly maxRetransmits: number | null; + readonly maxPacketLifeTime: number | null; + /** + * What the implementation is still holding. Sends do not move it — a case + * that wants a busy channel sets it, then calls {@link drained} to model the + * association catching up. + */ + bufferedAmount = 0; + bufferedAmountLowThreshold = 0; + /** Every frame this end was asked to send, in order. */ + readonly sent: Uint8Array[] = []; + #peer: FakeChannel | null = null; + /** Frames delivered before this end opened, held as a real one would. */ + readonly #inbox: Uint8Array[] = []; + readonly #events = new FakeEventTarget(); + + constructor(label: string, defect?: ChannelDefect) { + this.label = defect === 'mislabeled' ? `${label}-other` : label; + this.ordered = defect !== 'unordered'; + this.maxRetransmits = defect === 'lossy' ? 3 : null; + this.maxPacketLifeTime = defect === 'expiring' ? 500 : null; + } + + /** The association caught up: drop to the low-water mark and wake the sender. */ + drained(): void { + this.bufferedAmount = 0; + this.#events.emit('bufferedamountlow', {}); + } + + link(peer: FakeChannel): void { + this.#peer = peer; + } + + addEventListener(type: string, handler: (ev: unknown) => void): void { + this.#events.addEventListener(type, handler); + } + + send(data: ArrayBuffer | ArrayBufferView): void { + if (this.readyState !== 'open') throw new Error('the channel is not open'); + // Copied on the way out, so what a case reads back is what was sent rather + // than whatever the caller's buffer holds by the time it looks. + const bytes = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)) + : new Uint8Array(data.slice(0)); + this.sent.push(bytes); + const peer = this.#peer; + if (!peer) return; + // A microtask, so ordering is the queue's rather than the caller's stack. + queueMicrotask(() => peer.deliver(bytes)); + } + + close(): void { + if (this.readyState === 'closed') return; + this.readyState = 'closed'; + // The far end learns of it, as it does over a real SCTP association — on a + // task rather than in this stack, since the network is between them. + queueMicrotask(() => this.#peer?.drop()); + } + + open(): void { + if (this.readyState !== 'connecting') return; + this.readyState = 'open'; + this.#emit('open', {}); + for (const frame of this.#inbox.splice(0)) this.deliver(frame); + } + + /** The channel dies under a live session: closed, with an event. */ + drop(): void { + if (this.readyState === 'closed') return; + this.readyState = 'closed'; + this.#emit('close', {}); + } + + /** One frame from the far end, held until this end is open. */ + deliver(frame: Uint8Array): void { + if (this.readyState === 'connecting') { + this.#inbox.push(frame); + return; + } + if (this.readyState !== 'open') return; + this.receiveRaw(frame.slice().buffer); + } + + /** Deliver whatever a peer speaking another protocol would put on the wire. */ + receiveRaw(data: unknown): void { + this.#emit('message', { data }); + } + + #emit(type: string, ev: unknown): void { + this.#events.emit(type, ev); + } +} diff --git a/lib/src/remote/pocket-app/App.push.test.tsx b/lib/src/remote/pocket-app/App.push.test.tsx index 25158d422..5c9732ec0 100644 --- a/lib/src/remote/pocket-app/App.push.test.tsx +++ b/lib/src/remote/pocket-app/App.push.test.tsx @@ -61,6 +61,7 @@ vi.mock('../client/pocket-client', async (importOriginal) => ({ hasPriorUse = () => true; registeredPushEndpoint = () => null; setOnBurrowGone = () => undefined; + setOnTransportChanged = () => undefined; close = () => undefined; openSocket = async () => undefined; signin = async () => ({}); diff --git a/lib/src/remote/pocket-app/App.scan.test.tsx b/lib/src/remote/pocket-app/App.scan.test.tsx index 3fff9bbbc..ef81da5ad 100644 --- a/lib/src/remote/pocket-app/App.scan.test.tsx +++ b/lib/src/remote/pocket-app/App.scan.test.tsx @@ -20,6 +20,8 @@ import App, { BURROWS_EMPTY, BURROWS_TITLE, SCAN_LABEL, + TRANSPORT_PATH_LABELS, + TRANSPORT_RELAY_CAUSES, UNSUPPORTED_BROWSER_TITLE, } from './App'; import type { ConnectResult, PairingResult } from '../client/pocket-client'; @@ -44,6 +46,10 @@ import { setNativeFieldValue } from '../../lib/dom'; const fake = vi.hoisted(() => ({ noiseSupported: true as boolean, + /** The path callback `App` registered, so a case can report a cutover. */ + onTransportPath: null as + | ((path: 'relay' | 'direct', cause: 'unsupported' | 'declined' | 'failed' | null) => void) + | null, hasPriorUse: false, sessionToken: null as string | null, setup: vi.fn<(credential: { setupToken: string }, label: string) => Promise>(), @@ -95,6 +101,13 @@ vi.mock('../client/pocket-client', async (importOriginal) => ({ hasPriorUse = () => fake.hasPriorUse; registeredPushEndpoint = () => null; setOnBurrowGone = () => undefined; + setOnTransportChanged = ( + callback: + | ((path: 'relay' | 'direct', cause: 'unsupported' | 'declined' | 'failed' | null) => void) + | null, + ) => { + fake.onTransportPath = callback; + }; close = () => fake.clientClose(); openSocket = async () => undefined; setup = (credential: { setupToken: string }, label: string) => fake.setup(credential, label); @@ -190,6 +203,7 @@ beforeEach(() => { fake.hello.mockReset().mockResolvedValue({}); fake.adapterInit.mockReset().mockResolvedValue(undefined); fake.adapterDispose.mockReset(); + fake.onTransportPath = null; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -481,6 +495,42 @@ describe('the Burrows list', () => { expect(rowFor(container, 'Second laptop').textContent).toContain('Offline'); }); + /** + * **Which path carries the session is shown, never inferred**, so a relayed + * fallback is visible rather than silent (`docs/specs/pocket-app.md`). + */ + it('says which path carries the session, and re-reads it at the cutover', async () => { + fake.hasPriorUse = true; + fake.listKnownBurrows.mockResolvedValue([await knownBurrow('burrow-1', 'First laptop')]); + fake.listBurrows.mockResolvedValue([{ burrowId: 'burrow-1', label: '', online: true }]); + await boot(); + await click(container, 'Sign in with passkey'); + + await click(container, 'Connect'); + + // Every session starts on the relay and says so. + expect(container.textContent).toContain(TRANSPORT_PATH_LABELS.relay.label); + + // An attempt that quietly stayed relayed changes only the reason, so the + // label alone would say nothing had happened. **The reason rides the hover + // text, never the label**: a third state for the common case would read as + // a fault. + act(() => fake.onTransportPath?.('relay', 'declined')); + await settle(); + + const note = container.querySelector(`[title="${TRANSPORT_PATH_LABELS.relay.title} ` + + `${TRANSPORT_RELAY_CAUSES.declined}"]`); + expect(note?.textContent).toBe(TRANSPORT_PATH_LABELS.relay.label); + + act(() => fake.onTransportPath?.('direct', null)); + await settle(); + + expect(container.textContent).toContain(TRANSPORT_PATH_LABELS.direct.label); + expect(container.textContent).not.toContain(TRANSPORT_PATH_LABELS.relay.label); + // Nothing left to explain: the hover text is the path alone. + expect(container.querySelector(`[title="${TRANSPORT_PATH_LABELS.direct.title}"]`)).not.toBeNull(); + }); + /** * An authenticated `pairing-required` removes local authorization without * discarding the pin, so the row offers *Pair again* — which starts at the diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index d3eb320b5..5b6a5630c 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -26,7 +26,12 @@ import { } from '../client/pocket-client'; import { PasskeyAlreadyRegisteredError, browserWebAuthn } from '../client/webauthn'; import { BURROW_IS_AN_APP, SCAN_LABEL } from '../setup-copy'; -import { probeNoiseSupport, type PairingInvitation } from 'remote-lib-common'; +import { + probeNoiseSupport, + type DirectPath, + type DirectRelayCause, + type PairingInvitation, +} from 'remote-lib-common'; import { indexedDbKnownBurrowStore, indexedDbPendingDeletionStore, @@ -120,6 +125,14 @@ export default function App({ fetch: window.fetch.bind(window), webauthn: browserWebAuthn, createWebSocket: (url) => new WebSocket(url) as unknown as PocketSocket, + // **No ICE servers**: a public STUN or TURN default would hand a third + // party this phone's address, and the shipped deployment is a tailnet + // where host candidates reach (`docs/specs/remote-api.md` → Transport → + // "Direct path"). A browser without WebRTC keeps its session relayed. + createDirectPeer: () => + typeof RTCPeerConnection === 'undefined' + ? null + : new RTCPeerConnection({ iceServers: [] }), knownBurrows: indexedDbKnownBurrowStore(), pendingDeletions: indexedDbPendingDeletionStore(), }), @@ -340,6 +353,17 @@ export default function App({ return () => client.setOnBurrowGone(null); }, [client, teardownAdapter]); + /** + * Which path carries the live session, and why. Subscribed rather than read on + * render: the cutover happens seconds into a session, long after the wall is + * up, and an attempt that quietly stays relayed changes only the detail. + */ + const [transport, setTransport] = useState(RELAYED_TRANSPORT); + useEffect(() => { + client.setOnTransportChanged((path, cause) => setTransport({ path, cause })); + return () => client.setOnTransportChanged(null); + }, [client]); + /** The connect half, shared so a fresh pairing can continue straight into it. */ const connectTo = useCallback( async (burrow: BurrowView) => { @@ -610,7 +634,13 @@ export default function App({ // The adapter is stood up before the phase moves, so the ref is set // whenever this branch is reachable. return adapterRef.current ? ( - + ) : ( ); @@ -761,15 +791,65 @@ export const BURROWS_EMPTY = `No Burrows paired yet. ${BURROW_IS_AN_APP} On the computer, open Settings → ` + 'Remote control → Set up a phone, then scan the code.'; +/** + * What the path indicator says, and the sentence behind each. **Which path + * carries the session is shown, never inferred**, so a relayed fallback is + * visible rather than silent (`docs/specs/pocket-app.md`). + */ +export const TRANSPORT_PATH_LABELS: Record = { + relay: { label: 'relay', title: 'This session goes through the relay.' }, + direct: { label: 'direct', title: 'This session goes straight to the computer.' }, +}; + +/** + * What each reason for staying relayed says to the person holding the phone. + * + * **The copy lives here, with every other Pocket string**, and the transport + * hands up only which of the three it was ({@link DirectRelayCause}): the text + * an attempt fails with includes a runtime's own exception message, which + * belongs in the operator's log and not on a phone. + */ +export const TRANSPORT_RELAY_CAUSES: Record = { + unsupported: 'This device cannot make a direct connection.', + declined: 'The computer turned a direct connection down.', + failed: 'A direct connection was tried and did not work.', +}; + +/** Which path carries the session, and why it is not the direct one. */ +export interface TransportView { + readonly path: DirectPath; + readonly cause: DirectRelayCause | null; +} + +/** + * Where every session starts and where each one ends: relayed, with no reason + * to give. Shared so the initial state and the default prop are one value. + */ +export const RELAYED_TRANSPORT: TransportView = { path: 'relay', cause: null }; + +/** + * The indicator's hover text: which path, and the reason behind it where there + * is one. **The reason is shown, never the label** — an attempt that quietly + * stayed relayed is still `relay`, and inventing a third state for it would + * make the common case look like a fault. + */ +export function transportTitle({ path, cause }: TransportView): string { + const { title } = TRANSPORT_PATH_LABELS[path]; + return cause ? `${title} ${TRANSPORT_RELAY_CAUSES[cause]}` : title; +} + /** The connected Pocket shell: Burrow navigation chrome over the remote wall. */ export function ConnectedView({ burrow, adapter, + transport = RELAYED_TRANSPORT, onLeave, onError, }: { burrow: BurrowView; adapter: RemotePtyAdapter; + /** Which path carries the session, and why; see {@link transportTitle}. */ + transport?: TransportView; onLeave: () => void; onError?: (error: unknown) => void; }): React.ReactElement { @@ -780,6 +860,9 @@ export function ConnectedView({ ‹ {BURROWS_TITLE}

{burrow.label || burrow.burrowId}

+ + {TRANSPORT_PATH_LABELS[transport.path].label} +
diff --git a/lib/src/remote/pocket-app/pocket-chrome.tsx b/lib/src/remote/pocket-app/pocket-chrome.tsx index 7a29c7038..d40c42fa2 100644 --- a/lib/src/remote/pocket-app/pocket-chrome.tsx +++ b/lib/src/remote/pocket-app/pocket-chrome.tsx @@ -51,6 +51,9 @@ export const PK = { header: 'flex shrink-0 items-center gap-2 bg-header-active-bg px-4 pb-2.5 pt-[max(0.625rem,env(safe-area-inset-top))] text-header-active-fg', headerTitle: 'm-0 min-w-0 flex-1 truncate text-[13px] font-semibold tracking-[0.01em]', + // A settled fact about the band it sits in — which path carries the session — + // captioned in alpha-on-fg rather than coloured: it reports, never alerts. + headerNote: 'shrink-0 text-[11px] text-header-active-fg/70', body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]', // Safe centering: the first-run screen (install notice + the scan action + the diff --git a/lib/src/remote/test-e2e-client.ts b/lib/src/remote/test-e2e-client.ts index f4f5b7134..8c2a000a3 100644 --- a/lib/src/remote/test-e2e-client.ts +++ b/lib/src/remote/test-e2e-client.ts @@ -34,6 +34,9 @@ import { type PresenceProofV1, } from 'remote-lib-common'; import type { FakeSocket } from './test-fake-socket'; +import { DirectPeer } from './direct/direct-peer'; +import type { FakeDirectNetwork } from './direct/test-fake-peer'; +import type { RemoteTimer } from './ws'; const subtle = globalThis.crypto.subtle; @@ -153,7 +156,7 @@ export async function flushUntil(get: () => T | undefined, timeoutMs = 2000): } /** Poll for at most `timeoutMs`, answering `undefined` if it never arrives. */ -async function pollFor(get: () => T | undefined, timeoutMs: number): Promise { +export async function pollFor(get: () => T | undefined, timeoutMs: number): Promise { const start = Date.now(); for (;;) { const value = get(); @@ -225,13 +228,21 @@ export async function settleUntilQuiet(read: () => number, stableRounds = 3): Pr throw new Error(`settleUntilQuiet: reading never went quiet after 40 settles (last ${last})`); } -/** The Burrow's outgoing `e2e` frames for one ceremony, in order. */ +/** + * The Burrow's outgoing `e2e` frames for one ceremony, in order, narrowed to one + * `step` where a caller wants only the transport half. + */ export function e2eFramesFor( socket: FakeSocket, kind: string, id: string, + step?: string, ): Array> { - return socket.frames('e2e').filter((frame) => frame.kind === kind && frame.id === id); + return socket + .frames('e2e') + .filter( + (frame) => frame.kind === kind && frame.id === id && (step === undefined || frame.step === step), + ); } /** Deliver one relay-stamped `e2e` frame to the Burrow. */ @@ -250,8 +261,9 @@ export function sendE2eFrame( } /** - * Decrypt the Burrow's most recent control message on one ceremony, waiting for - * one to arrive. + * Decrypt one of the Burrow's control messages on a ceremony, waiting for it to + * arrive: the most recent by default, or the one at `index` where a case is + * reading a session's signals in order. * * The wait is the point: every step of a ceremony awaits several WebCrypto * calls, so a test that read the frame log after a fixed number of turns would @@ -263,13 +275,14 @@ export async function readOutcome( session: NoiseTransportSession, kind: string, id: string, + index?: number, ): Promise> { - const last = await pollFor(() => { - const frames = e2eFramesFor(socket, kind, id).filter((frame) => frame.step === 'transport'); - return frames[frames.length - 1]; + const frame = await pollFor(() => { + const frames = e2eFramesFor(socket, kind, id, 'transport'); + return index === undefined ? frames[frames.length - 1] : frames[index]; }, 2000); - if (!last) throw new Error('the Burrow sent no outcome'); - const receipt = session.receive(fromBase64Url(last.ct as string)); + if (!frame) throw new Error('the Burrow sent no outcome'); + const receipt = session.receive(fromBase64Url(frame.ct as string)); if (receipt.kind !== 'control') { throw new Error(`expected a control message, got ${receipt.kind}`); } @@ -391,3 +404,78 @@ export async function openConnectionSession(options: { burrowChallenge: toBase64Url(payload), }; } + +/** + * The Client half of the direct path, as a test drives it against a real + * Burrow: offer, read the answer, accept it, and — once the channel opens — + * announce this end's own switch (`docs/specs/remote-api.md` → Transport → + * "Direct path"). + * + * It decrypts every Burrow→Client transport frame it consumes, so a caller must + * not also read the session's relay frames while it is running. + */ +export interface TestDirectPath { + /** The Client-side wrapper; `send` puts one transport ciphertext on the channel. */ + readonly peer: DirectPeer; + /** Ciphertexts the Burrow put on the channel, in arrival order. */ + readonly inbound: Uint8Array[]; + /** The signals decrypted off the relay while opening it, in order. */ + readonly signals: Array>; +} + +export async function openDirectPath(options: { + socket: FakeSocket; + burrowId: string; + clientId: string; + connectionId: string; + session: NoiseTransportSession; + network: FakeDirectNetwork; + /** This end's own `direct-switch`, once the channel is open (default true). */ + switchOutbound?: boolean; + /** The wrapper's deadlines; a Burrow suite shares its own clock's. */ + setTimer?: RemoteTimer; +}): Promise { + const { socket, burrowId, clientId, connectionId, session, network } = options; + const inbound: Uint8Array[] = []; + const signals: Array> = []; + const peer = new DirectPeer({ + peer: network.createOfferer(), + ...(options.setTimer ? { setTimer: options.setTimer } : {}), + handlers: { + onOpen: () => {}, + onFrame: (frame) => inbound.push(frame), + onClosed: () => {}, + onViolation: () => {}, + }, + }); + let cursor = e2eFramesFor(socket, 'connection', connectionId, 'transport').length; + const nextSignal = async (): Promise> => { + const value = await readOutcome(socket, session, 'connection', connectionId, cursor); + cursor += 1; + signals.push(value); + return value; + }; + const sendControl = (value: Record): void => { + sendE2eFrame(socket, { + clientId, + burrowId, + kind: 'connection', + id: connectionId, + step: 'transport', + ct: toBase64Url(session.sendControl(value)), + }); + }; + + const offer = await peer.offer(); + if (offer === null) throw new Error('the test peer could not describe an offer'); + sendControl({ v: 1, t: 'direct-offer', sdp: offer }); + const answer = await nextSignal(); + // A decline is an answer too: the caller reads it off `signals`. + if (answer.t !== 'direct-answer') return { peer, inbound, signals }; + await peer.acceptAnswer(answer.sdp as string); + // The Burrow's own switch is its last message on the relay. + await nextSignal(); + if (options.switchOutbound !== false) sendControl({ v: 1, t: 'direct-switch' }); + await settle(); + return { peer, inbound, signals }; +} diff --git a/lib/src/remote/test-fake-socket.ts b/lib/src/remote/test-fake-socket.ts index 1f4418133..a4b7eae44 100644 --- a/lib/src/remote/test-fake-socket.ts +++ b/lib/src/remote/test-fake-socket.ts @@ -10,6 +10,26 @@ import type { RemoteWebSocket } from './ws'; +/** + * The listener map every fake in this stack keeps: `addEventListener` plus a + * way to fire one. Shared because the fake socket, the fake peer connection, + * and the fake data channel all had a private copy of exactly this. + */ +export class FakeEventTarget { + readonly #handlers = new Map void>>(); + + addEventListener(type: string, handler: (ev: unknown) => void): void { + const list = this.#handlers.get(type) ?? []; + list.push(handler); + this.#handlers.set(type, list); + } + + /** Deliver one event to everything listening for `type`. */ + emit(type: string, ev: unknown): void { + for (const handler of this.#handlers.get(type) ?? []) handler(ev); + } +} + export class FakeSocket implements RemoteWebSocket { /** `CONNECTING` until {@link open}, as a real socket is. */ readyState = 0; @@ -26,12 +46,10 @@ export class FakeSocket implements RemoteWebSocket { * timing one. */ onSend: ((frame: Record) => void) | null = null; - readonly #handlers = new Map void>>(); + readonly #events = new FakeEventTarget(); addEventListener(type: string, handler: (ev: unknown) => void): void { - const list = this.#handlers.get(type) ?? []; - list.push(handler); - this.#handlers.set(type, list); + this.#events.addEventListener(type, handler); } send(data: string): void { @@ -87,6 +105,6 @@ export class FakeSocket implements RemoteWebSocket { } #emit(type: string, ev: unknown): void { - for (const handler of this.#handlers.get(type) ?? []) handler(ev); + this.#events.emit(type, ev); } } diff --git a/lib/src/remote/test-relay.ts b/lib/src/remote/test-relay.ts index ad165271d..7305e6d97 100644 --- a/lib/src/remote/test-relay.ts +++ b/lib/src/remote/test-relay.ts @@ -17,7 +17,12 @@ * session, so a pairing or connection that succeeds here succeeded end to end. */ -import { isE2eClientFrame, isE2eBurrowFrame, type E2eKind } from 'remote-lib-common'; +import { + isE2eClientFrame, + isE2eBurrowFrame, + type E2eBurrowFrame, + type E2eKind, +} from 'remote-lib-common'; import { FakeSocket } from './test-fake-socket'; import { testRoutingId } from './test-e2e-client'; @@ -39,6 +44,30 @@ export interface TestRelay { errorClient(message: string): void; /** Corrupt the `ct` of the next Burrow→Client frame, as a hostile relay would. */ tamperNextBurrowFrame(): void; + /** + * Buffer Burrow→Client frames instead of delivering them, until + * {@link releaseToClient}. The one ordering an in-memory relay cannot + * otherwise produce: this relay routes synchronously, so a direct channel + * frame can never overtake the `direct-switch` that precedes it — which is + * exactly the race the receiver's holding queue exists for. + */ + holdToClient(): void; + /** + * Start holding at the first Burrow→Client frame `match` answers true for, + * and hold everything after it. For an ordering that depends on *which* + * frame is in flight — a replacement session's outcome, say — where + * {@link holdToClient} would have to be armed before the frame exists. + */ + holdToClientWhen(match: (frame: E2eBurrowFrame) => boolean): void; + /** + * Whether frames are being held. **A case that armed + * {@link holdToClientWhen} must wait for this before releasing**: releasing + * first leaves the arming predicate live, so the frame it was meant to catch + * is buffered with nothing left to let it go. + */ + isHoldingToClient(): boolean; + /** Deliver everything held, in the order it was sent. */ + releaseToClient(): void; /** Stop routing, without closing either socket. */ stop(): void; } @@ -58,6 +87,10 @@ export function createTestRelay(options: { let client: { socket: FakeSocket; bound: string | null } | null = null; let live = true; let tamper = false; + /** Buffered Burrow→Client deliveries, or null while routing normally. */ + let held: Array<() => void> | null = null; + /** Arms {@link TestRelay.holdToClientWhen}, and is spent by the frame it matches. */ + let holdWhen: ((frame: E2eBurrowFrame) => boolean) | null = null; burrowSocket.onSend = (frame) => { if (!live || !client) return; @@ -70,14 +103,22 @@ export function createTestRelay(options: { if (client.bound !== burrowId) return; const ct = tamper ? flipLastCharacter(frame.ct) : frame.ct; tamper = false; - client.socket.receive({ - t: 'e2e', - burrowId, - kind: frame.kind as E2eKind, - id: frame.id, - step: frame.step, - ct, - }); + const target = client.socket; + const deliver = () => + target.receive({ + t: 'e2e', + burrowId, + kind: frame.kind as E2eKind, + id: frame.id, + step: frame.step, + ct, + }); + if (holdWhen?.(frame)) { + held ??= []; + holdWhen = null; + } + if (held) held.push(deliver); + else deliver(); }; return { @@ -125,6 +166,20 @@ export function createTestRelay(options: { tamperNextBurrowFrame() { tamper = true; }, + holdToClient() { + held ??= []; + }, + holdToClientWhen(match) { + holdWhen = match; + }, + isHoldingToClient() { + return held !== null; + }, + releaseToClient() { + const pending = held ?? []; + held = null; + for (const deliver of pending) deliver(); + }, stop() { live = false; }, diff --git a/lib/src/remote/test-timers.ts b/lib/src/remote/test-timers.ts new file mode 100644 index 000000000..453833133 --- /dev/null +++ b/lib/src/remote/test-timers.ts @@ -0,0 +1,53 @@ +/** + * The {@link RemoteTimer} the remote stack's suites arm their deadlines on. + * + * Test-only, and shared for the reason `./test-fake-socket.ts` is: a keepalive + * interval, a gathering deadline, and a channel setup deadline are all the same + * seam, and no case can afford to wait one out. Two private copies were two + * names for the same firing rule. + */ + +import type { RemoteTimer } from './ws'; + +export interface FakeTimers { + /** Pass as `setTimer`; every armed deadline lands in {@link live}. */ + setTimer: RemoteTimer; + /** Every deadline armed and not yet cancelled or fired. */ + readonly live: Array<{ run: () => void; delayMs: number; cancelled: boolean }>; + /** Fire the most recently armed deadline, as its elapsing would. */ + fire(): void; + /** Fire the one armed for `delayMs`, where more than one deadline is live. */ + fireAt(delayMs: number): void; +} + +export function fakeTimers(): FakeTimers { + // Dropped on cancel and on fire rather than flagged, so this *is* `live`: a + // case that re-arms a keepalive a hundred times neither grows it without + // bound nor pays a filtered copy per read. + const live: Array<{ run: () => void; delayMs: number; cancelled: boolean }> = []; + const take = (index: number): (() => void) => { + const timer = live.splice(index, 1)[0]!; + timer.cancelled = true; + return timer.run; + }; + return { + setTimer(run: () => void, delayMs: number): () => void { + const timer = { run, delayMs, cancelled: false }; + live.push(timer); + return () => { + const index = live.indexOf(timer); + if (index >= 0) take(index); + }; + }, + live, + fire(): void { + if (live.length === 0) throw new Error('no timer is armed'); + take(live.length - 1)(); + }, + fireAt(delayMs: number): void { + const index = live.findIndex((entry) => entry.delayMs === delayMs); + if (index < 0) throw new Error(`no timer armed for ${delayMs}ms`); + take(index)(); + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9de83f766..af27653ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,9 +309,34 @@ importers: standalone/sidecar: dependencies: + detect-libc: + specifier: 2.1.2 + version: 2.1.2 + node-datachannel: + specifier: 0.33.2 + version: 0.33.2 node-pty: specifier: 1.2.0-beta.15 version: 1.2.0-beta.15 + optionalDependencies: + '@node-datachannel/darwin-arm64': + specifier: 0.33.2 + version: 0.33.2 + '@node-datachannel/darwin-x64': + specifier: 0.33.2 + version: 0.33.2 + '@node-datachannel/linux-arm64-gnu': + specifier: 0.33.2 + version: 0.33.2 + '@node-datachannel/linux-x64-gnu': + specifier: 0.33.2 + version: 0.33.2 + '@node-datachannel/win32-arm64-msvc': + specifier: 0.33.2 + version: 0.33.2 + '@node-datachannel/win32-x64-msvc': + specifier: 0.33.2 + version: 0.33.2 vscode-ext: dependencies: @@ -1225,6 +1250,55 @@ packages: resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} engines: {node: '>= 20.19.0'} + '@node-datachannel/android-arm64@0.33.2': + resolution: {integrity: sha512-I71a0jICUNYgh4ZI5AcINEMuRtQSVyrpp4FV3kJZMcx31xS0vKKtf06tpsUAtW5uS+uRXbZR+vlMEqS1h710wQ==} + cpu: [arm64] + os: [android] + + '@node-datachannel/darwin-arm64@0.33.2': + resolution: {integrity: sha512-EwBGiaMh3MX6zjYoZHuK02Q6hmQQIvR39335Kb/ZnP3fXZU0/VWx6ksHLM6K0XFFYRfP+yJeVgU79g++xoeQpw==} + cpu: [arm64] + os: [darwin] + + '@node-datachannel/darwin-x64@0.33.2': + resolution: {integrity: sha512-a1nvL6MiskSjV3TWTNXdVpI/2kHNIOP2AbuBZjR5eC7VSrH/6hCFfHIBW4SFKXGFm8LOGliY9Df10UnJUutjwA==} + cpu: [x64] + os: [darwin] + + '@node-datachannel/linux-arm64-gnu@0.33.2': + resolution: {integrity: sha512-YWAu3EiMl2HRi/fwWlfFTW30wFTkRVYJWRHQcI1uDSeZYG/tfyBqBy6CRy9Sgrb0xjOYZ6XhLDj8cdX0foPlpg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@node-datachannel/linux-arm64-musl@0.33.2': + resolution: {integrity: sha512-16o3Ny5hbxTTjDmhsZog/t7p2tRP6s/Oa/V9kKzZfiGlCqvxxCE4ACOWw/MWHOhN0xI9/63IIRfrrnQnPxjK8g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@node-datachannel/linux-x64-gnu@0.33.2': + resolution: {integrity: sha512-grLRAbZgSIX8nmObciblyCmnevDCst0snhV+z1fvDmUlzXIVXeDmv6lEzAaWs3KRleagqwSsFg5d1td1bwMT9w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-datachannel/linux-x64-musl@0.33.2': + resolution: {integrity: sha512-PlpgvYjo+gmBhIkRFaZWIhCF335FMTJmCpl+FVa4XliEgyOqD+cvjQPWcbnlrA2Jd+QnLiZt+NxT7nv4QXpoiQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@node-datachannel/win32-arm64-msvc@0.33.2': + resolution: {integrity: sha512-8NpHkm7R8EY0jHGHOXdK7K0wjWY34PJ+PJY++2VDHTAtXGazNm28zcnVE/4IU0N26YGdCDSSWat3OO3W6nwoRw==} + cpu: [arm64] + os: [win32] + + '@node-datachannel/win32-x64-msvc@0.33.2': + resolution: {integrity: sha512-aLk0O/M1JNVpzxx4RVBXMJTMlpswE04Cz5baayKwuEN+qk9hO6DSJgYMR6Wq9a5zGYAmvLFlOg+LsePyXkWliA==} + cpu: [x64] + os: [win32] + '@node-rs/crc32-android-arm-eabi@1.10.7': resolution: {integrity: sha512-mWNghDkwgoc5uJhhPx/LbgLoNAxnq6Sfz4pTxi4NWG7s5l+dPe7K+L4kXb0oOfwl3Yq3Ro7CNPGLrU4cmTXhaA==} engines: {node: '>= 10'} @@ -3691,6 +3765,10 @@ packages: resolution: {integrity: sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA==} engines: {node: '>=v0.6.5'} + node-datachannel@0.33.2: + resolution: {integrity: sha512-WRL+uqYG2eSvpnKuCOKueaMiyKlDjkJFd6pFH/f2SbD/EiXLMXwmYNU5z+TDQPrAv+BMgkuC40UESeRRL+4zBw==} + engines: {node: '>=18.20.0'} + node-pty@1.2.0-beta.15: resolution: {integrity: sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==} @@ -5469,6 +5547,33 @@ snapshots: '@noble/ciphers@2.4.0': {} + '@node-datachannel/android-arm64@0.33.2': + optional: true + + '@node-datachannel/darwin-arm64@0.33.2': + optional: true + + '@node-datachannel/darwin-x64@0.33.2': + optional: true + + '@node-datachannel/linux-arm64-gnu@0.33.2': + optional: true + + '@node-datachannel/linux-arm64-musl@0.33.2': + optional: true + + '@node-datachannel/linux-x64-gnu@0.33.2': + optional: true + + '@node-datachannel/linux-x64-musl@0.33.2': + optional: true + + '@node-datachannel/win32-arm64-msvc@0.33.2': + optional: true + + '@node-datachannel/win32-x64-msvc@0.33.2': + optional: true + '@node-rs/crc32-android-arm-eabi@1.10.7': optional: true @@ -7857,6 +7962,20 @@ snapshots: node-bitmap@0.0.1: {} + node-datachannel@0.33.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + '@node-datachannel/android-arm64': 0.33.2 + '@node-datachannel/darwin-arm64': 0.33.2 + '@node-datachannel/darwin-x64': 0.33.2 + '@node-datachannel/linux-arm64-gnu': 0.33.2 + '@node-datachannel/linux-arm64-musl': 0.33.2 + '@node-datachannel/linux-x64-gnu': 0.33.2 + '@node-datachannel/linux-x64-musl': 0.33.2 + '@node-datachannel/win32-arm64-msvc': 0.33.2 + '@node-datachannel/win32-x64-msvc': 0.33.2 + node-pty@1.2.0-beta.15: dependencies: node-addon-api: 7.1.1 diff --git a/remote-lib-common/src/index.ts b/remote-lib-common/src/index.ts index 816b98908..0f4dde2e7 100644 --- a/remote-lib-common/src/index.ts +++ b/remote-lib-common/src/index.ts @@ -28,6 +28,7 @@ export * from './security/push.js'; export * from './security/push-seal.js'; export * from './security/pairing.js'; export * from './security/e2e-bounds.js'; +export * from './security/direct-path.js'; export * from './security/token-bucket.js'; export * from './security/pairing-invitation.js'; export * from './security/e2e-ceremony.js'; diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts new file mode 100644 index 000000000..1c514ac0c --- /dev/null +++ b/remote-lib-common/src/security/direct-path.ts @@ -0,0 +1,424 @@ +/** + * The direct path's shared half: the four signaling controls that ride inside + * an established session, and the per-end cutover state machine that moves that + * session off the relay (`docs/specs/remote-api.md` -> Transport -> "Direct + * path"). + * + * One implementation, so the two ends cannot disagree about what a switch + * means. Nothing here knows about `RTCPeerConnection`, the relay envelope, or + * either endpoint's plumbing: a signal is a control message like any other + * (`docs/specs/relay.md` -> "E2E framing") and a held frame is bytes. + */ + +import { isBoundedString } from './bytes.js'; + +/** + * How long a peer waits for the channel to open before abandoning the attempt + * and staying relayed. Generous: gathering, DTLS, and SCTP all fit inside it on + * a link that works at all, and the cost of waiting is nothing — the session is + * carrying traffic on the relay the whole time. + */ +export const DIRECT_SETUP_TIMEOUT_MS = 15_000; + +/** + * The label of the one data channel a session opens. + * + * A two-end agreement rather than one end's naming choice: the answerer checks + * an incoming channel against it, so an offerer that renamed the channel would + * be declined by a Burrow it had not shipped alongside. + */ +export const DIRECT_CHANNEL_LABEL = 'dormouse'; + +/** + * The same deadline for the answerer, which arms it a relay round trip later + * than the offerer does and so **must be the one that gives up first**. Its + * channel closing is what reaches the offerer while the offerer is still + * unswitched, so both ends abandon a slow attempt and stay relayed instead of + * meeting on the fatal rule for a switch onto an abandoned channel. The + * ordering is pinned by `remote-lib-common/test/direct-path.test.mjs`. + */ +export const DIRECT_ANSWER_TIMEOUT_MS = 10_000; + +/** + * How long a peer waits for ICE gathering to finish before sending whatever + * local description it has. There is no trickle path — the SDP crosses inside + * the session as one control message — so a gatherer that never completes must + * not strand the attempt. + */ +export const DIRECT_GATHER_TIMEOUT_MS = 3_000; + +/** + * The most SDP one signal may carry, in characters. + * + * Derived from `CONTROL_PAYLOAD_SIZE` in + * `remote-lib-common/src/security/noise-transport.ts`, which every control body is padded + * to and may not exceed: with the SDP restricted to the characters SDP is made + * of ({@link isDirectSdp}), JSON encodes each of them as at most two bytes, so + * `2 * MAX_DIRECT_SDP_LENGTH` plus the envelope always fits. The relationship + * is pinned by `remote-lib-common/test/direct-path.test.mjs`. + * + * A description longer than this is not sent: the offerer skips the attempt and + * an answerer declines, and the session stays relayed. + */ +export const MAX_DIRECT_SDP_LENGTH = 2000; + +/** + * How many bytes of held channel frames a receiver holds while awaiting the + * peer's switch. **The operative bound of the two**: what the window has to + * cover is one relay one-way hop of a terminal stream, and bytes are what the + * machine actually holds (`docs/specs/remote-security-model.md` -> "Burrow + * bounds"). + */ +export const MAX_DIRECT_PENDING_BYTES = 4 * 1024 * 1024; + +/** + * How many channel frames a receiver holds, whatever their size. Set above + * where the ~1 KiB frames a PTY produces can reach it, so it stops only a peer + * sending thousands of tiny ones; {@link MAX_DIRECT_PENDING_BYTES} is what + * bounds real traffic. The relationship is pinned by + * `remote-lib-common/test/direct-path.test.mjs`. + */ +export const MAX_DIRECT_PENDING_FRAMES = 8192; + +/** + * How long this end waits, after putting its own `direct-switch` on the relay, + * for the peer's to come back the other way. + * + * Its own bound rather than the holding queue's: a peer that has stopped + * switching leaves this end sending into a channel nothing reads, and waiting + * for {@link MAX_DIRECT_PENDING_BYTES} to fill makes the wait a function of how + * chatty the session happens to be. + * + * **Biased long, because the two outcomes are not symmetric.** What is being + * waited on is a relay hop — on exactly the congested uplink the direct path + * exists to escape — while expiry is burrow loss, costing a fresh handshake and + * a WebAuthn prompt. Waiting costs only queue space, which + * {@link MAX_DIRECT_PENDING_BYTES} already bounds, so this is no shorter than + * the negotiation it follows. The ordering is pinned by + * `remote-lib-common/test/direct-path.test.mjs`. + */ +export const DIRECT_HANDOFF_TIMEOUT_MS = DIRECT_SETUP_TIMEOUT_MS; + +/** + * How long a connection may sit `disconnected` before the attempt is written + * off. ICE reports that state on a gap the connection may well recover from — a + * phone changing networks, a radio blip — and ending a switched session there + * costs a fresh handshake and a WebAuthn prompt, so the transient case is + * waited out. `failed` and `closed` are terminal and are never waited on. + */ +export const DIRECT_DISCONNECTED_GRACE_MS = 5_000; + +/** + * How much a sender holds while the channel drains, in bytes and in frames. + * + * The same pair of numbers as the receiver's hold, for the same reasons: what a + * queue has to cover is a burst of a terminal stream, bytes are what the machine + * actually holds, and the frame count sits above where the ~1 KiB frames a PTY + * produces can reach it. A sender that overruns them is one whose peer is not + * draining fast enough to stay in order, which is a dead session rather than a + * dropped frame — the same answer the receiver gives. + */ +export const MAX_DIRECT_OUTBOUND_BYTES = MAX_DIRECT_PENDING_BYTES; +export const MAX_DIRECT_OUTBOUND_FRAMES = MAX_DIRECT_PENDING_FRAMES; + +/** + * How much the channel implementation may have buffered before a sender stops + * handing it more and queues instead, and the level it must drain back to + * before sending resumes. + * + * Two levels rather than one, so a busy stream is not woken on every frame. + * {@link MAX_DIRECT_OUTBOUND_BYTES} is what bounds the wait; these only decide + * where the ciphertext sits while the association catches up. + */ +export const DIRECT_BUFFER_HIGH = 256 * 1024; +export const DIRECT_BUFFER_LOW = 64 * 1024; + +/** + * A run of channel frames bounded in both frames and bytes, **bytes binding + * first**. + * + * Both of the direct path's queues are one of these — what a receiver holds + * until the peer's switch decrypts, and what a sender holds while the channel + * drains — so "over the bound" means one thing in both directions. + * + * **What goes in is kept by reference.** Whether a frame outlives its caller's + * buffer is a question about where that buffer came from, which only the caller + * knows: a sender's frames are ciphertext its session just minted and nothing + * else holds, while a receiver's are a view over the runtime's own message + * buffer and are copied at {@link DirectCutover.onChannelFrame}. + */ +export class DirectFrameQueue { + readonly #frames: Uint8Array[] = []; + readonly #maxFrames: number; + readonly #maxBytes: number; + #bytes = 0; + + constructor(maxFrames: number, maxBytes: number) { + this.#maxFrames = maxFrames; + this.#maxBytes = maxBytes; + } + + get length(): number { + return this.#frames.length; + } + + get bytes(): number { + return this.#bytes; + } + + /** Take one frame, or answer `false` where it would break either bound. */ + push(frame: Uint8Array): boolean { + if (this.#frames.length >= this.#maxFrames || this.#bytes + frame.length > this.#maxBytes) { + return false; + } + this.#frames.push(frame); + this.#bytes += frame.length; + return true; + } + + /** The oldest frame, or `undefined` where there is none. */ + shift(): Uint8Array | undefined { + const frame = this.#frames.shift(); + if (frame) this.#bytes -= frame.length; + return frame; + } + + /** Everything held, in arrival order, leaving the queue empty. */ + take(): Uint8Array[] { + const frames = [...this.#frames]; + this.clear(); + return frames; + } + + clear(): void { + this.#frames.length = 0; + this.#bytes = 0; + } +} + +/** + * Which path carries a session's traffic. `direct` only once **both** + * directions have switched — until then the relay is still carrying half of it, + * and telling the user otherwise would be a claim about a path that is not yet + * the only one. + */ +export type DirectPath = 'relay' | 'direct'; + +/** + * Why a session is still on the relay, as the three answers a person can act + * on: this device brought no WebRTC, the other end said no, or it was tried and + * did not work. + * + * **A closed set, never the failure text.** The reasons an attempt gives up on + * include a runtime's own exception message, and a set is what keeps those out + * of a phone's screen while leaving them in the log the operator reads. It is + * also what makes a new give-up path a compile error until someone decides + * which of the three a user is being told. + */ +export type DirectRelayCause = 'unsupported' | 'declined' | 'failed'; + +/** + * The signaling messages, as `control` transport plaintexts on an established + * session. Versioned and discriminated so a peer that does not know them + * ignores them rather than failing the session. + * + * `direct-offer` is Client->Burrow, `direct-answer` and `direct-decline` are + * Burrow->Client, and `direct-switch` travels in either direction as its + * sender's last message on the relay path. + */ +export type DirectSignalV1 = + | { readonly v: 1; readonly t: 'direct-offer'; readonly sdp: string } + | { readonly v: 1; readonly t: 'direct-answer'; readonly sdp: string } + | { readonly v: 1; readonly t: 'direct-decline' } + | { readonly v: 1; readonly t: 'direct-switch' }; + +/** + * The characters an SDP may be made of: printable US-ASCII plus CR and LF. + * + * Restricting them is what turns {@link MAX_DIRECT_SDP_LENGTH} from a character + * bound into a byte bound — every one of these JSON-encodes to at most two + * bytes — and every description either end generates is already inside it. A + * description that is not stays out of the session: the attempt is abandoned + * and the relay keeps carrying it. + */ +export function isDirectSdp(value: unknown): value is string { + if (!isBoundedString(value, MAX_DIRECT_SDP_LENGTH)) return false; + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code === 0x0d || code === 0x0a) continue; + if (code < 0x20 || code > 0x7e) return false; + } + return true; +} + +/** + * Structural validation of a decrypted control message that claims to be a + * signal. Authenticated by Noise, which proves *who* sent it and nothing about + * its shape — so the keys are exact, the version is the one this end speaks, + * and the SDP is bounded before anything reads it. + * + * Answers `false` for every other control message rather than throwing: an + * unknown control shape on an established session is ignored, never a session + * failure, which is what lets a peer without this stack stay talking. + */ +export function isDirectSignalV1(value: unknown): value is DirectSignalV1 { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const signal = value as Record; + if (signal.v !== 1) return false; + const keys = Object.keys(signal); + switch (signal.t) { + case 'direct-offer': + case 'direct-answer': + return keys.length === 3 && isDirectSdp(signal.sdp); + case 'direct-decline': + case 'direct-switch': + return keys.length === 2; + default: + return false; + } +} + +/** What a relay transport frame may do once this end has read the peer's switch. */ +export type DirectRelayOutcome = 'process' | 'violation'; + +/** What one inbound channel frame turned out to be. */ +export type DirectChannelOutcome = 'process' | 'held' | 'overflow'; + +/** What the peer's `direct-switch` turned out to mean; see {@link DirectCutover.onSwitchDecrypted}. */ +export type DirectSwitchOutcome = + | { readonly kind: 'drain'; readonly frames: Uint8Array[] } + | { readonly kind: 'fatal' }; + +/** + * One end's view of the cutover, which is per direction and never negotiated: + * each side switches its own sends and learns about the peer's when the peer's + * `direct-switch` decrypts. + * + * **Order is preserved per direction** because a sender's switch is its last + * relay message and a receiver holds channel frames until it has read that + * switch — so nothing sent after it can be processed before what was sent + * before it. The holding queue is bounded in both frames and bytes; a peer that + * overruns it is one this end cannot keep in order, which is a dead session + * rather than a dropped frame. + * + * The attempt's lifecycle lives here too, so the two ends cannot disagree about + * when one may start or what a switch means after one has been given up. + */ +export class DirectCutover { + /** + * How far this end's one attempt has got: `idle` before it starts, + * `attempting` from {@link begin} until {@link abandon}, and `abandoned` + * forever after. There is no way back to `idle`, which is what makes the + * attempt once-per-session. + */ + #state: 'idle' | 'attempting' | 'abandoned' = 'idle'; + #outbound: DirectPath = 'relay'; + #inbound: DirectPath = 'relay'; + readonly #held = new DirectFrameQueue(MAX_DIRECT_PENDING_FRAMES, MAX_DIRECT_PENDING_BYTES); + + /** Where this end's own messages go. */ + get outbound(): DirectPath { + return this.#outbound; + } + + /** Where the peer's messages come from, as of the last switch it sent. */ + get inbound(): DirectPath { + return this.#inbound; + } + + /** What carries the session as a whole; see {@link DirectPath}. */ + get path(): DirectPath { + return this.#outbound === 'direct' && this.#inbound === 'direct' ? 'direct' : 'relay'; + } + + /** Whether either direction has left the relay — what a channel loss ends. */ + get switched(): boolean { + return this.#outbound === 'direct' || this.#inbound === 'direct'; + } + + get pendingFrames(): number { + return this.#held.length; + } + + get pendingBytes(): number { + return this.#held.bytes; + } + + /** + * Claim this session's one attempt. **The one-attempt-per-session gate**: a + * second call answers `false` and allocates nothing, whatever the first + * attempt did or is still doing. + */ + begin(): boolean { + if (this.#state !== 'idle') return false; + this.#state = 'attempting'; + return true; + } + + /** + * Give the attempt up, leaving the session exactly as relayed as it was, and + * release what it was holding. + * + * **Only legal before either direction has switched** — after that there is no + * relay left to fall back to, so a caller reaching here has confused an + * abandoned attempt with burrow loss. + */ + abandon(): void { + if (this.switched) throw new Error('a switched direct path cannot be abandoned'); + this.#state = 'abandoned'; + this.clear(); + } + + /** + * Move this end's sends onto the channel. The caller sends its + * `direct-switch` on the relay *first*: this is the line after which nothing + * else may. Idempotent; what keeps a duplicate open from putting a second + * switch on the wire is `DirectPeer`'s own once-only open guard. + */ + switchOutbound(): void { + this.#outbound = 'direct'; + } + + /** + * One transport frame arriving on the relay. Once the peer has switched there + * is nothing left for it to send there, so a frame that arrives anyway is a + * peer this end can no longer keep in order with the channel. + */ + onRelayTransport(): DirectRelayOutcome { + return this.#inbound === 'direct' ? 'violation' : 'process'; + } + + /** + * The peer's `direct-switch` decrypted: everything held is now known to come + * after it, so `drain` carries the held frames in arrival order and empties + * the queue. + * + * **A switch onto a channel this end has abandoned is `fatal`**: nothing that + * peer sends can arrive any more, and the alternative is a session whose every + * request hangs unanswered. + */ + onSwitchDecrypted(): DirectSwitchOutcome { + if (this.#state === 'abandoned') return { kind: 'fatal' }; + this.#inbound = 'direct'; + return { kind: 'drain', frames: this.#held.take() }; + } + + /** + * One inbound channel frame: processed once the peer's switch has been read, + * held until then, and `overflow` when holding it would break the bound. + * + * **A held frame is copied, and only a held frame.** Holding is what makes a + * frame outlive this call, and the caller's buffer — a view over whatever the + * runtime handed it — cannot be trusted to still say the same thing when the + * queue drains. A frame answered `process` is decrypted before this returns. + */ + onChannelFrame(frame: Uint8Array): DirectChannelOutcome { + if (this.#inbound === 'direct') return 'process'; + return this.#held.push(frame.slice()) ? 'held' : 'overflow'; + } + + /** Release held frames; a disposed session has nothing left to drain them. */ + clear(): void { + this.#held.clear(); + } +} diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs new file mode 100644 index 000000000..9b1f73a78 --- /dev/null +++ b/remote-lib-common/test/direct-path.test.mjs @@ -0,0 +1,358 @@ +/** + * The direct path's shared half (`docs/specs/remote-api.md` -> Transport -> + * "Direct path"): the signaling guard, the bound that keeps a signal inside one + * control message, and the cutover state machine both ends run. + * + * The endpoints that drive it are `lib/src/remote/direct/direct-peer.test.ts` + * and the in-process end-to-end cases in `pocket-client.test.ts` / + * `burrow-runtime.test.ts`. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + CONTROL_PAYLOAD_SIZE, + DIRECT_ANSWER_TIMEOUT_MS, + DIRECT_BUFFER_HIGH, + DIRECT_BUFFER_LOW, + DIRECT_DISCONNECTED_GRACE_MS, + DIRECT_GATHER_TIMEOUT_MS, + DIRECT_HANDOFF_TIMEOUT_MS, + DIRECT_SETUP_TIMEOUT_MS, + DirectCutover, + DirectFrameQueue, + MAX_DIRECT_OUTBOUND_BYTES, + MAX_DIRECT_OUTBOUND_FRAMES, + MAX_DIRECT_PENDING_BYTES, + MAX_DIRECT_PENDING_FRAMES, + MAX_DIRECT_SDP_LENGTH, + encodeTransportPlaintext, + isDirectSdp, + isDirectSignalV1, + utf8Encode, +} from '../dist/index.js'; + +const SDP = 'v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n'; + +// --- The signaling guard ---------------------------------------------------- + +test('accepts the four signals and nothing else', () => { + assert.ok(isDirectSignalV1({ v: 1, t: 'direct-offer', sdp: SDP })); + assert.ok(isDirectSignalV1({ v: 1, t: 'direct-answer', sdp: SDP })); + assert.ok(isDirectSignalV1({ v: 1, t: 'direct-decline' })); + assert.ok(isDirectSignalV1({ v: 1, t: 'direct-switch' })); + assert.ok(!isDirectSignalV1({ v: 1, t: 'direct-renegotiate' })); +}); + +test('rejects anything that is not a plain versioned object', () => { + for (const value of [null, undefined, 'direct-switch', 7, [{ v: 1, t: 'direct-switch' }]]) { + assert.ok(!isDirectSignalV1(value)); + } + // A future version is not this one: a peer that cannot read a signal ignores + // it and stays relayed rather than guessing at its fields. + assert.ok(!isDirectSignalV1({ v: 2, t: 'direct-switch' })); + assert.ok(!isDirectSignalV1({ t: 'direct-switch' })); +}); + +test('rejects an extra key, on every shape', () => { + assert.ok(!isDirectSignalV1({ v: 1, t: 'direct-offer', sdp: SDP, iceServers: ['stun:x'] })); + assert.ok(!isDirectSignalV1({ v: 1, t: 'direct-switch', after: 3 })); + // The two that carry nothing carry nothing: an SDP on a decline is a field + // no reader has, which is exactly the shape a smuggler would pick. + assert.ok(!isDirectSignalV1({ v: 1, t: 'direct-decline', sdp: SDP })); +}); + +test('rejects an sdp that is missing, not a string, or over the bound', () => { + assert.ok(!isDirectSignalV1({ v: 1, t: 'direct-offer' })); + assert.ok(!isDirectSignalV1({ v: 1, t: 'direct-offer', sdp: 42 })); + assert.ok(!isDirectSignalV1({ v: 1, t: 'direct-answer', sdp: null })); + assert.ok(isDirectSignalV1({ v: 1, t: 'direct-answer', sdp: 'a'.repeat(MAX_DIRECT_SDP_LENGTH) })); + assert.ok( + !isDirectSignalV1({ v: 1, t: 'direct-answer', sdp: 'a'.repeat(MAX_DIRECT_SDP_LENGTH + 1) }), + ); +}); + +test('an sdp is printable ASCII with CRLF, and nothing else', () => { + assert.ok(isDirectSdp(SDP)); + assert.ok(!isDirectSdp('a=candidate:\u0000')); + assert.ok(!isDirectSdp('a=x:\t')); + // A multi-byte character would encode to more than the two bytes per + // character MAX_DIRECT_SDP_LENGTH is derived from. + assert.ok(!isDirectSdp('s=café\r\n')); +}); + +/** + * The bound's whole reason: a signal has to survive + * {@link encodeTransportPlaintext}, which refuses a control body over + * `CONTROL_PAYLOAD_SIZE` rather than truncating it. The worst case is an SDP + * made entirely of characters JSON escapes, which is the widest an accepted one + * can encode to. + */ +test('any signal with a maximal sdp fits one control message', () => { + for (const t of ['direct-offer', 'direct-answer']) { + const sdp = '"'.repeat(MAX_DIRECT_SDP_LENGTH); + const signal = { v: 1, t, sdp }; + assert.ok(isDirectSignalV1(signal)); + const json = utf8Encode(JSON.stringify(signal)); + assert.ok( + json.length <= CONTROL_PAYLOAD_SIZE, + `${t} encodes to ${json.length} bytes, over the ${CONTROL_PAYLOAD_SIZE}-byte control body`, + ); + // The padded body plus its kind byte, which is what actually goes on the wire. + const plaintext = encodeTransportPlaintext({ kind: 'control', value: signal }); + assert.equal(plaintext.length, CONTROL_PAYLOAD_SIZE + 1); + } +}); + +test('the timings the spec names are the values that ship', () => { + assert.equal(DIRECT_SETUP_TIMEOUT_MS, 15_000); + assert.equal(DIRECT_ANSWER_TIMEOUT_MS, 10_000); + assert.equal(DIRECT_GATHER_TIMEOUT_MS, 3_000); + assert.ok(DIRECT_GATHER_TIMEOUT_MS < DIRECT_SETUP_TIMEOUT_MS); + // The answerer arms its deadline a relay hop after the offerer arms its own, + // so it has to be the end that gives up first: its channel closing reaches + // the offerer while the offerer is still unswitched and can abandon cleanly. + // The other order kills a healthy relayed session on a slow ICE — the + // answerer opens, switches, and its `direct-switch` decrypts at an offerer + // that has just abandoned, which is fatal at both ends. + assert.ok(DIRECT_ANSWER_TIMEOUT_MS < DIRECT_SETUP_TIMEOUT_MS); + // The wait for the peer's own switch is a relay hop on the uplink the direct + // path exists to escape, and firing early is burrow loss while waiting costs + // only queue space — so it is the setup budget itself rather than a number of + // its own. Asserted as the alias it is: `>=` would be a tautology through it, + // and a literal would fail a legitimate re-tuning of the budget. + assert.equal(DIRECT_HANDOFF_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS); + // Long enough that a gap ICE recovers from is waited out rather than charged + // a fresh handshake and a WebAuthn prompt. + assert.equal(DIRECT_DISCONNECTED_GRACE_MS, 5_000); +}); + +/** + * The two water marks bracket where ciphertext sits while the association + * catches up. They only matter relative to each other and to the queue: a low + * mark at or above the high one would wake the sender on every frame, and a + * high mark above what may be held would let the implementation buffer more + * than this side is willing to. + */ +test('the send water marks bracket the queue they feed', () => { + assert.equal(DIRECT_BUFFER_HIGH, 256 * 1024); + assert.equal(DIRECT_BUFFER_LOW, 64 * 1024); + assert.ok(DIRECT_BUFFER_LOW < DIRECT_BUFFER_HIGH); + assert.ok(DIRECT_BUFFER_HIGH < MAX_DIRECT_OUTBOUND_BYTES); +}); + +/** + * A sender holds what a receiver holds. The window each covers is a burst of + * the same terminal stream, so sizing them apart would mean one of the two + * numbers had a reason the other did not. + */ +test('both directions are bounded the same way', () => { + assert.equal(MAX_DIRECT_OUTBOUND_BYTES, MAX_DIRECT_PENDING_BYTES); + assert.equal(MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_PENDING_FRAMES); +}); + +// --- The queue both directions use ------------------------------------------ + +const frame = (n, size = 4) => new Uint8Array(size).fill(n); + +test('a queue admits frames until either bound, bytes first at PTY sizes', () => { + const queue = new DirectFrameQueue(4, 10); + assert.equal(queue.push(frame(1, 6)), true); + assert.equal(queue.length, 1); + assert.equal(queue.bytes, 6); + // 6 + 5 is over the byte cap while the frame cap has room to spare, and a + // refused frame changes nothing. + assert.equal(queue.push(frame(2, 5)), false); + assert.equal(queue.length, 1); + assert.equal(queue.push(frame(2, 4)), true); +}); + +test('a queue admits no more frames than its frame cap, whatever their size', () => { + const queue = new DirectFrameQueue(2, 1_000); + assert.equal(queue.push(frame(1, 1)), true); + assert.equal(queue.push(frame(2, 1)), true); + assert.equal(queue.push(frame(3, 1)), false); +}); + +/** + * Whether a frame outlives its caller's buffer is a question about where that + * buffer came from, which only the caller knows — a sender's is ciphertext its + * session just minted and nothing else holds, a receiver's is a view over the + * runtime's own message buffer. So the queue keeps what it is given, and + * `DirectCutover` is what copies (see "a held frame is copied", below). + */ +test('a queue keeps what it is given, without copying it', () => { + const queue = new DirectFrameQueue(4, 100); + const buffer = frame(1); + queue.push(buffer); + buffer.fill(9); + assert.deepEqual(queue.take(), [frame(9)]); +}); + +test('a queue gives frames back in arrival order, and empties on take', () => { + const queue = new DirectFrameQueue(4, 100); + queue.push(frame(1)); + queue.push(frame(2)); + assert.deepEqual(queue.shift(), frame(1)); + assert.equal(queue.bytes, 4); + assert.deepEqual(queue.take(), [frame(2)]); + assert.equal(queue.length, 0); + assert.equal(queue.bytes, 0); + assert.equal(queue.shift(), undefined); +}); + +/** + * The two holding bounds are not independent: the byte cap is the one meant to + * bind, and the frame cap only exists so a peer cannot hold the queue open with + * frames too small to fill it. + */ +test('the holding queue is bounded in bytes first', () => { + assert.equal(MAX_DIRECT_PENDING_BYTES, 4 * 1024 * 1024); + // A PTY emits ~1 KiB chunks uncoalesced, one per channel frame, so at the + // frame size terminal traffic actually has the byte cap is reached first. + assert.ok( + MAX_DIRECT_PENDING_FRAMES * 1024 >= MAX_DIRECT_PENDING_BYTES, + `${MAX_DIRECT_PENDING_FRAMES} frames of 1 KiB is under the ${MAX_DIRECT_PENDING_BYTES}-byte cap`, + ); + // And the window it covers is one relay one-way hop, which is hundreds of + // milliseconds to a phone on cellular: half a second of a fast stream fits. + assert.ok(MAX_DIRECT_PENDING_BYTES >= 0.5 * 5_000_000); +}); + +// --- The cutover ------------------------------------------------------------ + +test('starts relayed in both directions', () => { + const cutover = new DirectCutover(); + assert.equal(cutover.outbound, 'relay'); + assert.equal(cutover.inbound, 'relay'); + assert.equal(cutover.path, 'relay'); + assert.equal(cutover.switched, false); + assert.equal(cutover.onRelayTransport(), 'process'); +}); + +test('claims the session’s one attempt, and never a second', () => { + const cutover = new DirectCutover(); + assert.equal(cutover.begin(), true); + // The one-attempt-per-session gate: a second offer allocates nothing. + assert.equal(cutover.begin(), false); + cutover.abandon(); + // And an attempt that was given up cannot be restarted either. + assert.equal(cutover.begin(), false); +}); + +test('abandoning releases what it held, and is refused once switched', () => { + const cutover = new DirectCutover(); + cutover.begin(); + cutover.onChannelFrame(frame(1)); + cutover.abandon(); + assert.equal(cutover.pendingFrames, 0); + assert.equal(cutover.pendingBytes, 0); + + // After a switch there is no relay to fall back to, so a caller reaching here + // has confused an abandoned attempt with burrow loss. + const switched = new DirectCutover(); + switched.begin(); + switched.switchOutbound(); + assert.throws(() => switched.abandon(), /cannot be abandoned/); +}); + +test('a switch onto an abandoned channel is fatal, and a switch while attempting drains', () => { + const abandoned = new DirectCutover(); + abandoned.begin(); + abandoned.abandon(); + assert.deepEqual(abandoned.onSwitchDecrypted(), { kind: 'fatal' }); + // Refused before anything moved: the session is over, not half switched. + assert.equal(abandoned.inbound, 'relay'); + + const attempting = new DirectCutover(); + attempting.begin(); + attempting.onChannelFrame(frame(1)); + attempting.onChannelFrame(frame(2)); + assert.deepEqual(attempting.onSwitchDecrypted(), { + kind: 'drain', + frames: [frame(1), frame(2)], + }); + assert.equal(attempting.inbound, 'direct'); +}); + +test('switches each direction on its own, and only both make it direct', () => { + const cutover = new DirectCutover(); + cutover.begin(); + cutover.switchOutbound(); + // Idempotent, so a duplicate open cannot move the cutover twice. + cutover.switchOutbound(); + assert.equal(cutover.outbound, 'direct'); + assert.equal(cutover.switched, true); + // The peer is still on the relay, so the relay still carries half of it. + assert.equal(cutover.path, 'relay'); + assert.equal(cutover.onRelayTransport(), 'process'); + + cutover.onSwitchDecrypted(); + assert.equal(cutover.inbound, 'direct'); + assert.equal(cutover.path, 'direct'); +}); + +test('holds channel frames until the peer’s switch, then drains them in order', () => { + const cutover = new DirectCutover(); + assert.equal(cutover.onChannelFrame(frame(1)), 'held'); + assert.equal(cutover.onChannelFrame(frame(2)), 'held'); + assert.equal(cutover.pendingFrames, 2); + assert.equal(cutover.pendingBytes, 8); + + const drained = cutover.onSwitchDecrypted(); + assert.deepEqual(drained.frames, [frame(1), frame(2)]); + assert.equal(cutover.pendingFrames, 0); + assert.equal(cutover.pendingBytes, 0); + // Everything after the switch is read straight through. + assert.equal(cutover.onChannelFrame(frame(3)), 'process'); +}); + +test('a relay transport after the peer’s switch is a violation', () => { + const cutover = new DirectCutover(); + cutover.onSwitchDecrypted(); + assert.equal(cutover.onRelayTransport(), 'violation'); +}); + +test('overflows on the frame cap', () => { + const cutover = new DirectCutover(); + for (let i = 0; i < MAX_DIRECT_PENDING_FRAMES; i += 1) { + assert.equal(cutover.onChannelFrame(frame(i)), 'held'); + } + assert.equal(cutover.onChannelFrame(frame(0)), 'overflow'); + // Refused, not counted: the queue is exactly at its cap. + assert.equal(cutover.pendingFrames, MAX_DIRECT_PENDING_FRAMES); +}); + +test('overflows on the byte cap, whatever the frame count', () => { + const cutover = new DirectCutover(); + const big = MAX_DIRECT_PENDING_BYTES / 2; + assert.equal(cutover.onChannelFrame(new Uint8Array(big)), 'held'); + assert.equal(cutover.onChannelFrame(new Uint8Array(big)), 'held'); + assert.equal(cutover.pendingBytes, MAX_DIRECT_PENDING_BYTES); + assert.equal(cutover.onChannelFrame(new Uint8Array(1)), 'overflow'); + assert.ok(cutover.pendingFrames < MAX_DIRECT_PENDING_FRAMES); +}); + +/** + * A held frame is the one thing here that outlives the call that delivered it, + * so it is the one the caller's buffer cannot be trusted for: both ends hand + * over a view, and a runtime that pools or reuses that buffer would otherwise + * drain whatever landed in it next. + */ +test('a held frame is copied, so the caller’s buffer may be reused', () => { + const cutover = new DirectCutover(); + const buffer = frame(1); + assert.equal(cutover.onChannelFrame(buffer), 'held'); + buffer.fill(9); + + assert.deepEqual(cutover.onSwitchDecrypted(), { kind: 'drain', frames: [frame(1)] }); +}); + +test('clear releases what a disposed session will never drain', () => { + const cutover = new DirectCutover(); + cutover.onChannelFrame(frame(1)); + cutover.clear(); + assert.equal(cutover.pendingFrames, 0); + assert.equal(cutover.pendingBytes, 0); +}); diff --git a/scripts/direct-interop/browser.ts b/scripts/direct-interop/browser.ts new file mode 100644 index 000000000..f51bcf9da --- /dev/null +++ b/scripts/direct-interop/browser.ts @@ -0,0 +1,105 @@ +/** + * The browser half of the direct path's interop fixture: the Client's side, + * exactly as Pocket builds it (`docs/specs/remote-api.md` -> Transport -> + * "Direct path"). + * + * Bundled and served by `./run.mjs`; see its header for how to run the pair. + * The wrapper under test is the shipped `DirectPeer` — nothing here + * reimplements a negotiation — over the browser's own `RTCPeerConnection` with + * no ICE servers, which is the one combination the in-process suites cannot + * reach: `direct-peer.test.ts` links two fakes, and `native-direct-peer.test.ts` + * runs the addon against itself. + */ + +import { MAX_DIRECT_SDP_LENGTH } from 'remote-lib-common'; +import { DirectPeer, type DirectPeerLike } from '../../lib/src/remote/direct/direct-peer'; + +/** + * What the page reports back for `run.mjs` to print. Test data only. + * + * **Measurements, not a verdict.** `run.mjs` decides whether the run passed, + * against the frames it actually got back — the strictly stronger check, and + * one definition of "finished" rather than two that can disagree. + */ +export interface BrowserReport { + readonly error?: string; + /** The offer this browser actually produced, against the signal's bound. */ + readonly offerSdpLength?: number; + readonly maxDirectSdpLength: number; + /** Candidate lines in that offer — what makes a multi-homed machine large. */ + readonly candidates?: number; + /** The association's own per-message limit, as this browser reports it. */ + readonly maxMessageSize?: number | null; + /** The size of every frame echoed back, in the order this end saw them. */ + readonly echoed?: number[]; +} + +declare const __INTEROP_TOKEN__: string; + +/** How long after the channel opens the page reports what it echoed. */ +const ECHO_SETTLE_MS = 2_000; + +async function post(path: string, body: unknown): Promise { + const response = await fetch(`${path}?t=${encodeURIComponent(__INTEROP_TOKEN__)}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new Error(`${path} answered ${response.status}`); + return response.json(); +} + +async function run(): Promise { + const echoed: number[] = []; + const connection = new RTCPeerConnection({ iceServers: [] }) as unknown as DirectPeerLike; + const opened = Promise.withResolvers(); + const lost = Promise.withResolvers(); + + const peer: DirectPeer = new DirectPeer({ + peer: connection, + handlers: { + onOpen: () => opened.resolve(), + // The addon's side sends; this end echoes each frame straight back, so + // the bytes and the order they arrive in are checked over a real + // association rather than a linked pair. The frame is a view over the + // event's buffer, and `send` may queue it — so it is copied here, which + // production never has to do. + onFrame: (frame) => { + echoed.push(frame.length); + peer.send(frame.slice()); + }, + onClosed: (reason) => lost.reject(new Error(reason)), + onViolation: (reason) => lost.reject(new Error(reason)), + }, + }); + + const sdp = await peer.offer(); + const measured = { + maxDirectSdpLength: MAX_DIRECT_SDP_LENGTH, + offerSdpLength: sdp?.length, + candidates: sdp ? (sdp.match(/^a=candidate/gm) ?? []).length : undefined, + }; + // `offer()` answers null for a description this end would not send, which on + // a machine with many interfaces is the interesting outcome rather than a + // crash: the attempt is skipped and the session stays relayed. + if (!sdp) return { ...measured, error: 'the offer did not fit one signal' }; + + const answer = (await post('/answer', { sdp })) as { sdp?: string; error?: string }; + if (!answer.sdp) return { ...measured, error: answer.error ?? 'no answer' }; + await peer.acceptAnswer(answer.sdp); + await Promise.race([opened.promise, lost.promise]); + + const settled = new Promise((resolve) => setTimeout(resolve, ECHO_SETTLE_MS)); + await Promise.race([settled, lost.promise]); + + return { ...measured, maxMessageSize: connection.sctp?.maxMessageSize ?? null, echoed }; +} + +function show(report: BrowserReport): void { + document.body.textContent = JSON.stringify(report, null, 2); + void post('/report', report); +} + +run().then(show, (error: unknown) => { + show({ error: String(error), maxDirectSdpLength: MAX_DIRECT_SDP_LENGTH }); +}); diff --git a/scripts/direct-interop/run.mjs b/scripts/direct-interop/run.mjs new file mode 100644 index 000000000..5955b79b1 --- /dev/null +++ b/scripts/direct-interop/run.mjs @@ -0,0 +1,255 @@ +/** + * Browser-to-addon interop for the direct path (`docs/specs/remote-api.md` -> + * Transport -> "Direct path"). Manual, and test data only. + * + * ```sh + * dor ensure -- pnpm exec node scripts/direct-interop/run.mjs + * dor ab --key direct-interop open "$(cat "$TMPDIR/dormouse-direct-interop.url")" + * ``` + * + * The URL carries a per-run token, so it is written to that file as well as + * printed: a terminal pane wraps it into unreadable single characters. + * + * **The combination nothing else covers.** `direct-peer.test.ts` links two + * in-memory fakes and `native-direct-peer.test.ts` runs the addon against + * itself; what actually ships is a phone's browser stack negotiating with + * `node-datachannel` in the sidecar, and no CI job has a browser. So this pair + * is the shipped one: the browser offers, as Pocket does, and the addon answers, + * as the standalone Burrow does — over the shipped `DirectPeer` on both sides, + * with `iceServers: []` at both ends. + * + * It answers three questions a fake cannot: + * 1. Does a real browser's offer fit `MAX_DIRECT_SDP_LENGTH`, and by how + * much? That bound is derived from one padded control body, and an SDP + * over it silently costs the direct path on that machine. + * 2. Does a real association carry a whole `NOISE_MAX_MESSAGE_LENGTH` frame + * between the two stacks, in order, byte for byte? + * 3. Do the two `DirectPeerLike` implementations satisfy the seam as written? + * + * Run it on a machine with the interfaces you care about — a tailnet, a VPN, + * docker bridges — since question 1 is a property of the host, not the code. + */ + +import { createServer } from 'node:http'; +import { randomBytes } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isOwnOrigin } from '../../lib/src/host/loopback-guard.ts'; +// The same gate `standalone/scripts/dev-agent-browser.mjs` uses, for the same +// reason: an unbundled dev script cannot import the TypeScript guard, and the +// loopback-plus-token rule must not have a second implementation. +import { isAuthorized } from '../../standalone/scripts/dev-host-guard.mjs'; + +/** What the addon puts on the channel, largest first; see question 2 above. */ +const FRAME_SIZES = [65_535, 4_096, 33]; +/** How long the whole run is given before it reports what it has. */ +const RUN_BUDGET_MS = 60_000; + +const here = (path) => fileURLToPath(new URL(path, import.meta.url)); +// esbuild resolves a bare specifier from the importing file's own directory, +// and `scripts/` is not a package: `browser.ts` names `remote-lib-common`, +// which the workspace links under `lib/`. +const LIB = here('../../lib'); +const sidecarRequire = createRequire(here('../../standalone/sidecar/package.json')); +const buildRequire = createRequire(here('../../standalone/package.json')); +const { build } = buildRequire('esbuild'); + +const token = randomBytes(24).toString('hex'); +const temp = await mkdtemp(join(tmpdir(), 'dormouse-direct-interop-')); + +// The wrapper under test is bundled rather than imported: it is TypeScript +// that reaches into the webview library, and this file is neither. The two +// bundles share nothing, so they are built together. +const [, browserBundle] = await Promise.all([ + build({ + entryPoints: [here('../../lib/src/remote/direct/direct-peer.ts')], + outfile: join(temp, 'direct-peer.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'warning', + }), + build({ + entryPoints: [here('./browser.ts')], + absWorkingDir: LIB, + nodePaths: [join(LIB, 'node_modules')], + bundle: true, + platform: 'browser', + format: 'iife', + target: 'es2023', + write: false, + logLevel: 'warning', + define: { __INTEROP_TOKEN__: JSON.stringify(token) }, + }), +]); +const { DirectPeer } = createRequire(import.meta.url)(join(temp, 'direct-peer.cjs')); +const javascript = browserBundle.outputFiles[0].text; + +// `iceServers: []` as both shipped factories pass it: host candidates only. +const { RTCPeerConnection } = sidecarRequire('node-datachannel/polyfill'); +const addon = sidecarRequire('node-datachannel'); + +const frames = FRAME_SIZES.map((size, index) => Buffer.alloc(size, index + 1)); +/** What the addon got back, in the order it got it. */ +const returned = []; +let browserReport = null; +let verdict = null; + +const done = Promise.withResolvers(); +const finish = (result) => { + if (verdict) return; + verdict = result; + done.resolve(); +}; + +/** Whether every frame came back byte for byte, in the order it was sent. */ +const echoedIntact = () => + returned.length === frames.length && returned.every((frame, index) => frame.equals(frames[index])); + +/** + * The run ends when both halves have spoken. The page's report and the last + * echo race each other over two different transports — the report goes back up + * the loopback HTTP the page was served on, the echoes over the channel — so + * neither one on its own says the exchange finished. + */ +const maybeFinish = () => { + if (!browserReport || !echoedIntact()) return; + finish({ ok: !browserReport.error, error: browserReport.error }); +}; + +const peer = new DirectPeer({ + peer: new RTCPeerConnection({ iceServers: [] }), + handlers: { + // The addon sends; a failure comes back through `onClosed` in its own words. + onOpen: () => { + for (const frame of frames) peer.send(frame); + }, + onFrame: (frame) => { + // Copied out of the event's buffer, which the runtime may reuse. + returned.push(Buffer.from(frame)); + maybeFinish(); + }, + onClosed: (reason) => finish({ ok: false, error: `channel gone: ${reason}` }), + onViolation: (reason) => finish({ ok: false, error: `violation: ${reason}` }), + }, +}); + +const readJson = (req) => + new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + if (body.length > 1_000_000) reject(new Error('body too large')); + }); + req.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + }); + +const server = createServer(async (req, res) => { + const send = (status, body) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + try { + const port = server.address().port; + const url = new URL(req.url, `http://127.0.0.1:${port}`); + // A loopback bind is not an access control: any page in the user's browser + // reaches 127.0.0.1 too (`docs/specs/security-local.md` -> "Loopback + // Listeners"), so the shared guard gates the Host header, the per-run + // token, and a POST's content-type, and `isOwnOrigin` gates the rest. + if (!isAuthorized(req, { token, port })) { + send(403, { error: 'forbidden' }); + return; + } + if (url.pathname === '/') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end( + `direct interop` + + `
negotiating…
`, + ); + return; + } + if (req.method !== 'POST' || !isOwnOrigin(req.headers.origin, port)) { + send(403, { error: 'forbidden' }); + return; + } + if (url.pathname === '/answer') { + const { sdp } = await readJson(req); + const answer = await peer.answer(String(sdp)); + if (!answer) { + send(200, { error: 'the addon would not answer that offer' }); + finish({ ok: false, error: 'the addon declined the browser offer' }); + return; + } + send(200, { sdp: answer }); + return; + } + if (url.pathname === '/report') { + browserReport = await readJson(req); + send(200, { ok: true }); + // A page that gave up says so at once; otherwise the last echoes may + // still be in flight, and the run ends when they land. + if (browserReport.error) finish({ ok: false, error: browserReport.error }); + else maybeFinish(); + return; + } + send(404, { error: 'not found' }); + } catch (error) { + send(500, { error: String(error) }); + } +}); + +/** Where the run's URL is left, since a narrow pane wraps it unreadably. */ +const URL_FILE = join(tmpdir(), 'dormouse-direct-interop.url'); + +server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + const url = `http://127.0.0.1:${port}/?t=${token}`; + void writeFile(URL_FILE, url); + console.log(`listening on 127.0.0.1:${port}`); + console.log(` dor ab --key direct-interop open "$(cat ${URL_FILE})"`); +}); + +setTimeout( + () => + finish({ + ok: false, + error: browserReport + ? 'the addon did not get its frames back intact' + : 'the page never reported', + }), + RUN_BUDGET_MS, +); +await done.promise; + +console.log( + JSON.stringify( + { + ...verdict, + browser: browserReport, + addon: { + received: returned.map((frame) => frame.length), + expected: FRAME_SIZES, + libraryVersion: addon.getLibraryVersion(), + }, + }, + null, + 2, + ), +); + +peer.close(); +addon.cleanup(); +server.close(); +await rm(temp, { recursive: true, force: true }); +await rm(URL_FILE, { force: true }); +process.exit(verdict.ok ? 0 : 1); diff --git a/scripts/e2e-lint.mjs b/scripts/e2e-lint.mjs index f4fa4ef03..3273029c1 100644 --- a/scripts/e2e-lint.mjs +++ b/scripts/e2e-lint.mjs @@ -9,7 +9,8 @@ * Why this exists: the properties the trust boundary rests on are *absences* — * one Noise suite and no way to select another, no JavaScript curve, no * plaintext relay route, no legacy frame discriminant left to answer, no - * Relay-side view of protocol-v1, no checked-in service worker shadowing the + * Relay-side view of protocol-v1 or of the direct path's signaling, no ICE + * server, no checked-in service worker shadowing the * built one. An absence is exactly what a reviewer stops noticing: nothing in a * diff says "a second cipher suite is now reachable", and the nightly audit is * thorough but probabilistic. This makes the cheap half deterministic, so @@ -74,6 +75,12 @@ const E2E_MODULES = [ // `passkey.ts` does, which is why that one is out of scope and this one is in. 'remote-lib-common/src/security/presence.ts', 'remote-lib-common/src/security/acl.ts', + // The direct path carries the same promoted session onto a data channel, so + // it is inside the boundary for the same reasons the transport is: one key + // agreement, one AEAD, and no second construction reachable from either. + 'remote-lib-common/src/security/direct-path.ts', + 'lib/src/remote/direct/direct-endpoint.ts', + 'lib/src/remote/direct/direct-peer.ts', 'remote-lib-common/src/remote/wire.ts', 'lib/src/remote/burrow/burrow-runtime.ts', 'lib/src/remote/burrow/push-delivery.ts', @@ -223,6 +230,45 @@ export const RULES = [ violationFile: 'relay/src/relay.ts', violation: "\nimport type { DirectoryEntry } from 'remote-lib-common';\n", }, + { + rule: 'No STUN or TURN URL in shipped source', + security: 'any ICE server reaches shipped source', + kind: 'forbid', + trees: SOURCE_TREES, + // Anchored on the quote that opens the literal, because the bare scheme is + // a substring of ordinary prose: `// ... early return:` and `Saturn:` both + // contain `turn:`, and a rule that reddened on those would be deleted. + pattern: /['"`](?:stuns?|turns?):/g, + violationFile: 'lib/src/remote/pocket-app/App.tsx', + violation: "\nconst __selftest = 'stun:stun.example.net:19302';\n", + }, + { + rule: 'No non-empty `iceServers` list anywhere', + security: 'any ICE server reaches shipped source', + kind: 'forbid', + trees: SOURCE_TREES, + // The empty array is the shipped value at both ends and must keep passing, + // so the pattern demands a first element: anything after `[` that is + // neither whitespace nor the closing bracket. A server assembled at runtime + // is past what a regex can see, which is why the spec row is read by hand + // as well. + pattern: /iceServers\s*:\s*\[\s*[^\]\s]/g, + violationFile: 'lib/src/remote/pocket-app/App.tsx', + violation: '\nconst __selftest = { iceServers: [{ urls: [] }] };\n', + }, + { + rule: 'The Relay never names a direct-path signal or an SDP', + security: 'any signaling leaves the ciphertext', + kind: 'forbid', + trees: ['relay/src/'], + // The signals ride as `control` messages inside the session, so the Relay + // routes them without knowing they exist. Naming one is the leading + // indicator that a route, a guard, or a frame type has started to care — + // the same reasoning as the protocol-v1 rule above. + pattern: /\b(?:direct-offer|direct-answer|direct-decline|direct-switch|RTCPeerConnection|sdp)\b/gi, + violationFile: 'relay/src/relay.ts', + violation: "\nconst __selftest = { sdp: '' };\n", + }, { rule: 'No checked-in service worker beside the built one', security: 'the worker in `lib/src/remote/pocket-app/sw.ts` is the only thing that opens one', @@ -257,6 +303,14 @@ export const RULES = [ // Every one of these is load-bearing on every message that carries it, so // an optional spelling is a shape where a peer can simply omit the // authentication and have the type still check. + // + // `lib/src/remote/direct/direct-peer.ts` is deliberately not in this list, + // even though it is an `E2E_MODULES` entry: its `readonly sdp?: string` + // mirrors `RTCSessionDescriptionInit`, whose optionality is the W3C API's, + // not ours. The signal that *does* carry an SDP over the wire keeps it + // required — `DirectSignalV1` in + // `remote-lib-common/src/security/direct-path.ts`, whose guard demands the + // exact key set. pattern: /\b(?:ct|salt|sealed|handshakeHash|key|ciphertext|plaintext|proof|assertion)[ \t]*\?[ \t]*:/g, violationFile: 'remote-lib-common/src/security/push-seal.ts', violation: '\nexport interface SelftestSeal {\n readonly ct?: string;\n}\n', diff --git a/scripts/public-docs-lint.mjs b/scripts/public-docs-lint.mjs index cff78169e..2adcefeff 100644 --- a/scripts/public-docs-lint.mjs +++ b/scripts/public-docs-lint.mjs @@ -495,9 +495,13 @@ async function checkGenerated() { /** * Public copy must not present staged remote transports as shipped. * - * Scoped by the spec that stages them: the ban applies only while WebRTC is - * still below docs/specs/remote-api.md's `## Future` fold, so promoting it - * retires this rule in the same commit that ships it. + * Scoped by the spec that stages them: the ban holds while docs/specs/ + * remote-api.md's `## Future` still names WebRTC, and the last item that does + * retires this rule in the commit that ships it. The direct path is half + * promoted — the phone offers one and the standalone Burrow answers, while the + * VS Code Burrow and relay-supplied ICE servers are still staged — so "Dormouse + * connects your phone directly" is exactly the sentence this rule is still here + * to stop, since it would be false for a VS Code user reading it. */ function checkNoStagedClaims() { const api = readRepoFile('docs/specs/remote-api.md'); diff --git a/scripts/sign-and-deploy.sh b/scripts/sign-and-deploy.sh index 0a9a50da4..7468f4457 100755 --- a/scripts/sign-and-deploy.sh +++ b/scripts/sign-and-deploy.sh @@ -629,6 +629,12 @@ sign_macos_app() { || error "Signed Node sidecar failed to launch" (cd "$sidecar_dir" && "$node_sidecar" -e "require('node-pty')") \ || error "Signed Node sidecar failed to load node-pty" + # The direct path's addon, loaded through the same bare specifier the + # sidecar uses. Its own `.node` is signed by the sweep above, but only a + # load proves the hardened runtime lets it open one — and nothing before a + # phone's first `direct-offer` on a user's machine would otherwise find out. + (cd "$sidecar_dir" && "$node_sidecar" -e "require('node-datachannel/polyfill')") \ + || error "Signed Node sidecar failed to load node-datachannel" log "macOS signing complete ($arch_label)" } diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 8472f3d44..c3a0e8206 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -13,18 +13,18 @@ "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3700, - "docs/specs/pocket-app.md": 4050, - "docs/specs/relay.md": 10150, - "docs/specs/remote-api.md": 3600, - "docs/specs/remote-security-model.md": 4200, + "docs/specs/pocket-app.md": 4400, + "docs/specs/relay.md": 10200, + "docs/specs/remote-api.md": 4700, + "docs/specs/remote-security-model.md": 4750, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, "docs/specs/security-local.md": 2550, - "docs/specs/security-remote.md": 4900, - "docs/specs/security-supply-chain.md": 1150, + "docs/specs/security-remote.md": 5800, + "docs/specs/security-supply-chain.md": 1200, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1000, - "docs/specs/standalone.md": 4400, + "docs/specs/standalone.md": 4550, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index 8b86f2b4e..9c7a6dc78 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -6,6 +6,7 @@ // - lib/src/host/remote/sidecar-entry.ts → sidecar/burrow.cjs // See docs/specs/dor-browser.md and docs/specs/remote-api.md. import { build } from 'esbuild'; +import { readFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -23,6 +24,28 @@ const sidecar = path.resolve(here, '../sidecar'); // so this is the enforcement point — there is no webview CSP in front of it. const remoteSrc = resolveRemoteConnectSrc(process.env, 'sidecar'); +// What the sidecar installs at runtime, read from the manifest that installs +// it: `node-datachannel` resolves its platform package and `detect-libc` +// relative to its own `__dirname`, so every one of these has to stay an +// installed package under `sidecar/node_modules` and be required by name — +// inlining one here would leave that loader looking beside `burrow.cjs`. +// Derived rather than listed, so declaring a dependency is what keeps it out. +const SIDECAR_RUNTIME_DEPS = Object.keys( + JSON.parse(readFileSync(path.resolve(sidecar, 'package.json'), 'utf8')).dependencies ?? {}, +); +// Each package by name, plus every subpath export of it (`node-datachannel/polyfill`). +const NATIVE_DIRECT = SIDECAR_RUNTIME_DEPS.flatMap((name) => [name, `${name}/*`]); +// The list `assertNothingInlined` checks is this same one, so a manifest that +// stopped declaring the addon would take the check away with the `external` +// entry and the build would go green on a `burrow.cjs` that cannot load it. +if (!SIDECAR_RUNTIME_DEPS.includes('node-datachannel')) { + throw new Error( + 'sidecar: package.json no longer declares "node-datachannel" under "dependencies" — it would ' + + 'be inlined into burrow.cjs, and nothing before the first direct-offer on a real machine ' + + 'would notice.', + ); +} + const bundles = [ { entry: 'iframe-proxy.ts', out: 'iframe-proxy.cjs' }, { entry: 'agent-browser-host.ts', out: 'agent-browser-host.cjs' }, @@ -31,17 +54,53 @@ const bundles = [ out: 'burrow.cjs', define: { [CONNECT_SRC_PLACEHOLDER]: JSON.stringify(remoteSrc) }, assertBaked: true, + external: NATIVE_DIRECT, }, ]; +/** + * Fail the build if esbuild inlined a package the sidecar installs at runtime. + * + * `native-direct-peer.ts` calls `require('')` by literal, so an + * external specifier stays a `require-call` edge out of the bundle while a + * bundled one becomes an input of it. That difference is the whole check: an + * inlined addon produces a `burrow.cjs` that loads and then cannot find the + * addon's `.node` file, which nothing before the first `direct-offer` on a real + * machine would notice. + */ +function assertNothingInlined(metafile, outfile, names) { + // esbuild keys `metafile.outputs` by path relative to the process cwd, with + // `/` separators on every platform. + const outputKey = path.relative(process.cwd(), outfile).split(path.sep).join('/'); + const output = metafile.outputs[outputKey]; + if (!output) { + throw new Error( + `sidecar: esbuild metafile has no output for "${outputKey}" — cannot check what it bundled.`, + ); + } + for (const name of names) { + // Every layout a package manager resolves through ends in this segment, + // pnpm's content-addressed store included. + const inlined = Object.keys(output.inputs).find((input) => + input.includes(`node_modules/${name}/`), + ); + if (!inlined) continue; + throw new Error( + `sidecar: ${outputKey} inlined "${inlined}" — "${name}" is a sidecar runtime dependency, and ` + + 'the addon would look for its platform package beside the bundle instead of inside ' + + 'sidecar/node_modules.', + ); + } +} + // `tauri.conf.json`'s `bundle.resources` globs this whole directory, so a // pre-rename `remote-host.cjs` left in an older checkout would ship inside the // app — a dead Burrow with its own baked connect-src allowlist. await rm(path.resolve(sidecar, 'remote-host.cjs'), { force: true }); -for (const { entry, out, define, assertBaked } of bundles) { +for (const { entry, out, define, assertBaked, external } of bundles) { const outfile = path.resolve(sidecar, out); - await build({ + const result = await build({ entryPoints: [path.resolve(libHost, entry)], outfile, bundle: true, @@ -50,7 +109,10 @@ for (const { entry, out, define, assertBaked } of bundles) { target: 'node24', logLevel: 'warning', ...(define ? { define } : {}), + // Only the bundle with externals to check reads one. + ...(external ? { external, metafile: true } : {}), }); if (assertBaked) assertConnectSrcBaked(outfile, remoteSrc); + if (external) assertNothingInlined(result.metafile, outfile, SIDECAR_RUNTIME_DEPS); console.log(`[sidecar] built ${path.relative(process.cwd(), outfile)}`); } diff --git a/standalone/sidecar/package.json b/standalone/sidecar/package.json index 2973c8682..ac799408f 100644 --- a/standalone/sidecar/package.json +++ b/standalone/sidecar/package.json @@ -7,6 +7,16 @@ "test": "node --test" }, "dependencies": { + "detect-libc": "2.1.2", + "node-datachannel": "0.33.2", "node-pty": "1.2.0-beta.15" + }, + "optionalDependencies": { + "@node-datachannel/darwin-arm64": "0.33.2", + "@node-datachannel/darwin-x64": "0.33.2", + "@node-datachannel/linux-arm64-gnu": "0.33.2", + "@node-datachannel/linux-x64-gnu": "0.33.2", + "@node-datachannel/win32-arm64-msvc": "0.33.2", + "@node-datachannel/win32-x64-msvc": "0.33.2" } } diff --git a/website/scripts/dependency-workspaces.js b/website/scripts/dependency-workspaces.js index 3904855fa..2e017c855 100644 --- a/website/scripts/dependency-workspaces.js +++ b/website/scripts/dependency-workspaces.js @@ -14,7 +14,7 @@ export function assertWorkspaceCoverage(workspacePackages, roots, exclusions) { if (covered.has(name)) return; covered.add(name); const pkg = byName.get(name); - for (const dependency of getDependencyNames(pkg)) { + for (const { name: dependency } of getDependencyNames(pkg)) { if (byName.has(dependency)) visit(dependency); } } @@ -29,6 +29,12 @@ export function assertWorkspaceCoverage(workspacePackages, roots, exclusions) { } } +// `optional` rides along because an unresolvable dependency means something +// different on each edge: a required one is a broken install, an optional one is +// a package this machine cannot hold. export function getDependencyNames(pkg) { - return [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.optionalDependencies ?? {})]; + return [ + ...Object.keys(pkg.dependencies ?? {}).map((name) => ({ name, optional: false })), + ...Object.keys(pkg.optionalDependencies ?? {}).map((name) => ({ name, optional: true })), + ]; } diff --git a/website/scripts/dependency-workspaces.test.js b/website/scripts/dependency-workspaces.test.js index be0233366..8ccb35381 100644 --- a/website/scripts/dependency-workspaces.test.js +++ b/website/scripts/dependency-workspaces.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { assertWorkspaceCoverage } from './dependency-workspaces.js'; +import { assertWorkspaceCoverage, getDependencyNames } from './dependency-workspaces.js'; const workspace = (name, fields = {}) => ({ pkg: { name, ...fields } }); @@ -35,3 +35,20 @@ describe('dependency disclosure workspace coverage', () => { .toThrow('Workspace package names must be unique'); }); }); + +describe('dependency edges', () => { + it('flags which edges are optional and drops development ones', () => { + expect(getDependencyNames({ + dependencies: { runtime: '1.0.0' }, + optionalDependencies: { 'native-darwin': '2.0.0' }, + devDependencies: { builder: '3.0.0' }, + })).toEqual([ + { name: 'runtime', optional: false }, + { name: 'native-darwin', optional: true }, + ]); + }); + + it('accepts a manifest declaring neither block', () => { + expect(getDependencyNames({})).toEqual([]); + }); +}); diff --git a/website/scripts/generate-deps.js b/website/scripts/generate-deps.js index 5dc5f6371..557c282da 100644 --- a/website/scripts/generate-deps.js +++ b/website/scripts/generate-deps.js @@ -106,30 +106,55 @@ const workspacePackagesByName = new Map(workspacePackages.map((workspacePackage) workspacePackage.pkg.name, workspacePackage, ])); +const productRoots = new Set(productDependencyFilters); const externalPackages = new Map(); const visitedExternalPackagePaths = new Set(); const visitedWorkspacePackageNames = new Set(); +/** + * Optional dependencies of a product root this machine cannot install, mapped to + * the siblings they may be described from. See + * docs/specs/security-supply-chain.md -> "Disclosure". + */ +const undescribedPackages = new Map(); + +/** + * Identity of a disclosed package. Two installs of one name that agree on all + * four fields are one row with two versions; a disagreement is two rows. + */ +function externalPackageKey({ name, license, author, homepage }) { + return [name, license ?? "", author ?? "", homepage ?? ""].join("\0"); +} function addExternalPackage(pkg) { - const key = [ - pkg.name, - pkg.license ?? "", - formatAuthor(pkg.author) ?? "", - getHomepage(pkg) ?? "", - ].join("\0"); + const identity = { + name: pkg.name, + license: pkg.license ?? null, + author: formatAuthor(pkg.author), + homepage: getHomepage(pkg), + }; + const key = externalPackageKey(identity); const existing = externalPackages.get(key); if (existing) { existing.versions.add(pkg.version); return; } - externalPackages.set(key, { - name: pkg.name, - versions: new Set([pkg.version]), - license: pkg.license ?? null, - author: formatAuthor(pkg.author), - homepage: getHomepage(pkg), - }); + externalPackages.set(key, { ...identity, versions: new Set([pkg.version]) }); +} + +/** + * The names declared beside `packageName` in the same `optionalDependencies` + * block at the same exact version string. A prebuilt family is published in + * lockstep from one repository under one pinned version, so any of these + * describes the absent one exactly — which is also what keeps this disclosure + * identical on every machine that generates it. + */ +function optionalSiblingsAtSameVersion(pkg, packageName) { + const optionalDependencies = pkg.optionalDependencies ?? {}; + const version = optionalDependencies[packageName]; + return Object.keys(optionalDependencies).filter( + (name) => name !== packageName && optionalDependencies[name] === version, + ); } function scanWorkspacePackage(name) { @@ -143,7 +168,7 @@ function scanWorkspacePackage(name) { scanDependencies(workspacePackage.pkg, workspacePackage.dir); } -function scanDependency(fromDir, packageName) { +function scanDependency(fromDir, packageName, declaredBy) { if (workspacePackagesByName.has(packageName)) { scanWorkspacePackage(packageName); return; @@ -151,6 +176,23 @@ function scanDependency(fromDir, packageName) { const packageJsonPath = getPackageJsonPath(fromDir, packageName); if (!packageJsonPath) { + // Absent by design rather than under-reported, in one of two ways. An + // optional dependency of an external package ships to nobody: the Tauri + // bundle copies `standalone/sidecar/node_modules`, and pnpm puts a package + // there only if that manifest declares it — the addon's own list also names + // builds this project never releases (android, musl). An optional + // dependency a product root declares itself does ship, on the platform that + // can hold it, so it is described from a sibling below. Anything else + // missing is still a hard error. + if (declaredBy.optional) { + if (declaredBy.isProductRoot) { + undescribedPackages.set( + packageName, + optionalSiblingsAtSameVersion(declaredBy.pkg, packageName), + ); + } + return; + } throw new Error(`Could not resolve package.json for "${packageName}" from ${fromDir}`); } @@ -164,8 +206,9 @@ function scanDependency(fromDir, packageName) { } function scanDependencies(pkg, fromDir) { - for (const packageName of getDependencyNames(pkg)) { - scanDependency(fromDir, packageName); + const isProductRoot = productRoots.has(pkg.name); + for (const { name, optional } of getDependencyNames(pkg)) { + scanDependency(fromDir, name, { pkg, optional, isProductRoot }); } } @@ -173,6 +216,23 @@ for (const packageName of productDependencyFilters) { scanWorkspacePackage(packageName); } +// Snapshotted before the loop writes to `externalPackages`, so nothing is ever +// described from something that was itself described rather than read. +const describedPackagesByName = new Map(); +for (const pkg of externalPackages.values()) { + if (!describedPackagesByName.has(pkg.name)) describedPackagesByName.set(pkg.name, pkg); +} +for (const [packageName, siblings] of undescribedPackages) { + const sibling = siblings.map((name) => describedPackagesByName.get(name)).find(Boolean); + if (!sibling) { + throw new Error( + `"${packageName}" is not installed and neither is any sibling declared beside it at the same version, so it cannot be described`, + ); + } + const described = { ...sibling, name: packageName, versions: new Set(sibling.versions) }; + externalPackages.set(externalPackageKey(described), described); +} + // Within a single "A OR B OR ..." choice, move MIT to the front so the // listing reads consistently (MIT is the license we expect most often). function moveMitFirstInOrGroup(orExpression) { @@ -247,6 +307,15 @@ const missingLicense = { }; const missingAuthor = { "@hono/node-ws": "Hono middleware contributors", + // The addon ships a `contributors` array rather than npm's singular `author` + // field, and its prebuilt platform packages carry neither. + "@node-datachannel/darwin-arm64": "Murat Doğan, Paul-Louis Ageneau", + "@node-datachannel/darwin-x64": "Murat Doğan, Paul-Louis Ageneau", + "@node-datachannel/linux-arm64-gnu": "Murat Doğan, Paul-Louis Ageneau", + "@node-datachannel/linux-x64-gnu": "Murat Doğan, Paul-Louis Ageneau", + "@node-datachannel/win32-arm64-msvc": "Murat Doğan, Paul-Louis Ageneau", + "@node-datachannel/win32-x64-msvc": "Murat Doğan, Paul-Louis Ageneau", + "node-datachannel": "Murat Doğan, Paul-Louis Ageneau", "@tauri-apps/api": "Tauri Apps Contributors", "@tauri-apps/plugin-shell": "Tauri Apps Contributors", "@tauri-apps/plugin-updater": "Tauri Apps Contributors", diff --git a/website/src/data/dependencies-npm.json b/website/src/data/dependencies-npm.json index 60efdbdf2..1a3b78122 100644 --- a/website/src/data/dependencies-npm.json +++ b/website/src/data/dependencies-npm.json @@ -20,6 +20,48 @@ "author": "Paul Miller (https://paulmillr.com)", "homepage": "https://paulmillr.com/noble/" }, + { + "name": "@node-datachannel/darwin-arm64", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, + { + "name": "@node-datachannel/darwin-x64", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, + { + "name": "@node-datachannel/linux-arm64-gnu", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, + { + "name": "@node-datachannel/linux-x64-gnu", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, + { + "name": "@node-datachannel/win32-arm64-msvc", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, + { + "name": "@node-datachannel/win32-x64-msvc", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, { "name": "@phosphor-icons/react", "version": "2.1.10", @@ -160,6 +202,13 @@ "author": "Josh Junon (https://github.com/qix-)", "homepage": "https://github.com/debug-js/debug" }, + { + "name": "detect-libc", + "version": "2.1.2", + "license": "Apache-2.0", + "author": "Lovell Fuller ", + "homepage": "https://github.com/lovell/detect-libc" + }, { "name": "ecdsa-sig-formatter", "version": "1.0.11", @@ -258,6 +307,13 @@ "author": "Node.js API collaborators", "homepage": "https://github.com/nodejs/node-addon-api" }, + { + "name": "node-datachannel", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel#readme" + }, { "name": "node-pty", "version": "1.2.0-beta.15",