diff --git a/.github/audit/application-security.md b/.github/audit/application-security.md index ef1a19de3..433aa4384 100644 --- a/.github/audit/application-security.md +++ b/.github/audit/application-security.md @@ -40,9 +40,10 @@ nothing and must reach the direct path — `docs/specs/security-remote.md` -> of `service.ts` (the one baked origin and its nullable Hosted origin, the rendezvous gate before any socket, the single runtime, and the approval routed by `kind`); `lib/src/host/remote/local-networks.ts` and -`native-direct-peer.ts` (Local networks' hold on a one-time direct path: the -bound socket, the stripped offer and answer, and the selected-pair check — -`docs/specs/security-remote.md` -> "One-time connection"); +`native-direct-peer.ts` (Local networks' hold on a direct path: the bound +socket, the stripped offer and answer, and the selected-pair check — +`docs/specs/security-remote.md` -> "One-time connection" — and a paired +session's that nothing relayed is read — "Direct path"); `lib/src/remote/burrow/push-delivery.ts`; `lib/src/remote/client/pocket-client.ts` and `session-core.ts` (the phone's ceremonies, and the established session they promote), `one-time-client.ts` (the one-time phone, which keeps nothing and diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index c3d8b932e..14e9b75af 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -123,7 +123,8 @@ Be adversarial, and go past the `FAIL IF` list. Ask specifically: no one can forge a device code that redeems another's approval; an approval must need a recent admin login from the account's own origin and never replace a live one; the redemption must be one statement whose owner is that - approver; and a removed Burrow's row must be gone, its token opening nothing + approver, and a redeemed approval must never redeem again, even once its + Burrow is removed; and a removed Burrow's row must be gone, its token opening nothing on any relay route. Look for a user code predictable without the secret, an unlimited approval loop, and a table an unauthenticated caller can grow. - **Can push leak text, cross an account, or reach somewhere it should not?** diff --git a/AGENTS.md b/AGENTS.md index f010f9d44..0f2f4ab96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ A spec is the accurate reference for the current code: it states the invariants - **`docs/specs/website-docs.md`** — Public documentation on the marketing site: the generated references, the Markdown rendering contract they share, the left rail across the docs section, `vscode-ext/README.md` as the canonical guide published off-site, and the lint that pins their links. - **`docs/specs/webgl-text.md`** — SDF text rendering for the 3D/WebXR effort: the diffplug/xterm.js fork pipeline and its version lockstep, the SDF glyph architecture, the canopy Storybook lab. - **`docs/specs/remote-security-model.md`** — Remote-control trust model: one Noise channel per ceremony, passkeys proving presence inside it, per-Burrow Client statics, the Burrow (not the Relay) authorizing the pair. Read first for anything remote. -- **`docs/specs/remote-network.md`** — The network policy (Nothing / Local networks / Anywhere / My Relay only): its choke points, the update reminder, the Local networks path check, Cloudflare STUN, and the staged Hosted persistent transport. +- **`docs/specs/remote-network.md`** — The network policy (Nothing / Local networks / Anywhere / My Relay only): its choke points, the update reminder, the Local networks path check, Cloudflare STUN, and each level's paired-phone path. - **`docs/specs/remote-api.md`** — What an authorized Client speaks: the shipped terminal-only **protocol-v1** and the staged remainder. - **`docs/specs/relay.md`** — The selfhost coordinating Relay and shared Burrow-service runtime: env config, JSON-file state, WebAuthn without a library, HTTP API, relay flow, enrollment, running it end to end. - **`docs/specs/hosted.md`** — Hosted accounts: application boundary, login/linking policy, local development, and staged paid services. diff --git a/SELF_HOST.md b/SELF_HOST.md index 28f173599..8ca984a44 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -380,8 +380,8 @@ Burrow displays (`docs/specs/relay.md` → Setup tokens and the pairing QR). on their own; the section then shows the Relay, the relay connection and the paired-device count. - A stock build shows only a disabled "Use hosted.dormouse.sh" under - **Persistent Relay**, with nothing to enroll: the expected symptom of a stock + A stock build offers only "Enroll with hosted.dormouse.sh" under + **Persistent Relay**, and no setup password: the expected symptom of a stock build, not a Relay problem. 3. **The phone, and only then the code.** On the phone, open diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index bfde5abc3..39c46374a 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -19,7 +19,7 @@ Marketing is a separate bundle and deployment. `remote-lib-common` is compiled i **Must run committed Better Auth migrations before deploying code that needs them, never during a Worker request.** Postgres is reached through an uncached Hyperdrive binding. The runtime creates and closes its database pool within each request. -**Must install released core/auth packages from npm and commit their lockfile integrity hashes.** The installed packages' `dist/provenance.json` must name the same clean pgstencil commit; no runtime import depends on a sibling checkout. The auth migrations remain owned by the package; Dormouse's own tables migrate from `hosted/server/dormouse-migrations/`. +**Must install released core/auth packages from npm and commit their lockfile integrity hashes.** The installed packages' `dist/provenance.json` must name the same clean pgstencil commit; no runtime import depends on a sibling checkout. The auth migrations remain owned by the package; Dormouse's own tables migrate from `hosted/server/dormouse-migrations/`. **Never edit a merged migration**: a migrated database never reruns one, so append the next number (pinned by `hosted/server/tests/migrations.test.ts`). **Must declare every peer dependency of the installed packages in `hosted/package.json`**, so they share Hosted's copy and Renovate updates them. @@ -146,7 +146,7 @@ The relay Worker serves `GET /ws/burrow` and `GET /ws/client` at the self-host p - **Must recheck a Burrow's row in the object before accepting its socket**, under `blockConcurrencyWhile`: enrolled, the account's, its owner entitled, else the Worker's 401 or 403. A removal committed after the Worker's token check is refused here; one committed later has its `closeBurrow` delivered after the accept. **Never open a database connection in the object** (rationale): it reads rows through the relay Worker's `RelayRows` entrypoint, reached by `ctx.exports`. **Must bound every row read at `RELAY_ROW_READ_TIMEOUT_MS`**, under the runtime's 30 s `blockConcurrencyWhile` reset: past it the upgrade answers 503 and the sweep leaves its Burrows to the next. - **Must accept every socket through the Hibernation API**, tagged by role and id, keeping the routing state in its attachment — role, `clientId`, bound `burrowId`, `expiresAt` — so a woken object rebuilds it from `ctx.getWebSockets`. - **Must measure a text frame in UTF-8 bytes against `MAX_RELAY_FRAME_BYTES` before parsing it**, as the self-host `maxPayload` counts, closing 1009 past it; a binary frame is dropped. The Client cap is per account object. -- **Must keep one alarm at the earlier of the earliest held Client's `expiresAt` and, while a Burrow socket is held, the next sweep**, at most `RELAY_ROOM_SWEEP_MS` (an hour) away. Each alarm closes every expired Client 1008 `unauthorized`, its Burrow told `client-gone`; then reads every held Burrow's row in one query and closes 4001 each removed, another account's, or de-entitled, its Clients told `burrow-gone`; then re-arms, or clears while nothing is held. An upgrade only brings the alarm earlier and a close leaves it. It replaces the self-host expiry and revocation sweeps. +- **Must keep one alarm at the earlier of the earliest held Client's `expiresAt` and, while a Burrow socket is held, the next sweep**, at most `RELAY_ROOM_SWEEP_MS` (an hour) away. Each alarm closes every expired Client 1008 `unauthorized`, its Burrow told `client-gone`; then reads every held Burrow's row in one query and closes each removed or another account's 4001 (`WS_CLOSE_BURROW_REVOKED`) and each de-entitled 4002 (`WS_CLOSE_BURROW_NOT_ENTITLED`), its Clients told `burrow-gone`; then re-arms, or clears while nothing is held. An upgrade only brings the alarm earlier and a close leaves it. It replaces the self-host expiry and revocation sweeps. - **Must answer `RELAY_PING` with `RELAY_PONG` as the object's auto-response**, which never wakes it, reaches no handler, and is never forwarded. **A socket whose last auto-response is older than `RELAY_IDLE_TIMEOUT_MS` is gone**, retired with 1001; a socket that never pinged is never judged. It replaces the self-host heartbeat. Two gaps are accepted: a Burrow that never pings is never judged, since every build that can enroll with Hosted pings; and at the Client cap a backgrounded Pocket, its pings paused, counts as gone once silent that long — the cap is far above real use, and the Burrow reaps its session at 120 s anyway. - **Must close a removed Burrow's socket from the account Worker's `DELETE /api/relay/burrows/:burrowId`**, through its `RELAY_ROOM` binding (`script_name` the relay) calling `closeBurrow`: 4001, its Clients `burrow-gone`. **Must answer 204 once the row is gone, even if the close fails**, logging it; the sweep closes the socket. No route reaches either RPC; `GET /api/burrows` reads `online` through `onlineBurrows`, beside its query. @@ -154,17 +154,18 @@ Source of truth: `relaySocketRoutes` in `hosted/server/relay-sockets.ts`; `relay ## Burrow enrollment -A Burrow joins an account by device code, in place of the self-host setup password. The account owns the Burrow; the Burrow's own ACL still authorizes every Client. The relay serves the Burrow's two routes, the account the rest; wire types are `BurrowEnrollBeginResponse` / `BurrowEnrollPollResponse` in `remote-lib-common/src/remote/wire.ts`. +A Burrow joins an account by device code, in place of the self-host setup password. The account owns the Burrow; the Burrow's own ACL still authorizes every Client. The relay serves the Burrow's two routes, the account the rest; wire types are `BurrowEnrollBeginResponse` / `BurrowEnrollPollResponse` in `remote-lib-common/src/remote/wire.ts`. The desktop's side is `docs/specs/relay.md` -> "Burrow side". 1. The Burrow sends `{ origin }` to `POST /api/burrow/enroll/begin`. Another origin, or none, is the self-host 409 `ORIGIN_MISMATCH_ERROR`. The answer: `deviceCode`, `userCode` (`XXXX-XXXX`), `verificationUrl` (`ACCOUNT_ORIGIN/enroll#`, absent without `ACCOUNT_ORIGIN`), `expiresAt` (`ENROLLMENT_TTL_MS`, 10 minutes), and `interval` (5 seconds). 2. The user opens the link, signs in, compares the code, and approves, writing the approval `{ userCode, userId, expiresAt }`. -3. The Burrow polls `POST /api/burrow/enroll/poll` with `{ deviceCode }` every `interval`: `expired` for a malformed or expired code; `pending` while no live approval holds its user code; or `enrolled` with the self-host `BurrowEnrollResponse`, `origin` the relay's `APP_ORIGIN` and `rpId` its hostname, without `requireUserVerification`. 403 `NOT_ENTITLED_ERROR` when the approver is no longer entitled; 409 naming `ACCOUNT_ORIGIN/account` at `MAX_ENROLLED_BURROWS` Burrows. Both refusals keep the approval, so a later poll can enroll. +3. The Burrow polls `POST /api/burrow/enroll/poll` with `{ deviceCode }` every `interval`: `expired` for a malformed or expired code; `pending` while no live approval holds its user code; `enrolled` with the self-host `BurrowEnrollResponse`, `origin` the relay's `APP_ORIGIN` and `rpId` its hostname, without `requireUserVerification`; or `redeemed` with the `burrowId` an earlier poll enrolled, until the approval expires, so the Burrow can name what the account must remove. 403 `NOT_ENTITLED_ERROR` when the approver is no longer entitled; 409 naming `ACCOUNT_ORIGIN/account` at `MAX_ENROLLED_BURROWS` Burrows. Both refusals keep the approval, so a later poll can enroll. - **Never write from begin.** The device code is 32 bytes, the bearer shape: a 4-byte big-endian expiry in epoch seconds, then 28 random bytes. The user code is `enrollUserCode`: `HMAC-SHA-256(RELAY_ENROLL_SECRET, deviceCode)` read five bits at a time into `ENROLL_USER_CODE_ALPHABET`, values past it skipped (rationale). -- **Must store the approval alone** (`dormouse_relay_enrollment_approvals`): it cannot tell an issued code from any well-formed one, so it approves any; one no Burrow redeems expires (rationale). +- **Must store the approval alone** (`dormouse_relay_enrollment_approvals`; its redeemed columns in `004_relay_enrollment_redeemed.sql`): it cannot tell an issued code from any well-formed one, so it approves any; one no Burrow redeems expires (rationale). - **Never admit a begin or poll request carrying `Origin`** (403): only a Node Burrow calls them. Then 429 with `Retry-After` past `RELAY_ENROLL_BEGIN_LIMIT` (10 a minute per address) or `RELAY_ENROLL_POLL_LIMIT` (60), before the body limit and any database read. - **Must answer an expired device code from the code alone**, reading no database. -- **Must redeem in one statement**: the poll recomputes the user code, deletes its live approval, and inserts the Burrow owned by the approval's `userId`, under the account's lock with the cap check, so two polls mint one Burrow. The entitlement is rechecked first. +- **Must redeem in one statement**: the poll recomputes the user code, marks its live unredeemed approval with `redeemedBurrowId` and `redeemedAt`, and inserts the Burrow owned by the approval's `userId`, under the account's lock with the cap check, so two polls mint one Burrow. The entitlement is rechecked first. +- **Must keep a redeemed approval until it expires**, swept hourly, so a poll whose `enrolled` answer was lost reads `redeemed`. **Never key it to the Burrow**: removing that Burrow leaves it redeemed; only an approval after it expires replaces it. | Route (account) | Credential | Success | |---|---|---| @@ -223,4 +224,3 @@ Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / 1. Deploy the configured providers and pass real production acceptance. pgstencil includes the Microsoft fix; personal and work/school callbacks need acceptance. 2. Add per-browser login listing/revocation, sign-out-everywhere, and account recovery before broad paid use. Revisit the fixed 24-hour login lifetime for daily voice use. 3. Managed voice beyond the admin slice: a real entitlement or licence replacing `ADMIN_EMAIL`, credentials scoped for non-admin accounts, per-account quotas, usage accounting, and spending bounds beyond the fixed daily cap, and explicit text/redaction disclosure. -4. Hosted Relay beyond "Relay", "Relay sockets", and "Burrow enrollment": desktop enrollment — **saas-multitenant** in `docs/specs/relay.md` and **remote-network** in `docs/specs/remote-network.md`. Account login never replaces Burrow pairing and authorization. Paid security claims require independent review. diff --git a/docs/specs/one-time.md b/docs/specs/one-time.md index 965bc2d10..acc0f8835 100644 --- a/docs/specs/one-time.md +++ b/docs/specs/one-time.md @@ -100,8 +100,8 @@ no deadline. together. - **Timings.** An unused link lives `ONE_TIME_LINK_TTL_MS` (the pairing TTL, 5 minutes). The join and the confirmation finish by its expiry, and the - direct path's `ONE_TIME_DIRECT_DEADLINE_MS` (15 s) after the outcome ends - inside the room's hard deadline, `expiresAt + ONE_TIME_EXPIRY_GRACE_MS` (30 s). + direct path's `DIRECT_ONLY_DEADLINE_MS` (30 s) after the outcome ends + inside the room's hard deadline, `expiresAt + ONE_TIME_EXPIRY_GRACE_MS` (45 s). - **The ceremony's messages are padded `control` messages** on the Noise session (`docs/specs/relay.md` -> "E2E framing"): `OneTimeRequestV1 {code, label}` phone → Burrow, `label` one of `ONE_TIME_DEVICE_LABELS`, then one @@ -138,7 +138,7 @@ resumes. | `opening` | minting the keypair, then waiting up to `ONE_TIME_OPEN_TIMEOUT_MS` (8 s) for the room frame | | `waiting {url, expiresAt}` | the link is live; `expiresAt` is its last live millisecond | | `confirming {label, expiresAt}` | a phone's request awaits the approval modal | -| `connecting {label}` | confirmed; the direct path has `ONE_TIME_DIRECT_DEADLINE_MS` | +| `connecting {label}` | confirmed; the direct path has `DIRECT_ONLY_DEADLINE_MS` | | `connected {label, since}` | both directions are direct and the rendezvous is closed | | `ended {reason}` | terminal | @@ -175,7 +175,7 @@ decides them; a runtime never enters either. - **`end()` releases everything**: the session and its peer connection (a switched one once the goodbye has left it: `docs/specs/remote-api.md` → Transport), a pending approval, the key, queued work, every timer, and the - socket. Nothing is written. **A `user-ended` or `idle` ending sends the goodbye first, before + socket. Nothing is written. **A `user-ended`, `idle`, or `network-not-allowed` ending sends the goodbye first, before the room closes** (`docs/specs/remote-api.md` → Transport), so a phone still connecting hears it over the rendezvous. @@ -186,15 +186,16 @@ decides them; a runtime never enters either. | `expired` | a link past its expiry, claimed or not; a late request or confirmation; room close `4010` or `4014` | | `phone-left` | room close `4013` before the switch; any session failure after it | | `direct-failed` | a decline, an abandoned attempt, a session failure before the switch, or the direct deadline | -| `network-not-allowed` | the path check refused the direct path (`docs/specs/remote-network.md` -> "Local networks") | +| `network-not-allowed` | the path ended it, the state carrying the `refusal` (`docs/specs/remote-network.md` -> "Local networks") | | `idle` | `ESTABLISHED_E2E_IDLE_TIMEOUT_MS` without a decrypted phone message | | `unreachable` | no room frame by the open deadline, a first message that is not one, or a socket lost before it | | `rendezvous-lost` | any other close before the switch | | `burrow-error` | room close `4015`, a protocol violation, an application message over the rendezvous, a room past the message cap, or a local failure | Source of truth: `OneTimeRuntime` in -`lib/src/remote/burrow/one-time-runtime.ts`; `onRelayedApp` and -`onTransportChanged` in `lib/src/remote/burrow/established-session.ts`. +`lib/src/remote/burrow/one-time-runtime.ts`; `directOnly`, +`onDirectOnlyBroken`, and `directDeadlineAt` in +`lib/src/remote/burrow/established-session.ts`. Pinned by `lib/src/remote/burrow/one-time-runtime.test.ts`, which drives a real Noise initiator through the in-memory room `lib/src/remote/test-rendezvous.ts`. @@ -209,8 +210,8 @@ at most one session**, on `ClientSessionCore`, direct or not at all. 2. Hands the two digits to `onCode`, sends `OneTimeRequestV1 {code, label}`, and reads one outcome. 3. On `ok`, calls `onConfirmed`, establishes the session, and offers the - direct path, which has `ONE_TIME_DIRECT_DEADLINE_MS` to carry both - directions. + direct path, which has `DIRECT_ONLY_DEADLINE_MS` to carry both + directions (`ClientSessionCore.awaitDirect`). 4. At the switch, closes the rendezvous normally and resolves `{ok: true, burrowLabel}`. @@ -234,7 +235,7 @@ Every failure resolves `{ok: false, message}` with fixed copy: | room close `4011` or `4012` | `ONE_TIME_LINK_USED_MESSAGE` | | an expired link, room close `4010` or `4014`, any other close before an outcome past the link's expiry, or no answer by the room's deadline | `ONE_TIME_LINK_EXPIRED_MESSAGE` | | between an `ok` outcome and the switch: a decline, a lost session, the deadline, or any other close | `ONE_TIME_DIRECT_FAILED_MESSAGE` | -| between an `ok` outcome and the switch: the laptop's goodbye | `ONE_TIME_ENDED_MESSAGE` | +| between an `ok` outcome and the switch: the laptop's goodbye | `networkNotAllowedMessage` where it names the phone's address, `ONE_TIME_DIRECT_FAILED_MESSAGE` where it names the path and no address, else `ONE_TIME_ENDED_MESSAGE` | | a socket that never opened | `ONE_TIME_UNREACHABLE_MESSAGE` | | any other close, `close()`, or a session lost between the switch and the resolve | `ONE_TIME_ENDED_MESSAGE` | | a payload on message 2, or an outcome its guard refuses | `ONE_TIME_DENIAL_MESSAGES['burrow-error']` | @@ -468,7 +469,7 @@ sits in the Baseboard's right cluster (`docs/specs/layout.md` -> "Baseboard"). | `confirming` | "Type the two digits your phone shows into the dialog." | Cancel | | `connecting` | "Connecting directly…" | Cancel | | `connected` | the phone's label, then "has full control of your terminals." | End | -| `ended` | the reason's sentence | New link, Done | +| `ended` | the reason's sentence, a refusal's from `pathRefusalSentence` | New link, Done | - **The panel renders the service's state and owns only its busy and error**; a refused `oneTimeOpen` renders inline. **Closing Settings changes nothing**: diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 3ebeaa129..d6c3fdea2 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -152,6 +152,16 @@ discarding the pin (rationale). Each row carries **Remove**, which tombstones th delivery id before deleting the record; the list carries **Scan a setup code**. Pairing continues into connecting. +**A record of the signed-in account the Relay's list no longer names is +removed**, marked only off a `GET /api/burrows` that succeeded; a listed +offline Burrow, and another account's record, keep the offline row. +**Must bind removal checks to the account that started the list read.** Its +row reads `BURROW_REMOVED_COPY` for the deployment Pocket read +(`docs/specs/remote-network.md` -> "Anywhere") and offers **Forget** alone, +which is Remove. **A Connect answered `BURROW_UNAVAILABLE_MESSAGE` re-reads the +list**, showing that copy instead where the Burrow is gone; a failed re-read +keeps the original. + Source of truth: `PlatformAdapter` in `lib/src/lib/platform/types.ts`; `SetupOrSignin` / `BurrowsView` / `ConnectedView` and the `probeNoiseSupport` gate in `lib/src/remote/pocket-app/App.tsx`; `PairingCodeView` in @@ -547,10 +557,14 @@ injected timer, clock, and visibility seams in ## The path the session takes **Pocket offers a direct path once the connection outcome says `ok`**, over the -browser's own `RTCPeerConnection` (its ICE servers: -[remote-network.md](./remote-network.md) → Anywhere), and keeps the session +browser's own `RTCPeerConnection` (ICE servers by deployment, read before +every Connect and pairing: [remote-network.md](./remote-network.md) → +Anywhere), 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). +([remote-api.md](./remote-api.md) → Direct path owns the whole protocol) — +**unless the outcome says `directOnly`**, where the connect answers only once +the direct path carries the session +([remote-network.md](./remote-network.md) → Local networks). **Must retire the previous session — its peer, its channel, and its pending requests — immediately before the replacement's connection request goes out, and @@ -578,8 +592,8 @@ fresh handshake and one WebAuthn prompt. Before the switch a failed channel costs nothing. Source of truth: `PocketClient.connect` in -`lib/src/remote/client/pocket-client.ts`; `selfHostDirectPeer` in -`lib/src/remote/client/browser-direct-peer.ts`; `ClientSessionCore.transportPath` / +`lib/src/remote/client/pocket-client.ts`; `deploymentDirectPeer` in +`lib/src/remote/pocket-app/deployment.ts`; `ClientSessionCore.transportPath` / `setOnTransportChanged` in `lib/src/remote/client/session-core.ts`; `TRANSPORT_PATH_LABELS` / `TRANSPORT_RELAY_CAUSES` / `transportTitle` in `lib/src/remote/pocket-app/views.tsx`. @@ -651,7 +665,8 @@ One lib-owned bundle, two deployments: selfhost auth never depends on dormouse.sh existing. * **Hosted:** the relay Worker serves it at the root of `relay.dormouse.sh` beside the Hosted Relay's routes (`docs/specs/hosted.md` -> "Relay"); rpId is - that host. + that host. The staging adds `deployment.json`, which tells the bundle Hosted + serves it ([remote-network.md](./remote-network.md) → Anywhere). **The website stays fully static — playground and marketing pages — in both worlds**, sharing all terminal UI through `lib` and never duplicating Pocket diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 435ff686c..4c627c657 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -27,7 +27,8 @@ primitive lives in `remote-lib-common`, the terminal UI in `lib`/`standalone`. (`BURROW_REVOCATION_SWEEP_MS`, one minute), since the upgrade check runs once and a Burrow may stay connected indefinitely. The sweep closes it with `WS_CLOSE_BURROW_REVOKED` (4001) and its Clients get `burrow-gone`, the teardown a - disconnect performs; the Burrow may reconnect, and the upgrade then answers 401. + disconnect performs; the Burrow stands down ("Burrow side", relay socket + policy), and an upgrade it still tries answers 401. Revoking a *Client* is the Burrow's own ACL and still needs a Burrow restart ([remote-security-model.md](./remote-security-model.md)). * A dropped WebSocket is handled by reloading the page / reconnecting the burrow; @@ -121,7 +122,7 @@ webview CSPs carry no relay sources** (`docs/specs/vscode.md` → "CSP policy"; | `DORMOUSE_RELAY_ORIGIN` | Mode | Relay | One-time connection | Managed voice | Standalone auto-update | | --- | --- | --- | --- | --- | --- | -| unset, or `https://relay.dormouse.sh` | Hosted | Hosted's, which enrolls no Burrow yet | at this origin | at `https://voice.dormouse.sh` | on | +| unset, or `https://relay.dormouse.sh` | Hosted | Hosted's, enrolled by device code | at this origin | at `https://voice.dormouse.sh` | on | | any other accepted origin | self-host | exactly this origin | off | off | off | - **A self-host build sends nothing to `dormouse.sh` or any host under it @@ -151,8 +152,9 @@ webview CSPs carry no relay sources** (`docs/specs/vscode.md` → "CSP policy"; `DORMOUSE_ONE_TIME_ORIGIN`. - **Managed voice's origin is the constant `HOSTED_VOICE_ORIGIN`, never baked or overridden**, a loopback dev Hosted build included (rationale). -- **No build bakes `hosted.dormouse.sh`, the account's origin**; the desktop - reaches it only by a user's click. +- **The account's origin is the constant `HOSTED_ACCOUNT_ORIGIN` + (`https://hosted.dormouse.sh`), never baked and never requested**: the desktop + opens it only on a user's click, in the browser. - The Hosted Relay serves `relay.dormouse.sh` with Pocket at its root, never a tailnet or per-tenant host, passkeys binding to that origin (`docs/specs/hosted.md` → "Relay"). Hosted's origins: `docs/specs/hosted.md` @@ -160,9 +162,10 @@ webview CSPs carry no relay sources** (`docs/specs/vscode.md` → "CSP policy"; **The Burrow composes every Relay URL from the baked origin and takes none as input**: `enroll` and `enrollOffer` post to it, carrying no Relay URL, and **a -Hosted build refuses both**, Hosted taking no setup password and enrolling no -Burrow yet (`docs/specs/hosted.md` → Future), as does any build under the network -policy's `nothing` (`docs/specs/remote-network.md` → "Policy"); **a command still naming a Relay** (an older +Hosted build refuses both**, Hosted taking no setup password; it enrolls by +device code instead (`beginHostedEnrollment`, "Burrow side"), which a self-host +build refuses. Any build refuses all three under the network policy's `nothing` +(`docs/specs/remote-network.md` → "Policy"); **a command still naming a Relay** (an older webview's) **is refused unless it names the baked origin**. **An enrollment whose Relay URL or `origin` names another origin reads as none** wherever one is read — `start`, `status`, VS Code's activation — and stays on disk untouched, so @@ -184,15 +187,16 @@ pins it. **Enrollment and Burrow-authenticated push fetches must use `redirect: 'error'`** — a Node process does not re-check a redirect target, so following one could carry -the setup password, Burrow bearer token, or notification metadata to another -origin. +the setup password, a device code, Burrow bearer token, or notification metadata +to another origin. Source of truth: `resolveRelayOrigin` and `assertRelayOriginBaked` in `scripts/relay-origin.mjs`; `isAcceptedRelayOrigin` in `remote-lib-common/src/security/one-time-link.ts`; `standalone/vite.config.ts`; `bakedRelay`, -`bakedRelayMode`, `hostedOrigin`, `hostedVoiceOrigin`, and `HOSTED_VOICE_ORIGIN` in +`bakedRelayMode`, `hostedOrigin`, `hostedVoiceOrigin`, `hostedAccountOrigin`, +`isDevHostedBuild`, `HOSTED_VOICE_ORIGIN`, and `HOSTED_ACCOUNT_ORIGIN` in `lib/src/host/relay-origin.ts`; -`BurrowService`, `canEnroll`, and `loadEnrollmentFor` in +`BurrowService` and `loadEnrollmentFor` in `lib/src/host/remote/service.ts`. Pinned by `lib/src/host/relay-origin.test.ts` and `lib/src/host/remote/service.test.ts`. @@ -774,8 +778,8 @@ memo invalidation — live in that burrow's spec. `noiseStaticPublicKey` this Burrow mints locally **before** the request and never sends in it — [remote-security-model.md](./remote-security-model.md)) through its - `BurrowStateStore`, then opens and maintains `GET /ws/burrow` — only under the - network policy's `relay`; any other level holds the enrollment without a socket + `BurrowStateStore`, then opens and maintains `GET /ws/burrow` under every + network policy level but `nothing`, which holds the enrollment without a socket ([remote-network.md](./remote-network.md) -> Policy). **Must persist the operator's `label` locally and disclose it only inside encrypted outcomes** — the request body carries the credential and the baked `origin`, nothing else @@ -804,18 +808,84 @@ memo invalidation — live in that burrow's spec. same rule backwards**: the delete is awaited first and nothing else happens unless it succeeded, or a failed delete would leave the credential on disk for the next launch to read back. +* **Hosted enrollment** (a Hosted build, from the Settings dialog): + `beginHostedEnrollment` `{ label }` mints the Noise static, posts `{ origin }` + to `API_ROUTES.burrowEnrollBegin` at the baked origin, and holds an answer only + if `isBurrowEnrollBeginResponse` passes; the service then polls + `burrowEnrollPoll` itself every `interval` + ([hosted.md](./hosted.md) -> "Burrow enrollment"), off the lifecycle chain + until it redeems. Both requests carry the 10 s timeout and `redirect: 'error'`. + Refused on an enrolled machine. + - **Must answer a code already waiting, or redeeming, rather than replace it**: + another VS Code window's Enroll reaches the same service. A begin in flight + is joined; one after an ending ("Get a new code") begins anew. + - **Never change what ended until the new begin has its code.** + - **Must detach a cancelled begin immediately**, so a new begin never joins + its stale request. + - **The device code never leaves the service**, as `burrowToken` does not: + `status` carries `hostedEnrollment` — `waiting` with `userCode`, + `verificationUrl`, `expiresAt`, and `accountFull`; `redeeming`; or `ended` + with a reason from `HOSTED_ENROLLMENT_END_REASONS`, `answer-lost` naming its + `burrowId` — and `accountOrigin` + (`accountOriginFor`: `HOSTED_ACCOUNT_ORIGIN` in a release build, the last + begin's account origin in a dev one). Each change is a `status` event. + - **Must compose the verification URL, never take it from the Relay in a + release build**: `enrollVerificationUrl` answers + `HOSTED_ACCOUNT_ORIGIN/enroll#`. A dev Hosted build + (`isDevHostedBuild`: Hosted mode at a non-default origin) takes only the + origin of the Relay's `verificationUrl`, after `parseLinkFragment`'s checks + with path `/enroll` and the fragment exactly the code, and refuses the begin + without one. + - **Must stop polling** on the Relay's `expired`, at this machine's deadline + (the Relay's `expiresAt` held to 1–15 minutes, the clocks being separate), on + Cancel, on disposal, and on a change to `nothing`. + A 403 `NOT_ENTITLED_ERROR` ends it `not-entitled`; any other refusal ends it + `failed` with the service's sentence. **A transport failure (including a + 2xx body lost mid-read), a 5xx, or a 429 polls again**, a 429 adding 5 s to + the interval, up to 60 s; a complete body that fails the guard ends it + `failed`; **a full + account's 409 polls on with `accountFull`**, the Relay keeping the approval. + The Relay's `redeemed` (an earlier poll's answer lost) ends it `answer-lost` + with the `burrowId` it names; `isBurrowEnrollPollResponse` guards every + answer. + - **An `enrolled` answer takes the Enrollment path above**: `isEnrollment` + with the label and the static minted before the begin, the origin checked, + then store first on the lifecycle chain, **reporting `redeeming` until the + save and start finish**, which neither Cancel nor a begin interrupts. + - **Must hold a redemption that lands after Cancel and a new begin**: + it is spent and recorded by the Relay. Only a disposed service or an + enrolled machine cannot; that, the origin check, or a failed save ends it + `failed`, the message naming the Burrow and `/account` to + remove it at (a console warning once disposed). + - **Must retain an enrollment whose save succeeded when startup fails**, + reporting the startup error with restart guidance, never removal advice. * **Relay socket policy**: one socket at a time, reconnected with exponential - backoff (1 s, doubling to 30 s) after any close — **except a close carrying - `WS_CLOSE_BURROW_REPLACED`, which is terminal** (rationale): another Dormouse - instance enrolled with the same `burrowId` took the relay slot, so this one - disposes its sessions, reports `displaced`, and arms no timer. Coming back is - an explicit act — `reconnect()` — which takes the slot back and displaces the - other Burrow in turn, so `displaced` is the one connection state the user has to - act on. **Ignore open, message, and close events from sockets the controller - no longer owns** (rationale). - `lib/src/remote/burrow/burrow-runtime.test.ts` pins late delivery after stop and - restart. **Never construct a socket after service disposal**, including - from an enrollment or ACL read already in flight. + backoff (1 s, doubling to 30 s) after any close — **except three closes, which + are terminal** (rationale): the Burrow disposes its sessions, reports a latched state, and + arms no timer. + + | Close | State | Meaning | + |---|---|---| + | `WS_CLOSE_BURROW_REPLACED` (4000; rationale) | `displaced` | another Dormouse instance enrolled with the same `burrowId` took the relay slot | + | `WS_CLOSE_BURROW_REVOKED` (4001) | `removed` | the Burrow's row is gone | + | `WS_CLOSE_BURROW_NOT_ENTITLED` (4002, Hosted only) | `not-entitled` | its owner is no longer entitled | + + Coming back is an explicit act — `reconnect()` or a fresh start — which after + `displaced` takes the slot back and displaces the other Burrow in turn. + **A socket that never opened is probed before its next backoff**, since a + refused upgrade reaches the Burrow only as an error event: one + `GET /api/push/devices` as the Burrow, through the service's guarded fetch, + bounded at `BURROW_REQUEST_TIMEOUT_MS`. A 401 `UNAUTHORIZED_ERROR` (or + `UNKNOWN_BURROW_TOKEN_ERROR`) latches `removed`, a 403 `NOT_ENTITLED_ERROR` + `not-entitled`, and any other 2xx, 401, or 403 backs off. **At most one + answered probe per failure streak**; an open ends the streak, and **only a + 2xx, 401, or 403 spends it**: no answer, a 5xx, or any other status counts + as none (rationale). **A latched `removed` or `not-entitled` Burrow + (`relayRefuses`) asks its Relay nothing more**: no push, device list, or + setup code. **Ignore open, message, and close events, and probe answers, + from sockets the controller no longer owns** (rationale). **Never construct + a socket after service disposal**, including from an enrollment or ACL read + already in flight. * **Security**: `BurrowAcl` (persisted through the `BurrowStateStore`, **keyed per `burrowId`**, so an enrollment onto a fresh one starts with an empty ACL while a re-enrollment onto the same one keeps its paired devices), @@ -864,13 +934,21 @@ memo invalidation — live in that burrow's spec. authority holds at the PTY level** through that same resize path. Source of truth: `lib/src/host/remote/service.ts` (`BurrowService`, -`#enrollWith`, `#status`, `#setupQr`, `unenrolledStatus`, lifecycle + console -commands), +`#enrollWith`, `#adoptEnrollment`, `#beginHostedEnrollment`, +`#pollHostedEnrollment`, `#redeemHostedEnrollment`, `enrollVerificationUrl`, +`accountOriginFor`, `#status`, `#setupQr`, +`unenrolledStatus`, lifecycle + console commands), `lib/src/host/remote/burrow-state-store.ts`, `lib/src/host/remote/serial-queue.ts`, -`lib/src/remote/burrow/enrollment.ts`, `BurrowRuntime.mintInvitation` in +`lib/src/remote/burrow/enrollment.ts` (`performEnrollment`, +`beginHostedEnrollment`, `pollHostedEnrollment`), `isBurrowEnrollBeginResponse` +in `remote-lib-common/src/remote/wire.ts`, `BurrowRuntime.mintInvitation` in `lib/src/remote/burrow/burrow-runtime.ts`, `lib/src/remote/burrow/burrow-fetch.ts`, `lib/src/remote/burrow/enrolled-gate.ts`, `lib/src/remote/burrow/activation.ts` (the -webview's client half). +webview's client half); the relay socket policy in `BurrowRuntime.#onClose` in +`lib/src/remote/burrow/burrow-runtime.ts`, `probeBurrowStanding` in `lib/src/remote/burrow/burrow-fetch.ts`, and +`relayRefuses` in `lib/src/host/remote/service-protocol.ts`. Pinned by +`lib/src/remote/burrow/burrow-relay-socket.test.ts` and, for late delivery after +stop and restart, `lib/src/remote/burrow/burrow-runtime.test.ts`. ### Remote control, in the Settings dialog @@ -878,9 +956,8 @@ Enrolling is the one step a self-hoster cannot skip, so it is UI, not a console incantation: the **Remote control** choices in the Phones section of Settings → Network, shown under any level but Nothing ([remote-network.md](./remote-network.md) -> "Settings → Network"). A self-host -build enrolls only under **My Relay only**, which the user chooses first. -Its managed-Relay link follows [website-docs.md](./website-docs.md) -> -`/hosted` preview. +build enrolls only under **My Relay only**, which the user chooses first; a +Hosted build under Local networks or Anywhere. **It renders nothing at all where `getPlatform().burrow` is absent** — the website and lib dev server have no Burrow service, so the form would promise what @@ -896,15 +973,25 @@ notifications). Only the first has a Network topic beneath it, so only the first and Persistent Relay**, which un-enrolled discloses, **folded until clicked, offer or not — hidden, never unmounted**, what the build's mode allows: `status` carries the baked `relayOrigin` and `relayMode` (Relay origin), and a -Hosted build shows only a disabled "Use hosted.dormouse.sh", a self-host build -the enroll view. +Hosted build shows the Hosted enroll view, a self-host build the enroll view. The enroll view names `relayOrigin` over a two-field form (setup password, Burrow name — prefilled with the `suggestedLabel` `status` carries) calling the service's `enroll`; enrolled, it shows unclicked the Relay origin, relay connection state, and paired-device -count, with `Disconnect` and — only on `displaced` — `Reconnect`. Rules the UI -exists to honor: +count, with `Disconnect`, and per latched state: + +| State | Reads | Offers | +|---|---|---| +| `displaced` | another instance took the slot | Reconnect | +| `removed`, Hosted | `removedCopy`: removed from your account at the account host | **Enroll again**: clears the enrollment, then begins a device-code enrollment | +| `removed`, self-host | `removedCopy`: removed from the Relay's host, Disconnect to enroll again | nothing more | +| `not-entitled` | `NOT_ENTITLED_COPY` | Reconnect | + +**`removed` and `not-entitled` offer no "Set up a phone".** Enroll again's busy +and error live above both views, so a begin refused after the clear still +shows, and Persistent Relay starts unfolded over it. Rules the UI exists to +honor: - **The offer leads, but only where it can be pressed.** The card shows when an unexpired local offer file names the baked origin and this self-host Burrow is @@ -925,7 +1012,9 @@ exists to honor: - **The password is passed through, never held**, cleared on success; `enroll` answers `{ burrowId }`, so `burrowToken` never re-enters the webview. - **Refusals are shown, not swallowed**, the offer card included: the service's - own error is what the form renders, never a generic wrong-password message. + own error is what the form renders, never a generic wrong-password message — + for a Relay that never answered, the host and why + (`docs/specs/remote-network.md` -> "Policy"). - **Enrolled, "Set up a phone" opens an inline QR panel**, so a phone is set up by pointing a camera at the laptop rather than typing an origin and a 64-hex password. It mints on open and never before, re-mints shortly before @@ -951,9 +1040,25 @@ exists to honor: costs a retry rather than the app-wide ErrorBoundary. - **Disconnect asks first**: clearing the enrollment drops every paired phone until each pairs again. +- **The Hosted enroll view renders `status.hostedEnrollment` and holds no state + of its own** ("Burrow side" -> Hosted enrollment). Un-begun, the name field + prefilled with `suggestedLabel` and "Enroll with "; waiting, + the code in large type under `HOSTED_ENROLLMENT_CODE_LABEL`, the minutes left, + "Open to approve", which opens `verificationUrl` with + `openExternal` only on the click, and Cancel — with `accountFull`, a link to + remove a computer at the account; ended, why, in fixed copy per reason + (`HOSTED_ENROLLMENT_ENDED_COPY`, `failed` adding the service's sentence), with + "Get a new code", a plain begin, and Done, which cancels; `answer-lost` names + the Burrow to remove and links the account page. Redeeming, `HOSTED_ENROLLMENT_REDEEMING_COPY` alone. **Open is + disabled once the minutes left reach 0.** **The name field is hidden while a + code waits, never unmounted**, and **Persistent Relay starts unfolded over an + enrollment already begun**. Enrolled, the view adds "Manage computers at + ", linking `status.accountOrigin` + `/account`, the origin the waiting + view's link uses, and any `ended` enrollment, with Dismiss. - **An older broker's status is read into this shape**: an `offer` object as - `true`, `relayUrl` as `relayOrigin`, and a missing `relayMode` as Hosted, so - it shows no enroll form. + `true`, `relayUrl` as `relayOrigin`, a missing `relayMode` as Hosted, and a + missing or malformed `hostedEnrollment` as `null`, an unknown end reason as + `failed`, a missing `accountOrigin` as `null`. - **Status is re-read, not patched**: the service's `status` event carries only `{ enrolled, serving, serviceId }`, so every event triggers a full `status` command, and the dialog re-reads on open since another window may have enrolled meanwhile. **The @@ -963,22 +1068,24 @@ exists to honor: **Never publish a failed read over a status already read**: the next poll retries, and only a subscription that has read none shows the error. - **Reads are serialized, and coalescing stops at anything that changes the - answer** — `enroll`, `reconnect`, `clearEnrollment` and losing the last - subscriber each drop the read in flight (rationale). + answer** — `enroll`, `beginHostedEnrollment`, `cancelHostedEnrollment`, + `reconnect`, `clearEnrollment` and losing the last subscriber each drop the + read in flight (rationale). -Source of truth: `RelayChoices`, `UnenrolledRelay`, and `useSetupQr` in -`lib/src/components/RemoteControlSection.tsx`, `ScannableCode` in +Source of truth: `RelayChoices`, `UnenrolledRelay`, `HostedEnrollView`, and +`useSetupQr` in `lib/src/components/RemoteControlSection.tsx`, `ScannableCode` in `lib/src/components/ScannableCode.tsx` over `lib/src/components/QrCode.tsx` (`uqr` encodes; that draws, lazily, so the encoder stays out of every main bundle); `describePushTargets` in `lib/src/components/SettingsDialog.tsx`; -`dropInFlightRead` in `lib/src/remote/burrow/burrow-status-store.ts`; +`dropInFlightRead` and `hostedEnrollmentOf` in +`lib/src/remote/burrow/burrow-status-store.ts`; `lib/src/host/remote/enroll-offer.ts` for the offer's well-known per-platform path, read by `readUsableOffer` in `lib/src/host/remote/service.ts`. The `window.dormouseBurrow` console hook — the scripting seam — exposes the -five enrollment commands: `enroll(password, label)`, -`enrollOffer(label)`, `status`, -`reconnect`, `clearEnrollment`. **Pairing confirmation is never here**: it is a +seven enrollment commands: `enroll(password, label)`, +`enrollOffer(label)`, `beginHostedEnrollment(label)`, +`cancelHostedEnrollment`, `status`, `reconnect`, `clearEnrollment`. **Pairing confirmation is never here**: it is a modal because it must interrupt, and because the digits it takes are read off a phone ([remote-security-model.md](./remote-security-model.md) -> Pairing). The one-time commands are not on the hook either; `status()` prints `serving` @@ -1155,12 +1262,3 @@ session requires fresh WebAuthn presence, by design Unstaged but adjacent: origin migration (re-binding the passkey and enrollments after a Tailscale node rename), and the revocation UI staged in [remote-security-model.md](./remote-security-model.md) `## Future`. - -**Scope: saas-multitenant** — the managed Relay on `relay.dormouse.sh` beyond the account-scoped routes, push, Pocket, the device-code enrollment, and the per-account relay sockets [hosted.md](./hosted.md) → "Relay", "Relay sockets", and "Burrow enrollment" serve: the Hosted transport below. The **remote-network** scope in [remote-network.md](./remote-network.md) owns the deployment, transport, and network restriction design. - -### From single-owner to multi-tenant - -Selfhost (everything above the fold) stays as-is; SaaS is a parallel deployment -that lifts each single-tenant simplification, every one chosen to be liftable: - -* **Hosted transport.** Follow the **remote-network** scope in [remote-network.md](./remote-network.md) for routing and lifecycle. diff --git a/docs/specs/relay.rationale.md b/docs/specs/relay.rationale.md index 75a79f810..8fc26e965 100644 --- a/docs/specs/relay.rationale.md +++ b/docs/specs/relay.rationale.md @@ -96,6 +96,10 @@ **Why `WS_CLOSE_BURROW_REPLACED` is terminal rather than retried.** Reconnecting on it would evict the newer Burrow, which would reconnect and evict this one, forever; an explicit `reconnect()` breaks the loop. +**Why `removed` and `not-entitled` latch too.** Tested on the Hosted preview (2026-10): a computer removed on the account page had its socket closed 4001, then reconnected with backoff forever, each upgrade refused 401, which the global `WebSocket` reports only as an error event — so Settings still showed it enrolled and reconnecting while every phone's Connect failed as "did not answer". Retrying a token the Relay refuses changes nothing. 4002 is its own code because the fix differs: a plan, not a re-enrollment. + +**Why the probe, and its route.** A machine that starts after its removal never sees the 4001, and a refused upgrade carries no status a `WebSocket` exposes, so only an HTTP request can learn why. `GET /api/push/devices` is the one Burrow-gated read both Relays serve and answer with the gate's refusal; a plain `GET /ws/burrow` would not do, since Hosted answers 426 before it looks the token up. A probe that got no answer spends nothing because a laptop waking before its network would otherwise spend the streak's probe on a timeout and never learn; a 5xx or other status not about the token — a Relay restarting, a proxy's 502 — spends nothing for the same reason; the reconnect backoff already bounds how often it asks. + **Why all socket events check ownership.** In a loopback `ws` experiment (2026-09), closing the client in its open callback still delivered a queued message while CLOSING. The runtime assigns a frame's epoch when it arrives, so the in-flight handshake guard alone cannot reject a retired socket's later delivery: it adopts the new epoch. Such an init recreated pending state after stop; a late `client-gone` disposed a replacement connection. Guarding message and open delivery alongside close preserves the stopped or replacement lifetime. **Where a bad enrollment record would surface.** A record minted with an `undefined` in its `ConnectionPolicy` fails at no point during enrollment; it fails at the *next* read, where the store rejects it, so the machine silently un-enrolls at the next launch — an app-restart away from the response that caused it. Failing the exchange on the spot names the missing fields instead. diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 0040a32b7..34803c08e 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -57,7 +57,7 @@ Source of truth: the surface model the wire shapes reuse — `dor/src/protocol.t **A `RemoteApiSession` exists only for an authorized session.** Created at promotion — presence proof and ACL conjunction both passed ([remote-security-model.md](./remote-security-model.md) → Connection) — and disposed when the Client disconnects, when the Burrow reaps the session, and by any promotion that replaces it, so **a re-authorizing Client can never inherit the previous session's attachment**. -**The Burrow says goodbye before an ending it chose**: `SessionEndV1` (`{ v: 1, t: 'session-end' }`, exact keys, no payload), one padded control message on whichever path carries the session, sent by `EstablishedE2eSession.end` — Take back, an idle reap, a one-time End, and a replacement from the same Client static. **Never on a poisoned session, never instead of the dispose.** **The session is over at the goodbye**: nothing after it is read, and the remote-api handler goes with it. **A switched channel closes only once the goodbye has left it** — the sender's queue empty and `bufferedAmount` zero — **or after `SESSION_END_FLUSH_MS` (500 ms)**, sending nothing more meanwhile; the relay send is synchronous onto the socket, and the dispose follows at once (rationale). A Client reports it as burrow loss (`endedByBurrow`); an older one ignores it as an unknown control shape (rationale). +**The Burrow says goodbye before an ending it chose**: `SessionEndV1` (`{ v: 1, t: 'session-end' }`, exact keys), one padded control message on whichever path carries the session, sent by `EstablishedE2eSession.end` — Take back, an idle reap, a one-time End, a replacement from the same Client static, and a direct-only session's ending. **A path's ending adds `reason: 'network-not-allowed'`, and may add one IP literal `address` (≤ 45 characters) with its `addressSource`** (`docs/specs/remote-network.md` -> "Local networks"). **Never on a poisoned session, never instead of the dispose.** **The session is over at the goodbye**: nothing after it is read, and the remote-api handler goes with it. **A switched channel closes only once the goodbye has left it** — the sender's queue empty and `bufferedAmount` zero — **or after `SESSION_END_FLUSH_MS` (500 ms)**, sending nothing more meanwhile; the relay send is synchronous onto the socket, and the dispose follows at once (rationale). A Client reports it as burrow loss (`endedByBurrow`); an older one ignores it as an unknown control shape (rationale). Source of truth: `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/burrow-runtime.ts`, `EstablishedE2eSession` in `lib/src/remote/burrow/established-session.ts`, `DirectEndpoint.disposeAfterFlush` in `lib/src/remote/direct/direct-endpoint.ts`, `SessionEndV1` in `remote-lib-common/src/security/e2e-ceremony.ts`, `ClientSessionCore` in `lib/src/remote/client/session-core.ts`. @@ -98,7 +98,7 @@ signal always fits one control body. Which ICE servers each end gathers through: [remote-network.md](./remote-network.md) -> "Anywhere"; Local networks -restricts a one-time attempt further +restricts an attempt further ([remote-network.md](./remote-network.md) -> "Local networks"). **The two shipped stacks are proven against each other by hand**, by @@ -437,7 +437,7 @@ These are the methods the dor CLI speaks today; the remote API reuses their requ **Scope: direct-path** — latency. The shipped half is [Transport → Direct path](#direct-path), which Pocket and both Burrows speak today. What remains is to **dogfood** it across a tailnet, keystroke round-trip measured relayed and direct into the rationale. -A paired phone's network levels follow the **remote-network** scope in [remote-network.md](./remote-network.md). A session surviving relay loss remains unstaged. +A session surviving relay loss remains unstaged. ### 9. Audio diff --git a/docs/specs/remote-network.md b/docs/specs/remote-network.md index 63f9caac2..d3fb9e300 100644 --- a/docs/specs/remote-network.md +++ b/docs/specs/remote-network.md @@ -11,42 +11,50 @@ | Level | Offered in | What Dormouse opens on its own | |---|---|---| | `nothing` | every build | nothing | -| `local` (Local networks) | Hosted builds | one-time links, managed voice | -| `anywhere` (Anywhere) | Hosted builds | one-time links to any network, Cloudflare STUN as a phone connects, managed voice | +| `local` (Local networks) | Hosted builds | one-time links, the relay socket once enrolled and push through it, managed voice | +| `anywhere` (Anywhere) | Hosted builds | one-time links to any network, Cloudflare STUN as a phone connects, the relay socket once enrolled and push through it, managed voice | | `relay` (My Relay only) | self-host builds | the relay socket, and push through it | - **A new install starts at Nothing.** **Must save the default at the service's first read, so it never flips**: `relay` where an enrollment for the baked origin exists — an upgraded self-host install — else `nothing` (rationale). A VS Code window with no service reads the default unsaved. - **A stored level the build does not offer, or a stored record that is not a policy — an unparseable file included — reads as `nothing`**; the first stays on disk, as an enrollment for another origin does. - **Until the policy is read, the service reads it as `nothing`**; a read that fails leaves the Burrow down, and `setNetworkPolicy` saves over it. -- **Only `relay` runs the persistent Burrow.** Under any other level an enrollment is held and reported — `enrolled`, `connection: 'stopped'` — and the relay socket stays shut, since no other level has a path rule for a persistent session yet (`## Future`). +- **Every level but `nothing` runs the persistent Burrow** on an enrollment for the baked origin (`runsBurrow`); under `nothing` it is held and reported — `enrolled`, `connection: 'stopped'`. **Must start `BurrowRuntime` on the level's `directPeeringFor`, restarting it on any change `samePaths` sees**; each session hears the goodbye as it stops. - **`setNetworkPolicy` takes a policy only exactly**: its three keys, a level the build offers, at most 32 CIDRs, and a boolean `autoUpdate`. **Must save each CIDR in its canonical form** (`canonicalCidr`), refusing one that does not parse or repeats once canonical, and answer what it saved. **Must save before acting**: a save that fails changes nothing. -- **Must end the live one-time link or session with `user-ended`, and rest an ended one, on a change to the level, or to the allowed networks under `local`, and start or stop the relay socket to match**; a narrowed policy never leaves an old path exempt. **The allowed networks under any other level, and `autoUpdate`, end nothing** (rationale). -- **Every change is a `network-policy` event** carrying what `networkPolicy` answers: the policy, the build's levels, and this machine's interfaces, each with its addresses' canonical prefixes (loopback and link-local, `169.254.0.0/16` included, left out) and a `lan`, `vpn`, or `virtual` kind. **A Tailscale interface — named `tailscale*`, or carrying an `fd7a:115c:a1e0::/48` address — offers a host route as its tailnet range**, `100.64.0.0/10` or `fd7a:115c:a1e0::/48`, since a `/32` admits no phone; any other host route is offered as reported (rationale). +- **Must end the live one-time link or session with `user-ended`, and rest an ended one, on a change to the level, or to the allowed networks under `local`, and start, stop, or restart the relay socket to match**; a narrowed policy never leaves an old path exempt. **The allowed networks under any other level, and `autoUpdate`, end nothing** (rationale). +- **Every change is a `network-policy` event** carrying what `networkPolicy` answers: the policy, the build's levels, the path refusal held ("Local networks"), and this machine's interfaces, each with its addresses' canonical prefixes (loopback and link-local, `169.254.0.0/16` included, left out) and a `lan`, `vpn`, or `virtual` kind. **A Tailscale interface — named `tailscale*`, or carrying an `fd7a:115c:a1e0::/48` address — offers a host route as its tailnet range**, `100.64.0.0/10` or `fd7a:115c:a1e0::/48`, since a `/32` admits no phone; any other host route is offered as reported (rationale). **Must enforce the policy at its choke points** — the Burrow service, the managed-voice host, and the updater ("Updates"); **a new outbound path adds one here before it ships** (rationale). What the user clicks, and what their terminals, browser panes, and agents reach, are their own connections. **Nothing opens nothing**: -- **The Burrow service's socket factory, fetch, and direct-peer factory refuse at the call while the level is `nothing` or unread, and once the service is disposed** — the socket factory throws (a runtime reads it as a closed socket), fetch rejects, the peer factory answers `null` — so a path that forgets its own check still opens nothing. Everything the service opens, the enrollment exchange included, goes through them; the checks below stay, for the error a person reads. +- **The Burrow service's socket factory, fetch, and direct-peer factory refuse at the call while the level is `nothing` or unread, and once the service is disposed** — the socket factory throws (a runtime reads it as a closed socket), fetch rejects, the peer factory answers `null` — so a path that forgets its own check still opens nothing. Everything the service opens, the enrollment exchange included, goes through them; the checks below stay, for the error a person reads. **A request that gets no answer rejects naming the host and why** (`describeFetchFailure`), never a bare `fetch failed`. - **The Burrow service never opens the relay socket**, the enrollment held as above, so push, the device list, a test push, and setup codes, which need a running Burrow, make no request. -- **It refuses `enroll` and `enrollOffer` before any request**, the offer file unread. +- **It refuses `enroll`, `enrollOffer`, and `beginHostedEnrollment` before any request**, the offer file unread, and a change to `nothing` ends a Hosted enrollment awaiting approval. - **It offers no one-time link**: the resting state is `unavailable` with reason `network-off`, and `oneTimeOpen` is refused, as under `local` with no network allowed. - **Managed voice asks the service before every speak** and answers `network-off` without a request. -Source of truth: `NetworkPolicy`, `levelsFor`, and `storedNetworkPolicy` in `lib/src/remote/network-policy.ts`; `peekNetworkPolicyFor` and `BurrowService` in `lib/src/host/remote/service.ts`; `canonicalCidr`, `allowedAddressTest`, and `classifyNetworkInterfaces` in `lib/src/host/remote/network-interfaces.ts`; `NETWORK_POLICY_KEY` in `vscode-ext/src/burrow-store.ts`; `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts`; `subscribeToNetworkPolicy` in `lib/src/remote/burrow/network-policy-store.ts`. Pinned by `lib/src/host/remote/service.test.ts` and `lib/src/host/remote/network-interfaces.test.ts`. +Source of truth: `NetworkPolicy`, `levelsFor`, `runsBurrow`, and `storedNetworkPolicy` in `lib/src/remote/network-policy.ts`; `peekNetworkPolicyFor` and `BurrowService` in `lib/src/host/remote/service.ts`; `samePaths` in `lib/src/host/remote/direct-peering.ts`; `canonicalCidr`, `allowedAddressTest`, and `classifyNetworkInterfaces` in `lib/src/host/remote/network-interfaces.ts`; `NETWORK_POLICY_KEY` in `vscode-ext/src/burrow-store.ts`; `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts`; `describeFetchFailure` in `lib/src/remote/burrow/burrow-fetch.ts`; `subscribeToNetworkPolicy` in `lib/src/remote/burrow/network-policy-store.ts`. Pinned by `lib/src/host/remote/service.test.ts`, `lib/src/remote/burrow/burrow-fetch.test.ts`, and `lib/src/host/remote/network-interfaces.test.ts`. ## Local networks -Under `local` each one-time runtime is held to the networks allowed at its open; a change ends it ("Policy"). +Under `local` each runtime — a one-time link, or the persistent Burrow — is held to the networks allowed at its open or start; a change ends it ("Policy"). - **The attempt's UDP socket binds the one allowed address when exactly one is present**: a single interface holds every address in the allowed networks, loopback and link-local aside, and exactly one in its preferred family, IPv4 over IPv6. **Otherwise it listens on every interface** (`docs/specs/remote-security-model.md` -> "Direct path"), and the level restricts the path, not the listener. Chosen per attempt (rationale). - **Must strip every candidate outside the allowed networks from the Burrow's answer**, and send a default address outside them as `0.0.0.0`. **An answer left with no candidate refuses the attempt.** - **Must strip the phone's offer the same way before the Burrow applies it**, a hostname or mDNS name included, so its ICE agent sends no check and makes no lookup toward an address the level does not hold. **An offer left with no candidate is still answered**: the phone's checks reach the answer's candidates, and the pair forms peer-reflexive (rationale). - **Must check the selected candidate pair on the Burrow before its channel reports open**, and again while it is open and `connected` — on every ICE or connection state change, and every `DIRECT_PATH_RECHECK_MS` (rationale): both ends parse as IP addresses — IPv4-mapped IPv6 matching its IPv4 range — each inside an allowed CIDR. **A hostname, an mDNS name, or a pair the stack will not report refuses**, and a frame arriving before the open is checked first; once open, a reading with no pair is left to the connection's own state (rationale). - **Never trust SDP candidates, Hosted-observed addresses, or Client claims** as path evidence; only the Burrow's own ICE agent answers (rationale). -- **A refusal is a violation**: it ends the connection `network-not-allowed` (`docs/specs/one-time.md` -> "Burrow runtime"), switched or not. +- **A refusal is a violation**: it ends the session `network-not-allowed` (`docs/specs/one-time.md` -> "Burrow runtime"), switched or not. - **The check gates terminal traffic, not approval** (rationale): an off-network phone holding a link can reach the two-digit prompt and still receives no terminal byte. +- **A paired phone's session is direct-only**, with the one-time rule (`docs/specs/one-time.md` -> "Burrow runtime"): **`BurrowRuntime` makes a session direct-only exactly where the path policy is held**, says so in its outcome (`directOnly`), and hands the flag to `EstablishedE2eSession`, which ends it unread on an application message off the Relay, on a refused path, on a given-up attempt, or not direct both ways by `DIRECT_ONLY_DEADLINE_MS` — each with the goodbye. +- **Pocket sends no protocol-v1 on a direct-only session before both directions are direct.** A connect that never gets there ends the session with fixed copy: `DIRECT_ONLY_UNSUPPORTED_MESSAGE` where the browser has no WebRTC or the computer declined, the path refusal's naming the phone's address (below), else `DIRECT_ONLY_FAILED_MESSAGE`, **which never says to join a network**. **A Burrow ending meanwhile is that connect's failure alone**, never burrow loss. +- **The path refusal: where the path ends a direct-only session a path policy holds — refused, or given up or past the deadline once the phone offered — `EstablishedE2eSession` records `{ at, kind, end?, … }`**, paired and one-time alike: + - **`end: 'local'` where the policy refused this machine's end** — the pair's local end, checked first, or no candidate of this end — naming only its own address (`localAddress`). + - **Else `end: 'remote'`, naming the refused pair's remote end (`observed`), the only evidence; else the first public IP literal outside the allowed networks the phone offered, read before the strip (`reported`), which decides nothing** (rationale). No end where neither applies. + - **The goodbye carries only the phone's address** (`docs/specs/remote-api.md` -> Transport), on the relay before the switch; the phone shows `networkNotAllowedMessage`, **never told the allowed networks, nor that a `reported` address is off them**; no address, or no goodbye, keeps the generic copy. + - **The service holds the latest in memory** (`onPathRefused`, from both runtimes) on `networkPolicy` until `dismissPathRefusal`, **which clears only the refusal held at its arrival**; a one-time ending carries it. The laptop shows `pathRefusalSentence`, **blaming the phone's network only for `end: 'remote'`**, dated when not today. - **Never describe the level as proof of proximity** — a range is an address range, which another network can reuse, and a permitted peer can forward. +- **Never enroll Hosted into a customer's tailnet** or mint per-customer hostnames. -Source of truth: `holdsToAllowedNetworks` in `lib/src/remote/network-policy.ts`; `bindAddressFor` and `localNetworksPath` in `lib/src/host/remote/local-networks.ts`; `createNativeDirectPeerFactory` in `lib/src/host/remote/native-direct-peer.ts`; `DirectPathPolicy` and `DirectPeer` in `lib/src/remote/direct/direct-peer.ts`; `DirectEndpoint` in `lib/src/remote/direct/direct-endpoint.ts`; `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts`; `BurrowService` in `lib/src/host/remote/service.ts`. Pinned by `lib/src/host/remote/local-networks.test.ts`, `lib/src/remote/direct/direct-peer.test.ts`, `lib/src/remote/burrow/one-time-runtime.test.ts`, `lib/src/host/remote/service.test.ts`, and on the real addon `lib/src/host/remote/native-direct-peer.test.ts`; against a browser by hand, `scripts/direct-interop/run.mjs --allow` (rationale). +Source of truth: `holdsToAllowedNetworks` in `lib/src/remote/network-policy.ts`; `bindAddressFor`, `localNetworksPath`, and `firstPublicCandidate` in `lib/src/host/remote/local-networks.ts`; `createNativeDirectPeerFactory` in `lib/src/host/remote/native-direct-peer.ts`; `DirectPathPolicy` and `DirectPeer` in `lib/src/remote/direct/direct-peer.ts`; `DirectEndpoint` in `lib/src/remote/direct/direct-endpoint.ts`; `PathRefusal` and `goodbyeFor` in `lib/src/remote/direct/path-refusal.ts`; `networkNotAllowedMessage` in `lib/src/remote/client/session-core.ts`; `pathRefusalSentence` in `lib/src/components/remote-control-shared.ts`; `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts`; `BurrowRuntime` in `lib/src/remote/burrow/burrow-runtime.ts`; `EstablishedE2eSession` in `lib/src/remote/burrow/established-session.ts`; `DIRECT_ONLY_DEADLINE_MS` in `remote-lib-common/src/security/direct-path.ts`; `PocketClient.connect` in `lib/src/remote/client/pocket-client.ts`; `ClientSessionCore.awaitDirect` in `lib/src/remote/client/session-core.ts`; `BurrowService` in `lib/src/host/remote/service.ts`. Pinned by `lib/src/host/remote/local-networks.test.ts`, `lib/src/remote/direct/direct-peer.test.ts`, `lib/src/remote/burrow/one-time-runtime.test.ts`, `lib/src/remote/burrow/burrow-direct-only.test.ts`, `lib/src/remote/client/pocket-client.test.ts`, `lib/src/remote/client/one-time-e2e.test.ts`, `lib/src/remote/direct/path-refusal.test.ts`, `hosted/server/tests/relay-room.test.ts`, `lib/src/host/remote/service.test.ts`, and on the real addon `lib/src/host/remote/native-direct-peer.test.ts`; against a browser by hand, `scripts/direct-interop/run.mjs --allow` (rationale). ## Anywhere @@ -55,10 +63,13 @@ Under `anywhere` a one-time link opens with no network allowed, and its attempt - **Must gather through Cloudflare STUN on the Burrow under Anywhere alone**, else through no ICE server (rationale). - **Must choose a runtime's STUN and its path policy together, from the policy it opens or starts under**, and hold both for its life: a change to either ends it ("Policy"). - **Clients Hosted serves must always gather through Cloudflare STUN; Clients a self-host Relay serves, through none.** No policy crosses the wire, and a Client's extra candidates cannot widen Local networks, whose Burrow checks the path (rationale). +- **One Pocket bundle serves both, choosing its factory by deployment**: Hosted's staging writes `deployment.json` beside it, and any other complete answer there (a shell, a 404) reads as self-host. **Never put the file in a Pocket build**; the staging refuses one. +- **Pocket must never build a peer before it knows its deployment**: Connect and pairing await the read (`POCKET_DEPLOYMENT_READ_TIMEOUT_MS`); a complete answer is cached for the page, and an incomplete one (or a 5xx) fails retryably, read again next time. +- **A paired phone may fall back to relaying through Hosted**: under Anywhere the persistent Burrow holds no path policy, so its sessions are not direct-only. - **Never TURN** (direct-only: `docs/specs/one-time.md` -> "Burrow runtime"; rationale). - **Never proxy STUN over an HTTP or WebSocket endpoint**; only STUN on the WebRTC socket observes its mapping. -Source of truth: `CLOUDFLARE_STUN_URL` and `stunServers` in `lib/src/remote/direct/ice-servers.ts`; `burrowUsesStun` in `lib/src/remote/network-policy.ts`; `directPeeringFor` in `lib/src/host/remote/direct-peering.ts`; `BurrowDirectPeerFactory` and `createNativeDirectPeerFactory` in `lib/src/host/remote/native-direct-peer.ts`; `hostedDirectPeer` and `selfHostDirectPeer` in `lib/src/remote/client/browser-direct-peer.ts`. Pinned by `lib/src/host/remote/service.test.ts`, `lib/src/remote/client/browser-direct-peer.test.ts`, and on the real addon `lib/src/host/remote/native-direct-peer.test.ts`; by hand, `scripts/direct-interop/run.mjs --stun` (rationale); audited at `docs/specs/security-remote.md` -> "Direct path". +Source of truth: `CLOUDFLARE_STUN_URL` and `stunServers` in `lib/src/remote/direct/ice-servers.ts`; `burrowUsesStun` in `lib/src/remote/network-policy.ts`; `directPeeringFor` in `lib/src/host/remote/direct-peering.ts`; `BurrowDirectPeerFactory` and `createNativeDirectPeerFactory` in `lib/src/host/remote/native-direct-peer.ts`; `hostedDirectPeer` and `selfHostDirectPeer` in `lib/src/remote/client/browser-direct-peer.ts`; `deploymentDirectPeer`, `pocketDeploymentSource`, and `readPocketDeployment` in `lib/src/remote/pocket-app/deployment.ts`; `POCKET_DEPLOYMENT_FILE` and `HOSTED_POCKET_DEPLOYMENT` in `remote-lib-common/src/remote/pocket-deployment.ts`; `stageRelay` in `hosted/scripts/stage-relay.mjs`. Pinned by `lib/src/host/remote/service.test.ts`, `lib/src/remote/client/browser-direct-peer.test.ts`, `lib/src/remote/pocket-app/deployment.test.ts`, `lib/src/remote/pocket-app/App.scan.test.tsx`, `hosted/scripts/stage-relay.test.mjs`, `relay/test/static.test.mjs`, and on the real addon `lib/src/host/remote/native-direct-peer.test.ts`; by hand, `scripts/direct-interop/run.mjs --stun` (rationale); audited at `docs/specs/security-remote.md` -> "Direct path". ## Updates @@ -78,39 +89,31 @@ The Settings dialog's Network topic (`docs/specs/alert.md` -> "Settings dialog") - **Choosing Local networks with nothing allowed must first allow every prefix of this machine's `lan` interfaces**, never a `vpn` or `virtual` one; the panel fills them, not the service (rationale). **Any other choice must keep `allowed`.** - **The connection list states only what is built** (rationale): `connectionsFor` answers these rows and no others. Under `nothing` it answers none, and the list reads "Nothing. Terminals and browser panes still reach whatever you open in them, including panes restored at launch." +The relay socket runs ("persistent" below) where `runsBurrow` holds, in a self-host build or with an enrollment, unless the Burrow latched `removed` or `not-entitled` (`relayRefuses`; `docs/specs/relay.md` -> "Burrow side"). + | Row | Listed when | |---|---| -| the relay origin: only while a one-time link is open, handshakes and never terminal traffic | `opensOneTimeLinks` (`local`, a network allowed; `anywhere`) | -| `stun.cloudflare.com`, as a phone connects | `burrowUsesStun` (`anywhere`) | -| your phone, on any network where `phoneOnAnyNetwork` (`anywhere`), else an allowed one | `opensOneTimeLinks` | -| the Relay origin: always (unenrolled, "once this computer is enrolled") | `relay` | -| your phone, directly | `relay` | -| the Relay origin to the phone's push service, "where push is on" | `relay`, a phone paired (rationale) | +| the relay origin: only while a one-time link is open, handshakes and never terminal traffic | `opensOneTimeLinks` (`local`, a network allowed; `anywhere`), not persistent | +| the relay origin: always (unenrolled, "once this computer is enrolled"); terminal traffic when a phone can't connect directly, never under `local` | persistent | +| `stun.cloudflare.com`, as a phone connects | `opensOneTimeLinks` and `burrowUsesStun` (`anywhere`) | +| your phone: on any network where `phoneOnAnyNetwork` (`anywhere`), else an allowed one; "directly" where persistent (`relay`: only that) | `opensOneTimeLinks`, or `relay` and not so latched | +| the relay origin to the phone's push service, "where push is on" | persistent, a phone paired (rationale) | | `voice.dormouse.sh`, speaking in the managed voice | a Hosted build, a voice token saved | | `dormouse.sh`, each launch | `autoUpdate` on, in a build that updates itself ("Updates" below), in every window | -- **Allowed networks**, under `local`: one switch per interface, on when all its prefixes are allowed. **Must list every allowed range no switch reading On covers**, with Remove, naming the interface a partly allowed one belongs to; **switching one off keeps a range another switch reading On needs**. A typed range goes to the service, which saves its canonical form; more than 32 is refused in the panel. **With nothing allowed it says no phone can connect.** +- **Allowed networks**, under `local`: one switch per interface, on when all its prefixes are allowed. **Must list every allowed range no switch reading On covers**, with Remove, naming the interface a partly allowed one belongs to; **switching one off keeps a range another switch reading On needs**. A typed range goes to the service, which saves its canonical form; more than 32 is refused in the panel. **With nothing allowed it says no phone can connect.** **A held path refusal shows above the switches, with Dismiss.** - **Phones**: under any level but `nothing`, the Remote control choices (`docs/specs/relay.md` -> "Remote control, in the Settings dialog"); under `nothing`, the levels that allow a phone, each choosing itself, and Disconnect for a held enrollment, which is local. - **Updates**: a self-host build says it never updates itself, and a host with `hostOwnsUpdates` (VS Code) names the Marketplace. Any other build updates itself: the automatic-check switch, absent under `nothing`, over "Checked at each launch." or "Checked only when you ask."; **only with the platform's `updates` port** (`docs/specs/auto-update.md` -> "Threading") the last successful check — "Never checked on this computer." for none — Check now, and the week the Baseboard waits. - **Under `nothing`, Notifications' push and managed-voice lines say they are off because Network is set to Nothing**, each linking to this topic. **The Baseboard holds the policy store for the window's life**, so its settings preview reads the level on its first frame; the Network panel re-reads on mount, and **a failed re-read never replaces a policy already read**. -Source of truth: `NetworkSettings`, `NetworkPhones`, `NetworkUpdates`, `connectionsFor`, and `policyForLevel` in `lib/src/components/NetworkSettings.tsx`; `opensOneTimeLinks`, `burrowUsesStun`, `phoneOnAnyNetwork`, and `holdsToAllowedNetworks` in `lib/src/remote/network-policy.ts`; `changeNetworkPolicy` in `lib/src/remote/burrow/network-policy-store.ts`; `AlarmSettingsSection` in `lib/src/components/SettingsDialog.tsx`; `Baseboard` in `lib/src/components/Baseboard.tsx`. Pinned by `lib/src/components/NetworkSettings.test.tsx`, `lib/src/components/SettingsDialog.test.tsx`, `lib/src/components/Baseboard.test.tsx`, and `lib/src/stories/NetworkSettings.stories.tsx`. +Source of truth: `NetworkSettings`, `NetworkPhones`, `NetworkUpdates`, `connectionsFor`, and `policyForLevel` in `lib/src/components/NetworkSettings.tsx`; `opensOneTimeLinks`, `burrowUsesStun`, `phoneOnAnyNetwork`, and `holdsToAllowedNetworks` in `lib/src/remote/network-policy.ts`; `changeNetworkPolicy` and `dismissPathRefusal` in `lib/src/remote/burrow/network-policy-store.ts`; `relayRefuses` in `lib/src/host/remote/service-protocol.ts`; `AlarmSettingsSection` in `lib/src/components/SettingsDialog.tsx`; `Baseboard` in `lib/src/components/Baseboard.tsx`. Pinned by `lib/src/components/NetworkSettings.test.tsx`, `lib/src/components/SettingsDialog.test.tsx`, `lib/src/components/Baseboard.test.tsx`, and `lib/src/stories/NetworkSettings.stories.tsx`. ## Future **Scope: remote-network** — build in order: 1. **Anywhere on a phone**: **Must measure iOS Safari's offer size and gathering time, and the Burrow with STUN blocked**, before changing a budget. -2. **Hosted persistent**: desktop enrollment and the path rule for paired phones, beyond the routes, push, and sockets in `docs/specs/hosted.md` -> "Relay", "Relay sockets", and "Burrow enrollment", with **saas-multitenant** in `docs/specs/relay.md`, its connections in `connectionsFor`, the push row among them with desktop enrollment; Local networks and Anywhere then cover paired phones. ### Allowed networks - **Where several addresses are allowed, a per-attempt UDP forwarder may narrow the listener** (rationale): the peer binds loopback, one socket per allowed address relays to it, dropping sources outside the allowed networks, and the answer names those sockets. - -### Hosted persistent - -- **Under Local networks a paired phone's session is direct-only**, with the one-time rule: an application message off the Relay ends it unread. **May fall back to Hosted relaying under Anywhere.** -- **Must choose Pocket's direct-peer factory by deployment** ("Anywhere"), one bundle serving both; `lib/src/remote/pocket-app/App.tsx` hard-codes `selfHostDirectPeer`. -- **Must start `BurrowRuntime` on the level's `directPeeringFor`, restarting it on any change `samePaths` sees** ("Anywhere"). -- **Must enroll a Hosted build's Burrow by device code from the service** (`docs/specs/hosted.md` -> "Burrow enrollment"): begin and poll every `interval`, validate the begin answer, show the user code, and stop on `NOT_ENTITLED_ERROR`. The Burrow composes the verification URL itself from `ENROLL_PAGE_PATH` and the user code, never trusting the Relay's `verificationUrl`, and opens it only on the user's click and only at `https://hosted.dormouse.sh` in a release Hosted build; a dev Hosted build may follow the `verificationUrl` origin, as `DORMOUSE_RELAY_IS_HOSTED` relaxes the relay origin. `isEnrollment` in `lib/src/remote/burrow/enrollment.ts` stays the one guard of the enrollment shape. -- **Never enroll Hosted into a customer's tailnet** or mint per-customer hostnames. diff --git a/docs/specs/remote-network.rationale.md b/docs/specs/remote-network.rationale.md index 427a37833..23311f488 100644 --- a/docs/specs/remote-network.rationale.md +++ b/docs/specs/remote-network.rationale.md @@ -83,6 +83,18 @@ hold a secret: a one-time link shown on the laptop, or an invitation QR. With the check at channel open, every guarantee the user sees still holds — no terminal byte crosses a disallowed path — for none of that protocol. +**Why the code still comes first (Ned, 2026-10-01).** Failing a wrong-network +phone before the two-digit prompt was weighed again, so pairing could double as +the connection test. Only the Burrow's ICE agent can judge the path — browsers +hide their addresses behind mDNS, and the Relay sees only public IPs — so the +check would run ICE and DTLS with a phone nobody has approved, for anyone +holding the QR or link, and hand that holder the laptop's addresses: the +allowed-network ones under Local networks, the STUN-learned public IP under +Anywhere. With the code first, a grabbed QR or link yields the Relay's address +and the Burrow id, nothing more, unless the person at the laptop types its +digits. The phone learns at its first session instead, from a message naming the +address the laptop saw. + **Why the selected pair is evidence.** The pair's remote address is the one that answered ICE connectivity checks under the attempt's ufrag and password, which reach the peer only inside the session. SDP text, mDNS names, and addresses @@ -95,6 +107,28 @@ remote end inside an allowed range is what holds the path. A route that carries an allowed range elsewhere — a VPN claiming the LAN's range — is the proximity caveat again. +**Why a reported address is named, and never decides.** A phone on cellular +forms no pair: the Burrow strips every candidate it offers and answers with +allowed addresses the phone cannot reach, so the attempt gives up with nothing +observed — the common case Ned hit testing the one-time preview (2026-10-01). +The phone's offer still carries its server-reflexive candidate, the public +address Cloudflare STUN saw, and naming it tells the person at the laptop +"that was a carrier, not your Wi-Fi". But the offer is the phone's own text, +written before any check answered, so it is shown as what the phone reported +and read by nothing that decides. Only a pair the policy refused is named as +where the phone connected from: an allowed pair that never carried a channel +is no evidence of the network the phone was on. + +**Why a refusal says which end (review, 2026-10-01).** A laptop that left its +allowed networks — off the home Wi-Fi, a VPN down — gets its pair's local end +refused while the phone sits on the right network; naming the phone's address +then told the person at the laptop the phone was at fault, and told the phone +to join a network it was already on. The local end is checked first, since a +phone's address says nothing while the laptop is itself off the networks. A +reported address is read outside the allowed networks only, since one inside +them is no reason for a refusal, and its copy never asserts it is off them: the +phone's offer is a claim, and an allowed range can be public. + **Why a timer as well as the state events.** libjuice, the ICE agent under `node-datachannel`, makes the first nominated pair in its priority order the selected one on every bookkeeping pass, and changes state only through a diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 533e0ec41..8b7bef1c3 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -271,7 +271,8 @@ Source of truth: `BurrowRuntime.mintInvitation` / `#onPairingInit` / requires one active `BurrowAclRecord` holding all four of `accountId`, `passkeyCredentialId`, `passkeyPublicKeyHash`, and the IK-authenticated Client static. -- **Then `ConnectionOutcomeV1`**: success carries the Burrow label; denial carries +- **Then `ConnectionOutcomeV1`**: success carries the Burrow label (and + `directOnly` under Local networks); denial carries only `pairing-required`, `presence-rejected`, `protocol-rejected`, `burrow-busy`, or `burrow-error`. **Every ACL miss is `pairing-required`** — individual ACL and presence failures are logged owner-locally @@ -329,7 +330,7 @@ runtime that carries these rules out ("Burrow runtime"). outcome promotes the same session; the phone refuses protocol-v1 until both directions are direct, and an application message the Burrow decrypts off the rendezvous ends the session unread. A session with no direct path by - `ONE_TIME_DIRECT_DEADLINE_MS` ends at both ends, with no relayed fallback. + `DIRECT_ONLY_DEADLINE_MS` ends at both ends; no relayed fallback. - **After the switch the direct channel, not the rendezvous, is the lifecycle authority** — for this ceremony alone, the carve-out from [remote-api.md](./remote-api.md) -> "Direct path"'s rule that the Relay stays @@ -417,12 +418,13 @@ runtime holds the same line against the rendezvous (`docs/specs/one-time.md` | `E2E_INIT_BURST` / `E2E_INIT_REFILL_INTERVAL_MS` | 8 / 1 000 | same | | `DIRECT_SETUP_TIMEOUT_MS` / `DIRECT_ANSWER_TIMEOUT_MS` / `DIRECT_GATHER_TIMEOUT_MS` / `DIRECT_SRFLX_GRACE_MS` | 15 000 / 10 000 / 3 000 / 500 | `remote-lib-common/src/security/direct-path.ts` | | `DIRECT_HANDOFF_TIMEOUT_MS` / `DIRECT_DISCONNECTED_GRACE_MS` | `= DIRECT_SETUP_TIMEOUT_MS` (15 000) / 5 000 | same | +| `DIRECT_ONLY_DEADLINE_MS` | `= DIRECT_SETUP_TIMEOUT_MS + DIRECT_HANDOFF_TIMEOUT_MS` (30 000; rationale) | 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; one pair for a receiver's hold and a sender's queue alike (rationale) | same | | `DIRECT_BUFFER_HIGH` / `DIRECT_BUFFER_LOW` | 256 KiB / 64 KiB | same | | `MAX_ONE_TIME_FRAME_LENGTH` | one maximal `ct` + 512 | `remote-lib-common/src/remote/one-time-wire.ts` | | `MAX_ONE_TIME_FORWARDED` | 32 messages, both directions together | same | -| `ONE_TIME_LINK_TTL_MS` / `ONE_TIME_EXPIRY_GRACE_MS` / `ONE_TIME_DIRECT_DEADLINE_MS` | `= DEFAULT_PAIRING_TTL_MS` / 30 000 / 15 000 | same | +| `ONE_TIME_LINK_TTL_MS` / `ONE_TIME_EXPIRY_GRACE_MS` | `= DEFAULT_PAIRING_TTL_MS` / 45 000 (> `DIRECT_ONLY_DEADLINE_MS`) | same | | `ONE_TIME_OPEN_TIMEOUT_MS` | 8 000 | `lib/src/remote/burrow/one-time-runtime.ts` | - **Must bound waiting relay frames before enqueueing**, by count and cumulative diff --git a/docs/specs/remote-security-model.rationale.md b/docs/specs/remote-security-model.rationale.md index 5dc268901..e5aebb275 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -277,6 +277,14 @@ 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). +**Why the direct-only deadline is the sum of two bounds.** The Burrow arms it +when it sends the outcome, but the phone arms its own `DIRECT_SETUP_TIMEOUT_MS` +only once that outcome reaches it, and the switch it then sends crosses the +relay, which `DIRECT_HANDOFF_TIMEOUT_MS` bounds. A deadline equal to the setup +bound alone (as first shipped) could end a session whose phone was still inside +both of its own bounds; the one-time runtime and the paired Burrow share the one +constant so neither can drift shorter. + ## Direct path *(2026-09; the path shipped on the Pocket side and the standalone Burrow.)* diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 0434bfba1..2186a578a 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -43,14 +43,14 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. ## Relay boundary -**The Hosted Relay** on the relay Worker, and its account routes on the account Worker: `docs/specs/hosted.md` -> "Relay", "Relay sockets", and "Burrow enrollment" own them; these are the checks on them. Inspect `relayApiRoutes` in `hosted/server/relay-api.ts`, `hosted/server/relay-auth.ts`, `relaySocketRoutes` in `hosted/server/relay-sockets.ts`, `RelayRoom` in `hosted/server/relay-room.ts`, the frame layer in `remote-lib-common/src/remote/relay-routing.ts`, `relayAccountRoutes` in `hosted/server/relay-account.ts`, `cookieAdmin` in `hosted/server/account-gate.ts`, `hosted/server/relay-push.ts`, and `hosted/server/dormouse-migrations/002_relay.sql` and `003_relay_push.sql`. +**The Hosted Relay** on the relay Worker, and its account routes on the account Worker: `docs/specs/hosted.md` -> "Relay", "Relay sockets", and "Burrow enrollment" own them; these are the checks on them. Inspect `relayApiRoutes` in `hosted/server/relay-api.ts`, `hosted/server/relay-auth.ts`, `relaySocketRoutes` in `hosted/server/relay-sockets.ts`, `RelayRoom` in `hosted/server/relay-room.ts`, the frame layer in `remote-lib-common/src/remote/relay-routing.ts`, `relayAccountRoutes` in `hosted/server/relay-account.ts`, `cookieAdmin` in `hosted/server/account-gate.ts`, `hosted/server/relay-push.ts`, and `hosted/server/dormouse-migrations/002_relay.sql`, `003_relay_push.sql`, and `004_relay_enrollment_redeemed.sql`. - **FAIL IF** a session, Burrow, or setup token is stored other than as its SHA-256, an enrollment device code is stored at all, or an account's Relay rows outlive its user row. - **FAIL IF** a query reading a Burrow, passkey, presence nonce, or setup token is not scoped to the caller's account (the session's user, or the Burrow token's owner), a passkey registers to any account but the minting Burrow's owner, or a setup challenge redeems with another Burrow's token. - **FAIL IF** a setup token, challenge, or presence nonce is checked and spent in separate statements, so two concurrent redeemers can both win, or a restored setup token outlives its original expiry or is restored once it has passed, evicting a live one. - **FAIL IF** a Burrow-authenticated request, setup redemption, enrollment redemption, sign-in, or session-gated request skips rechecking the owner's entitlement (`isAdmin` on its user row) in that request, so a de-entitled account's session acts or a sign-in mints one; or a removed Burrow's row, setup tokens, or setup challenges survive its removal, or removal reaches another account's Burrow. - **FAIL IF** an unauthenticated route writes a row other than a sign-in challenge (enrollment begin writes none); a device code's expiry is read from anywhere but its own leading bytes, or a code past it reaches the database; or a user code is anything but `enrollUserCode` under `RELAY_ENROLL_SECRET`. -- **FAIL IF** an approval is redeemed other than in the one statement that deletes it and inserts its Burrow, so two polls can mint two Burrows; the Burrow is owned by anyone but the approval's `userId`; a poll redeems an approval for a user code its device code does not derive; an approval replaces a live one; or a poll or approval reads an approval past its `expiresAt`. +- **FAIL IF** an approval is redeemed other than in the one statement that marks it redeemed and inserts its Burrow, or a redeemed approval redeems again (removing its Burrow included), so two polls can mint two Burrows; the Burrow is owned by anyone but the approval's `userId`; a poll redeems an approval for a user code its device code does not derive; an approval replaces a live one; or a poll or approval reads an approval past its `expiresAt`. - **FAIL IF** enrollment begin or poll admits a request carrying `Origin`, or either reaches the database before its per-address limit. - **FAIL IF** an approval admits a request without a login, with an `Origin` other than the account's own exactly, from a login older than `LOGIN_FRESH_AGE_MS` or one whose creation time it cannot read, for an account but the entitled admin, or past the per-account attempt limit (`RELAY_APPROVE_LIMIT`, counted before the body is read). - **FAIL IF** a table a caller can grow has no cap keyed by whoever grows it (approvals: the per-account attempt limit), or a capped write deletes another key's rows; `signin/*` or `setup/begin`/`finish` reaches the database before its per-address limit; or a production `namespace_id` reaches `PREVIEW_RATELIMIT_OFFSET`. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index fc9d3f2ec..a26759f5a 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -162,7 +162,7 @@ Source of truth: `startCapabilityViewer` / `isInsideRoot` / `pathSegments` in `d The exposure is traffic the user never chose, which under Nothing is none. `docs/specs/remote-network.md` -> "Policy" owns the rule these checks audit. -- **FAIL IF** anything opens a connection on its own while the network policy is `nothing` or unread. `BurrowService` in `lib/src/host/remote/service.ts` must route every socket, request, and direct peer through its transport guard, which refuses at the call; start no `BurrowRuntime` under any level but `relay`; and refuse `enroll`, `enrollOffer`, and `oneTimeOpen` before any request. `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts` must ask `networkAllowed` before every speak, and `runUpdateCheck` in `standalone/src/updater.ts` must not call `check()` unless the policy it read is not `nothing` and `autoUpdate` is on, a failed read counting as `nothing`. Search the rest of `lib/src/host/`, `standalone/src/`, and `vscode-ext/src/` for a request outside these three. Pinned by `lib/src/host/remote/service.test.ts`, `lib/src/host/managed-voice-host.test.ts`, and `standalone/src/updater.test.ts`. +- **FAIL IF** anything opens a connection on its own while the network policy is `nothing` or unread. `BurrowService` in `lib/src/host/remote/service.ts` must route every socket, request, and direct peer through its transport guard, which refuses at the call; start no `BurrowRuntime` under `nothing`; and refuse `enroll`, `enrollOffer`, `beginHostedEnrollment`, and `oneTimeOpen` before any request. `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts` must ask `networkAllowed` before every speak, and `runUpdateCheck` in `standalone/src/updater.ts` must not call `check()` unless the policy it read is not `nothing` and `autoUpdate` is on, a failed read counting as `nothing`. Search the rest of `lib/src/host/`, `standalone/src/`, and `vscode-ext/src/` for a request outside these three. Pinned by `lib/src/host/remote/service.test.ts`, `lib/src/host/managed-voice-host.test.ts`, and `standalone/src/updater.test.ts`. - **FAIL IF** the policy can be written by anything but the user's own choice. `setNetworkPolicy` is the only writer and takes a policy only exactly (`parseNetworkPolicy` in `lib/src/remote/network-policy.ts`, `requestedNetworkPolicy` in `lib/src/host/remote/service.ts`); no Client, Relay, or Hosted answer may reach the store, and a stored record that is not a policy must read as `nothing`. ## Persisted state diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index e0dd299a4..51867d78b 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -67,10 +67,11 @@ phone, or fabricate a request (rationale). The only path back out is - **FAIL IF** `DEFAULT_RELAY_ORIGIN` is not exactly `https://relay.dormouse.sh` in **both** `scripts/relay-origin.mjs` and `lib/src/host/relay-origin.ts` (`lib/src/host/relay-origin.test.ts` pins both), or `.github/workflows/release.yml` sets `DORMOUSE_RELAY_ORIGIN` — either changes what every shipped binary talks to. - **FAIL IF** `HOSTED_VOICE_ORIGIN` in `lib/src/host/relay-origin.ts` is not exactly `https://voice.dormouse.sh` or is read from anything a build or user sets, or `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts` sends the voice token anywhere but `hostedVoiceOrigin`'s answer. Pinned by `lib/src/host/relay-origin.test.ts` and `lib/src/host/managed-voice-host.test.ts`. +- **FAIL IF** a Hosted build's enrollment opens an account page other than the one `enrollVerificationUrl` in `lib/src/host/remote/service.ts` composes — `HOSTED_ACCOUNT_ORIGIN/enroll#` in a release build; in a dev Hosted build (`isDevHostedBuild`) only the origin of the Relay's `verificationUrl`, after the link checks — or `HOSTED_ACCOUNT_ORIGIN` in `lib/src/host/relay-origin.ts` is not exactly `https://hosted.dormouse.sh`, is read from anything a build or user sets, or is requested by the desktop; or the enrollment's device code reaches a webview (`HostedEnrollmentState` in `lib/src/host/remote/service-protocol.ts`). Pinned by `lib/src/host/remote/service.test.ts` and `lib/src/host/relay-origin.test.ts`. - **FAIL IF** `assertRelayOriginBaked` is no longer called on the built bundle by both `standalone/scripts/build-sidecar-proxy.mjs` and `vscode-ext/scripts/esbuild.mjs` — including the **watch** branch of the VS Code script — or `resolveRelayOrigin` stops failing the build on any case `docs/specs/relay.md` -> "Relay origin" lists (rationale). - **FAIL IF** a Burrow reaches a Relay at any origin but its `relay` option — `bakedRelay()`, passed by `lib/src/host/remote/sidecar-entry.ts` and `vscode-ext/src/burrow.ts` — taking one from a command, the offer file, or a stored enrollment, connects on an enrollment whose Relay URL or `origin` names another rather than reading it as none (`loadEnrollmentFor`), or saves one whose Relay reports another `origin` (`BurrowService` in `lib/src/host/remote/service.ts`); or if `POST /api/burrow/enroll` in `relay/src/app.ts` reads the credential or touches `burrows.json` for a request naming another origin. - **FAIL IF** a self-host build can reach `dormouse.sh` or any host under it unless the user clicks a link to it. `hostedOrigin` and `hostedVoiceOrigin` in `lib/src/host/relay-origin.ts` must answer `null` there, and every Hosted-reaching host feature must take one of them and do nothing on `null`: `BurrowService` builds no `OneTimeRuntime`, and `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts` reads no token and sends no request. In standalone, `standalone/vite.config.ts` must bake the webview through `resolveRelayOrigin`; `startUpdateCheck` in `standalone/src/updater.ts` must return before `check()` unless `bakedRelayMode()` is `'hosted'`; `managedVoicePortForBuild` in `standalone/src/managed-voice-port.ts` must give a self-host webview no port; and `standalone/scripts/tauri.mjs` must overlay a self-host `tauri build` with no updater endpoint. Search the rest of `lib/src/host/`, `standalone/`, and `vscode-ext/src/` for any other request to a `dormouse.sh` host. -- **FAIL IF** the enrollment exchange in `lib/src/remote/burrow/enrollment.ts` or the shared `burrowFetch` in `lib/src/remote/burrow/burrow-fetch.ts` — the transport behind both push delivery and the setup-token mint — drops `redirect: 'error'`. **Every new Burrow→Relay call goes through `burrowFetch`** (rationale). +- **FAIL IF** an enrollment exchange in `lib/src/remote/burrow/enrollment.ts` — the password's, or the device code's begin and poll — or the shared `burrowFetch` in `lib/src/remote/burrow/burrow-fetch.ts` — the transport behind both push delivery and the setup-token mint — drops `redirect: 'error'`. **Every new Burrow→Relay call goes through `burrowFetch`** (rationale). ### Credentials at rest @@ -237,12 +238,13 @@ 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 — `EstablishedE2eSession` in `lib/src/remote/burrow/established-session.ts`, built by `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/burrow-runtime.ts` and by `OneTimeRuntime.#promote` in `lib/src/remote/burrow/one-time-runtime.ts`, reached only from a matching confirmation; `ClientSessionCore.establish` in `lib/src/remote/client/session-core.ts`, called only from the `ok: true` branches of `PocketClient.connect` in `lib/src/remote/client/pocket-client.ts` and `OneTimeClient.connectOnce` in `lib/src/remote/client/one-time-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 and the Burrow's goodbye (`SessionEndV1`, exact keys, no payload) are `control` messages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP, a candidate, or the goodbye: a negative search over `relay/src/` and `hosted/server/` for `sdp`, the four signal names, `session-end`, and `RTCPeerConnection` must find nothing. `scripts/e2e-lint.mjs` holds it textually. -- **FAIL IF** shipped source names an ICE server but Cloudflare's STUN, or hands it to a Burrow's peer at any level but `anywhere` or to a page a self-host Relay serves; a STUN server learns the address of each end that asks it. Under `remote-lib-common/src/`, `lib/src/`, `relay/src/`, and `hosted/server/`, the only `stun:`, `stuns:`, `turn:`, or `turns:` URL is exactly `stun:stun.cloudflare.com:3478`, spelled only as `CLOUDFLARE_STUN_URL` in `lib/src/remote/direct/ice-servers.ts` and listed only by `stunServers` there, which only the two peer factories call, the native one never with a literal `true`; and `iceServers` appears only in the two peer factories: `createNativeDirectPeerFactory` in `lib/src/host/remote/native-direct-peer.ts`, and `hostedDirectPeer` (the one-time page's) and `selfHostDirectPeer` (Pocket's) in `lib/src/remote/client/browser-direct-peer.ts`. `directPeeringFor` in `lib/src/host/remote/direct-peering.ts` must bind the native factory's `stun` true only where `burrowUsesStun` holds for the policy a runtime opens or starts under, beside its path policy. `scripts/e2e-lint.mjs` holds the spelling textually; pinned by `lib/src/host/remote/service.test.ts`, `lib/src/remote/client/browser-direct-peer.test.ts`, and `lib/src/host/remote/native-direct-peer.test.ts`. -- **FAIL IF** a peer connection can outlive its session by more than the goodbye's flush. `DirectEndpoint.dispose` closes it and must run on every path that ends one — or, once `EstablishedE2eSession.end` has put the goodbye on a switched channel, `DirectEndpoint.disposeAfterFlush`, which sends and delivers nothing more and closes it once the goodbye has left or after `SESSION_END_FLUSH_MS`: in `lib/src/remote/burrow/burrow-runtime.ts` `#disposeEstablished` — which `#disposeClient` reaches from `client-gone`, socket loss and `stop()` — and the session `#promoteConnection` replaces, each through `EstablishedE2eSession.dispose`; in `lib/src/remote/burrow/one-time-runtime.ts` `#end`, which every ending of a one-time connection runs through; in `lib/src/remote/client/session-core.ts` `disposeSession`, on every ending — `establish` replacing a session, `PocketClient`'s intentional `close()` and dropped relay socket, and every ending of a `OneTimeClient` included. +- **FAIL IF** any signaling leaves the ciphertext. The four signals and the Burrow's goodbye (`SessionEndV1`, exact keys) are `control` messages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP, a candidate, or the goodbye: a negative search over `relay/src/` and `hosted/server/` for `sdp`, the four signal names, `session-end`, and `RTCPeerConnection` must find nothing. `scripts/e2e-lint.mjs` holds it textually. +- **FAIL IF** shipped source names an ICE server but Cloudflare's STUN, or hands it to a Burrow's peer at any level but `anywhere` or to a page a self-host Relay serves; a STUN server learns the address of each end that asks it. Under `remote-lib-common/src/`, `lib/src/`, `relay/src/`, and `hosted/server/`, the only `stun:`, `stuns:`, `turn:`, or `turns:` URL is exactly `stun:stun.cloudflare.com:3478`, spelled only as `CLOUDFLARE_STUN_URL` in `lib/src/remote/direct/ice-servers.ts` and listed only by `stunServers` there, which only the two peer factories call, the native one never with a literal `true`; and `iceServers` appears only in the two peer factories: `createNativeDirectPeerFactory` in `lib/src/host/remote/native-direct-peer.ts`, and `hostedDirectPeer` (the one-time page's, and Pocket's where Hosted serves it) and `selfHostDirectPeer` (Pocket's elsewhere) in `lib/src/remote/client/browser-direct-peer.ts`, Pocket choosing by `deploymentDirectPeer` in `lib/src/remote/pocket-app/deployment.ts`, which reads Hosted only from the exact `deployment.json` Hosted's staging writes. `directPeeringFor` in `lib/src/host/remote/direct-peering.ts` must bind the native factory's `stun` true only where `burrowUsesStun` holds for the policy a runtime opens or starts under, beside its path policy. `scripts/e2e-lint.mjs` holds the spelling textually; pinned by `lib/src/host/remote/service.test.ts`, `lib/src/remote/client/browser-direct-peer.test.ts`, and `lib/src/host/remote/native-direct-peer.test.ts`. +- **FAIL IF** a peer connection can outlive its session by more than the goodbye's flush. `DirectEndpoint.dispose` closes it and must run on every path that ends one — or, once `EstablishedE2eSession.end` has put the goodbye on a switched channel, `DirectEndpoint.disposeAfterFlush`, which sends and delivers nothing more and closes it once the goodbye has left or after `SESSION_END_FLUSH_MS`: in `lib/src/remote/burrow/burrow-runtime.ts` `#disposeEstablished` — which `#disposeClient` reaches from `client-gone` and socket loss — and the session `#promoteConnection` replaces, each through `EstablishedE2eSession.dispose`, and `stop()`, its goodbye unflushed to leave no timer; in `lib/src/remote/burrow/one-time-runtime.ts` `#end`, which every ending of a one-time connection runs through; in `lib/src/remote/client/session-core.ts` `disposeSession`, on every ending — `establish` replacing a session, `PocketClient`'s intentional `close()` and dropped relay socket, and every ending of a `OneTimeClient` 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 that same pair — 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** either Burrow's native peer addon is loaded at host startup 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` and `vscode-ext/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 either 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** under Local networks a paired phone's application message is read off the Relay, or its session outlives a given-up attempt or a missed `DIRECT_ONLY_DEADLINE_MS`. `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/burrow-runtime.ts` must derive `directOnly` from the path policy alone, say so in the outcome (`ConnectionOutcomeV1.directOnly`), and hand that flag to `EstablishedE2eSession` in `lib/src/remote/burrow/established-session.ts`, which owns the rule for both runtimes: no relayed application message reaches the handler; it and a given-up attempt report through `onDirectOnlyBroken`; `directDeadlineAt` stays set until both directions are direct. Each ends with the goodbye, but for a refused path. `BurrowService` in `lib/src/host/remote/service.ts` must start a `local` Burrow on `localNetworksPath` over the policy's `allowed`, and restart it on any change `samePaths` sees. `scripts/e2e-lint.mjs` holds the derivation and its hand-off textually; pinned by `lib/src/remote/burrow/burrow-direct-only.test.ts`, `lib/src/remote/burrow/established-session.test.ts`, and `lib/src/host/remote/service.test.ts`; `hosted/server/tests/relay-room.test.ts` pins only Relay interop. - **FAIL IF** a direct path survives `client-gone`, `burrow-gone`, or a lost relay socket: the Relay stays the lifecycle authority on both paths of any session it carries ([One-time connection](#one-time-connection) is the one carve-out). 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"). ### One-time connection @@ -256,12 +258,12 @@ connection" owns the ceremony. - **FAIL IF** a one-time connection grants or writes anything that outlives it. `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts` must name no ACL, ACL store, delivery id, or presence verifier, persist nothing, and send a success outcome carrying the Burrow label alone. `scripts/e2e-lint.mjs` holds the naming textually. - **FAIL IF** a link can admit a second phone or a second guess. The first message 1 that completes IK against the link's key must reserve the link and erase that key; every later `init` is dropped before any WebCrypto, and one that fails leaves the link open. `OneTimeRuntime.#approve` must set `attempted` before its expiry check and `constantTimeEqual`, and every outcome — success and each denial — is one padded control message sealed with `sealControl` in `lib/src/remote/burrow/established-session.ts`. - **FAIL IF** the one-time approval modal can show text the phone chose, which could tell the person which digits to type. `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts` must pass the request's label through `knownOneTimeDeviceLabel` in `remote-lib-common/src/security/e2e-ceremony.ts` before `requestApproval` or any `OneTimeState` carries it, so a label that is not exactly a member of `ONE_TIME_DEVICE_LABELS` reaches the modal, the panel, and the Baseboard as `Phone browser`; the page's `oneTimeDeviceLabel` in `lib/src/remote/one-time-app/OneTimeApp.tsx` returns only members. Pinned by `lib/src/remote/burrow/one-time-runtime.test.ts` and `remote-lib-common/test/e2e-ceremony.test.mjs`. -- **FAIL IF** an application message crosses the rendezvous, or a one-time session outlives a missed direct deadline. After the outcome an application message decrypted off the rendezvous must end the session unread (`onRelayedApp` in `lib/src/remote/burrow/established-session.ts`), and a decline, an abandoned attempt, or no switch by `ONE_TIME_DIRECT_DEADLINE_MS` must end it — there is no relayed fallback. After the switch the direct channel is the lifecycle authority: the runtime closes the rendezvous, and channel loss or `ESTABLISHED_E2E_IDLE_TIMEOUT_MS` idle ends the session. -- **FAIL IF** the phone can reach the room unasked or put protocol-v1 on it. `OneTimeClient` in `lib/src/remote/client/one-time-client.ts` must open its socket only inside `connectOnce`, refuse every protocol-v1 method until both directions are direct, and close the rendezvous normally at the switch; a decline, an abandoned attempt, or no switch by `ONE_TIME_DIRECT_DEADLINE_MS` fails the attempt. It reads every frame through `parseOneTimeFrame` in `lib/src/remote/one-time-rendezvous.ts`, which measures it against `MAX_ONE_TIME_FRAME_LENGTH` before `JSON.parse`, and runs `isOneTimeBurrowFrame` on it. Pinned by `lib/src/remote/client/one-time-client.test.ts` and `lib/src/remote/client/one-time-e2e.test.ts`. +- **FAIL IF** an application message crosses the rendezvous, or a one-time session outlives a missed direct deadline. `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts` must make its one session `directOnly` on `EstablishedE2eSession`: after the outcome an application message decrypted off the rendezvous ends the session unread, and a decline, an abandoned attempt, or no switch by `DIRECT_ONLY_DEADLINE_MS` ends it — no relayed fallback. `scripts/e2e-lint.mjs` holds the flag textually. After the switch the direct channel is the lifecycle authority: the runtime closes the rendezvous, and channel loss or `ESTABLISHED_E2E_IDLE_TIMEOUT_MS` idle ends the session. +- **FAIL IF** the phone can reach the room unasked or put protocol-v1 on it. `OneTimeClient` in `lib/src/remote/client/one-time-client.ts` must open its socket only inside `connectOnce`, refuse every protocol-v1 method until both directions are direct, and close the rendezvous normally at the switch; a decline, an abandoned attempt, or no switch by `DIRECT_ONLY_DEADLINE_MS` fails the attempt. It reads every frame through `parseOneTimeFrame` in `lib/src/remote/one-time-rendezvous.ts`, which measures it against `MAX_ONE_TIME_FRAME_LENGTH` before `JSON.parse`, and runs `isOneTimeBurrowFrame` on it. Pinned by `lib/src/remote/client/one-time-client.test.ts` and `lib/src/remote/client/one-time-e2e.test.ts`. - **FAIL IF** a one-time phone keeps anything past its session. `OneTimeClient` must mint its static with `generateNoiseKeyPair`, nonextractable, for the one handshake, and it, `ClientSessionCore` in `lib/src/remote/client/session-core.ts`, and every module of the page in `lib/src/remote/one-time-app/` may name no browser store or service worker, nor import Pocket's records, key wrapping, passkeys, push, or `PocketClient`; the page's theme goes through `applyPocketTheme`, which writes nothing. `scripts/e2e-lint.mjs` holds the naming textually. - **FAIL IF** the rendezvous origin is anything but the Burrow's `hostedOrigin` (Relay origin, above), comes from a command (`oneTimeOpen` takes no parameters), or is used by a socket opened before it is checked. `BurrowService` in `lib/src/host/remote/service.ts` holds at most one runtime, ending the one a new link replaces, and refuses a new link while a phone is connecting or connected; the socket carries no `Origin` header. Pinned by `lib/src/host/remote/service.test.ts`. - **FAIL IF** under Local networks a one-time session's channel can report open, or stay open past a state change or `DIRECT_PATH_RECHECK_MS`, on a selected pair whose two ends are not both IP literals inside the allowed networks; the Burrow's answer carries, or the offer it applies keeps, a candidate outside them; or the attempt's socket is not bound to the one allowed address where exactly one is present. `BurrowService` in `lib/src/host/remote/service.ts` must hand a `local` runtime `localNetworksPath` over the policy's `allowed`, which `DirectEndpoint` in `lib/src/remote/direct/direct-endpoint.ts` hands to the peer factory, and `createNativeDirectPeerFactory` in `lib/src/host/remote/native-direct-peer.ts` must bind the address its `bindAddress` names; `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must apply only the offer the policy accepts, and consult the policy before `onOpen`, on each state change and every `DIRECT_PATH_RECHECK_MS` while open, `connected`, and reporting a pair, and before an unopened channel's frame, ending the session on refusal; `localNetworksPath` in `lib/src/host/remote/local-networks.ts` reads only the Burrow's own selected pair, refusing a name or no pair. Pinned by `lib/src/host/remote/local-networks.test.ts`, `lib/src/remote/direct/direct-peer.test.ts`, `lib/src/remote/burrow/one-time-runtime.test.ts`, and `lib/src/host/remote/native-direct-peer.test.ts`. -- **FAIL IF** the one-time runtime relies on the rendezvous for any bound. It must read every message through `parseOneTimeFrame`, which measures it against `MAX_ONE_TIME_FRAME_LENGTH` before `JSON.parse`, shape-guard every frame, stop reading a room past `MAX_ONE_TIME_FORWARDED` messages, gate each `init`'s WebCrypto on its own `TokenBucket` of `E2E_INIT_BURST`, and end on its own clock — a link not yet promoted at its expiry, claimed or not, and a promoted one by `ONE_TIME_DIRECT_DEADLINE_MS` — never after the room would. Pinned by `lib/src/remote/burrow/one-time-runtime.test.ts`. +- **FAIL IF** the one-time runtime relies on the rendezvous for any bound. It must read every message through `parseOneTimeFrame`, which measures it against `MAX_ONE_TIME_FRAME_LENGTH` before `JSON.parse`, shape-guard every frame, stop reading a room past `MAX_ONE_TIME_FORWARDED` messages, gate each `init`'s WebCrypto on its own `TokenBucket` of `E2E_INIT_BURST`, and end on its own clock — a link not yet promoted at its expiry, claimed or not, and a promoted one by `DIRECT_ONLY_DEADLINE_MS` — never after the room would. Pinned by `lib/src/remote/burrow/one-time-runtime.test.ts`. ### Revocation and the audit trail @@ -301,4 +303,4 @@ rather than inherited: - **We become the operator** of the relay. The end-to-end protocol keeps ceremony, terminal, remote-api, and notification content out of that operator's reach; what stays visible is exactly the metadata in `docs/specs/remote-security-model.md` -> "Residual metadata". - **An independent cryptographic review is a precondition** of claiming this model for a paid service (`docs/specs/remote-security-model.md` -> "Security Guarantees"). -- **The tailnet stops carrying load.** Every argument above that leans on "the origin is reachable only from the user's tailnet" has no cloud equivalent, and the multi-tenant account model replaces the single-owner setup password entirely (`docs/specs/relay.md` -> "Future", the **saas-multitenant** scope). +- **The tailnet stops carrying load.** Every argument above that leans on "the origin is reachable only from the user's tailnet" has no cloud equivalent, and the multi-tenant account model replaces the single-owner setup password entirely (`docs/specs/hosted.md` -> "Burrow enrollment"). diff --git a/docs/specs/security.md b/docs/specs/security.md index 5d95d60bd..8ae66e8d1 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -92,7 +92,7 @@ run this knows what they are taking on. after which the Relay sees that the session exists and nothing about its traffic ([Direct path](./remote-security-model.md#direct-path)). Hosted's one-time rendezvous sees a handshake's timing, addresses, and frame sizes, - and Cloudflare's STUN server sees the one-time phone's public address, and + and Cloudflare's STUN server sees every Hosted-served phone's public address, and under Anywhere this computer's ([Direct path](./security-remote.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)). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 51c14bd30..2bcf007f8 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -252,9 +252,9 @@ Source of truth: `VsCodeBurrowStateStore` and `ONE_TIME_SERVING_KEY` in `vscode- **Domain-separate the two proofs (`client:` / `server:`)** — without it a fake server could reflect the client's own proof back as its welcome. **The client verifies the welcome before it sends or answers anything else** (until then it forwards no notifies — they queue — answers no requests, streams no PTY, forwards no commands), and a welcome it cannot verify closes the socket, so squatting the path buys nothing (rationale). **Fresh nonces per connection** make a captured proof worthless on the next one. **Parseable JSON values that are not frame objects are rejected on both ends**, a first frame that is not a valid hello drops the socket, and **each side bounds the opening handshake to `HANDSHAKE_BUDGET_MS`**. -**Nothing starts until there is a Burrow to run.** Contention begins when activation finds an enrollment for the baked origin or the serving marker in `SecretStorage`, when `secrets.onDidChange` reports that another window wrote one, or on the first `enroll` / `enrollOffer` / `oneTimeOpen` / `setNetworkPolicy` command from any webview — the bootstrap for an un-enrolled machine, so a user who never enrolls, opens a link, or changes the network policy never sees a socket. **The service runs independently of webview lifetime**: a broker window with zero Dormouse webviews still relays, contributing an empty directory. +**Nothing starts until there is a Burrow to run.** Contention begins when activation finds an enrollment for the baked origin or the serving marker in `SecretStorage`, when `secrets.onDidChange` reports that another window wrote one, or on the first `enroll` / `enrollOffer` / `beginHostedEnrollment` / `oneTimeOpen` / `setNetworkPolicy` command from any webview — the bootstrap for an un-enrolled machine, so a user who never enrolls, opens a link, or changes the network policy never sees a socket. **The service runs independently of webview lifetime**: a broker window with zero Dormouse webviews still relays, contributing an empty directory. -**A command that arrives mid-contention is held, not refused** (rationale). Commands queue (bounded at a dozen, oldest refused on overflow) and drain when a role settles — to the service if this window brokered, over the link if it did not. **Each carries its own deadline, under the adapter's 15 s timeout**, so a contention that never settles produces a reason rather than a timeout. `enroll`, `enrollOffer`, `oneTimeOpen`, and `setNetworkPolicy` are the only commands that may *start* the contention — the last **so the service stays the policy's only writer**; everything else refuses only where there is genuinely nothing to reach. +**A command that arrives mid-contention is held, not refused** (rationale). Commands queue (bounded at a dozen, oldest refused on overflow) and drain when a role settles — to the service if this window brokered, over the link if it did not. **Each carries its own deadline, under the adapter's 15 s timeout**, so a contention that never settles produces a reason rather than a timeout. `enroll`, `enrollOffer`, `beginHostedEnrollment`, `oneTimeOpen`, and `setNetworkPolicy` are the only commands that may *start* the contention — the last **so the service stays the policy's only writer**; everything else refuses only where there is genuinely nothing to reach. Source of truth: `vscode-ext/src/burrow.ts` (service glue, provider, command routing), `ensurePeerNet` / `attempt` / `stillOurs` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/burrow.test.ts` and `vscode-ext/test/peer-link.test.ts`. @@ -335,7 +335,7 @@ Once an answer names a `ptyId`, the broker replaces that owner-local id with a s **Pairing UI events are the opposite: unaddressed and broadcast to every window's webviews**, because the approval modal must appear wherever the user happens to be looking. -**A window with no Burrow at all still answers the read-only commands** — reaching the terminal refusal is the ordinary un-enrolled state, not a failure. `status`, `pushDevices`, `pairingQueue`, `oneTimeStatus`, `networkPolicy`, `oneTimeEnd`, and `takeBack` answer exactly what an idle service returns (`unenrolledStatus`, `null`, `[]`, `idleOneTimeState`, `networkPolicyResult`, `{}`, `{ ended: false }` — builders shared with the service, the policy read by its `peekNetworkPolicyFor` and never saved), each caller reading the difference: `pushDevices` answers `null` for "nowhere to push" and rejects only when the Relay could not be asked (rationale), and `enrolled-gate.ts` seeds itself from `status`. That `status` reads the installer's offer file from this same process — a file read, not a socket — so the one-click card renders on a machine no window has a Burrow for, and its `enrollOffer` bootstraps the contention. **Everything else refuses with an error rather than dropping it**, so the console hook fails fast instead of hanging for its whole timeout. +**A window with no Burrow at all still answers the read-only commands** — reaching the terminal refusal is the ordinary un-enrolled state, not a failure. `status`, `pushDevices`, `pairingQueue`, `oneTimeStatus`, `networkPolicy`, `oneTimeEnd`, `cancelHostedEnrollment`, and `takeBack` answer exactly what an idle service returns (`unenrolledStatus`, `null`, `[]`, `idleOneTimeState`, `networkPolicyResult`, `{}`, `{}`, `{ ended: false }` — builders shared with the service, the policy read by its `peekNetworkPolicyFor` and never saved), each caller reading the difference: `pushDevices` answers `null` for "nowhere to push" and rejects only when the Relay could not be asked (rationale), and `enrolled-gate.ts` seeds itself from `status`. That `status` reads the installer's offer file from this same process — a file read, not a socket — so the one-click card renders on a machine no window has a Burrow for, and its `enrollOffer` bootstraps the contention. **Everything else refuses with an error rather than dropping it**, so the console hook fails fast instead of hanging for its whole timeout. Two UI events *are* addressed: **when a window completes the handshake the broker sends it the current `status` and `one-time` events** — each is emitted only when it changes and once as the service starts, so a window opened after the enrollment or the link would otherwise sit disarmed until reloaded. diff --git a/docs/stories/pairing.mdx b/docs/stories/pairing.mdx index f11bf1447..05e578636 100644 --- a/docs/stories/pairing.mdx +++ b/docs/stories/pairing.mdx @@ -136,8 +136,8 @@ DORMOUSE_RELAY_ORIGIN=https://..ts.net pnpm dogfood:vscode That makes it a self-host build, which contacts nothing of Dormouse's on its own: no one-time links, no managed voice, no update checks. Skipping this has a specific, recognizable symptom, and you will see it in the next section rather -than as a mysterious failure: a stock build's **Persistent Relay** offers only a -disabled "Use hosted.dormouse.sh", with nothing to enroll. +than as a mysterious failure: a stock build's **Persistent Relay** offers only +"Enroll with hosted.dormouse.sh", and no setup password. --- @@ -473,7 +473,7 @@ lands hours late, or on another paired phone, cannot masquerade as a real alarm. (The spoken alarm has a sibling **Play test sound**; both live in `docs/specs/alert.md` → Alarm settings.) -**Displaced** is the one connection state that needs a person. Another Dormouse +**Displaced** is one of three connection states that need a person. Another Dormouse instance enrolled with the same `burrowId` took the relay slot, and this one stood down — terminally, on purpose, because two instances fighting over a slot is worse than one stopping. Reconnect takes it back, and displaces the other in @@ -481,6 +481,15 @@ turn. +**Removed** is the second: the computer was removed from the account (or from +`burrows.json`), so the Relay closed its socket and this machine stood down +rather than retrying a token nothing will accept. On Hosted, Enroll again begins +a fresh code; a self-host machine is disconnected and enrolled again from the +form. The third, **not entitled**, is a Hosted plan that no longer includes +remote control, and offers Reconnect. + + + **Disconnect** asks first, because forgetting the enrollment drops every paired phone until each pairs again. diff --git a/hosted/scripts/stage-relay.mjs b/hosted/scripts/stage-relay.mjs index 5ed9559d9..7ccf3a4b9 100644 --- a/hosted/scripts/stage-relay.mjs +++ b/hosted/scripts/stage-relay.mjs @@ -1,4 +1,4 @@ -import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -6,13 +6,19 @@ import { assertPocketWorker, ONE_TIME_SHELL, } from "../../lib/scripts/assert-pocket-worker.mjs"; +import { + HOSTED_POCKET_DEPLOYMENT, + POCKET_DEPLOYMENT_FILE, +} from "../../remote-lib-common/src/remote/pocket-deployment.ts"; /** * Stage the relay Worker's assets: Pocket from `lib`'s `build:pocket` at the * root (`docs/specs/pocket-app.md` -> "Deployment: same-origin, always"), and * the one-time phone page from `build:one-time` at its page path - * (`docs/specs/one-time.md` -> "Phone page"). The directory is emptied first - * so the two builds are all it holds, and each copy's shell is checked against + * (`docs/specs/one-time.md` -> "Phone page"), and beside Pocket the + * `POCKET_DEPLOYMENT_FILE` that tells it Hosted serves it + * (`docs/specs/remote-network.md` -> "Anywhere"). The directory is emptied + * first so these are all it holds, and each copy's shell is checked against * its own policy — what the relay serves, not only what `lib` built. Returns * how many scripts each shell loads. */ @@ -28,12 +34,16 @@ export function stageRelay({ pocket, oneTime }, assets) { const pagePath = join(assets, ONE_TIME_SHELL.base); if (existsSync(join(pocket, ONE_TIME_SHELL.base))) throw new Error(`the Pocket build has a ${ONE_TIME_SHELL.base} the one-time page owns`); + // A self-host Relay serves that same build, which must read as self-host. + if (existsSync(join(pocket, POCKET_DEPLOYMENT_FILE))) + throw new Error(`the Pocket build has a ${POCKET_DEPLOYMENT_FILE} only Hosted's staging writes`); rmSync(assets, { recursive: true, force: true }); mkdirSync(assets, { recursive: true }); cpSync(pocket, assets, { recursive: true }); assertPocketWorker(assets); const pocketScripts = assertPocketShell(assets); if (pocketScripts === 0) throw new Error("the staged Pocket shell has no script"); + writeFileSync(join(assets, POCKET_DEPLOYMENT_FILE), `${JSON.stringify(HOSTED_POCKET_DEPLOYMENT)}\n`); cpSync(oneTime, pagePath, { recursive: true }); const oneTimeScripts = assertPocketShell(pagePath, ONE_TIME_SHELL); if (oneTimeScripts === 0) throw new Error("the staged one-time shell has no script"); diff --git a/hosted/scripts/stage-relay.test.mjs b/hosted/scripts/stage-relay.test.mjs index 3103aa663..692c7dd86 100644 --- a/hosted/scripts/stage-relay.test.mjs +++ b/hosted/scripts/stage-relay.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { stageRelay } from "./stage-relay.mjs"; @@ -45,11 +45,22 @@ test("Pocket lands at the root and the one-time page at its path, and they are a assert.deepEqual(readdirSync(assets).sort(), [ "assets", "connect", + "deployment.json", "diagnostics", "index.html", "manifest.webmanifest", "sw.js", ]); + // What tells the one Pocket bundle that Hosted serves it. + assert.deepEqual(JSON.parse(readFileSync(join(assets, "deployment.json"), "utf8")), { + deployment: "hosted", + }); +}); + +test("a Pocket build carrying the deployment file is refused, since a self-host Relay serves it as it is", () => { + const { pocket, oneTime, assets } = fixture(); + writeFileSync(join(pocket, "deployment.json"), '{"deployment":"hosted"}'); + assert.throws(() => stageRelay({ pocket, oneTime }, assets), /only Hosted's staging writes/); }); test("a shell its policy would refuse is not staged quietly", () => { diff --git a/hosted/server/dormouse-migrations/004_relay_enrollment_redeemed.sql b/hosted/server/dormouse-migrations/004_relay_enrollment_redeemed.sql new file mode 100644 index 000000000..6be7da763 --- /dev/null +++ b/hosted/server/dormouse-migrations/004_relay_enrollment_redeemed.sql @@ -0,0 +1,18 @@ +-- Up Migration +-- A redeemed device-code approval (docs/specs/hosted.md -> "Burrow enrollment"): +-- the first poll whose device code derives the user code enrolls a Burrow owned +-- by "userId" and marks the row redeemed, which it stays until it expires, so a +-- poll whose answer was lost learns it was spent. "redeemedBurrowId" names no +-- foreign key: removing that Burrow must never make the approval redeemable +-- again. +ALTER TABLE dormouse_relay_enrollment_approvals + ADD COLUMN "redeemedBurrowId" text, + ADD COLUMN "redeemedAt" timestamptz, + ADD CONSTRAINT dormouse_relay_enrollment_approvals_redeemed + CHECK (("redeemedBurrowId" IS NULL) = ("redeemedAt" IS NULL)); + +-- Down Migration +ALTER TABLE dormouse_relay_enrollment_approvals + DROP CONSTRAINT dormouse_relay_enrollment_approvals_redeemed, + DROP COLUMN "redeemedAt", + DROP COLUMN "redeemedBurrowId"; diff --git a/hosted/server/relay-account.ts b/hosted/server/relay-account.ts index a5ce9a61e..a9edb7dca 100644 --- a/hosted/server/relay-account.ts +++ b/hosted/server/relay-account.ts @@ -56,8 +56,9 @@ export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAc if (userCode === null) return c.json({ message: "That is not a code from Dormouse." }, 400); // Whether the code was ever issued is unknowable here: begin stores - // nothing. An approval no Burrow redeems expires. A live approval never - // moves to another account; an expired one is replaced. + // nothing. An approval expires, redeemed or not. A live approval never + // moves to another account, nor is one approved again once redeemed; an + // expired one is replaced, unredeemed. const approved = ( await accountQuery( @@ -65,7 +66,8 @@ export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAc `INSERT INTO dormouse_relay_enrollment_approvals AS a ("userCode", "userId", "expiresAt") VALUES ($1, $2, now() + ($3::float8 * interval '1 millisecond')) ON CONFLICT ("userCode") DO UPDATE - SET "userId" = EXCLUDED."userId", "expiresAt" = EXCLUDED."expiresAt" + SET "userId" = EXCLUDED."userId", "expiresAt" = EXCLUDED."expiresAt", + "redeemedBurrowId" = NULL, "redeemedAt" = NULL WHERE a."expiresAt" <= now() RETURNING 1`, [userCode, login.userId, ENROLLMENT_TTL_MS], diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index 6ca0c22b8..3db77c690 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -489,13 +489,18 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { return database(c, async (db) => { const { rows: [approval], - } = await db.query( - `SELECT a."userId", ${OWNER_COLUMNS} + } = await db.query( + `SELECT a."userId", a."redeemedBurrowId", ${OWNER_COLUMNS} FROM dormouse_relay_enrollment_approvals a JOIN "user" u ON u.id = a."userId" WHERE a."userCode" = $1 AND a."expiresAt" > now()`, [userCode], ); if (!approval) return answer({ status: "pending" }); + // Spent by an earlier poll whose answer this Burrow never read: the + // Burrow it minted is the account's to remove, and nothing redeems twice. + if (approval.redeemedBurrowId !== null) { + return answer({ status: "redeemed", burrowId: approval.redeemedBurrowId }); + } const owner = ownerOf(approval); // Rechecked here: an approval does not outlive its approver's entitlement. if (!owner.entitled) return c.json({ error: NOT_ENTITLED_ERROR }, 403); @@ -510,19 +515,32 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { ); // A full account keeps the approval, so a poll after a removal enrolls. if (enrolled >= MAX_ENROLLED_BURROWS) return "full"; - // Single-use: the approval is spent in the statement that enrolls its - // Burrow, owned by exactly its approver, so two polls cannot mint two. + // Single-use: the approval is marked redeemed in the statement that + // enrolls its Burrow, owned by exactly its approver, so two polls + // cannot mint two; the marked row stays until it expires. const { rowCount } = await db.query( `WITH spent AS ( - DELETE FROM dormouse_relay_enrollment_approvals + UPDATE dormouse_relay_enrollment_approvals + SET "redeemedBurrowId" = $3, "redeemedAt" = now() WHERE "userCode" = $1 AND "userId" = $2 AND "expiresAt" > now() + AND "redeemedAt" IS NULL RETURNING "userId" ) INSERT INTO dormouse_relay_burrows ("burrowId", "userId", "tokenHash") SELECT $3, "userId", $4 FROM spent`, [userCode, owner.userId, burrowId, digest(burrowToken)], ); - return rowCount ? "enrolled" : "spent"; + if (rowCount) return "enrolled"; + // Lost to a poll that redeemed it while this one waited on the lock, + // or expired since the read above. + const { + rows: [spent], + } = await db.query<{ redeemedBurrowId: string }>( + `SELECT "redeemedBurrowId" FROM dormouse_relay_enrollment_approvals + WHERE "userCode" = $1 AND "expiresAt" > now() AND "redeemedBurrowId" IS NOT NULL`, + [userCode], + ); + return spent ? { redeemed: spent.redeemedBurrowId } : "expired"; }); if (outcome === "full") { const where = c.env.ACCOUNT_ORIGIN; @@ -535,7 +553,8 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { 409, ); } - if (outcome === "spent") return answer({ status: "expired" }); + if (outcome === "expired") return answer({ status: "expired" }); + if (outcome !== "enrolled") return answer({ status: "redeemed", burrowId: outcome.redeemed }); return answer({ status: "enrolled", // The Burrow enforces `origin`/`rpId` as its ConnectionPolicy; Hosted diff --git a/hosted/server/relay-room.ts b/hosted/server/relay-room.ts index ff86bff3b..ddf44b1be 100644 --- a/hosted/server/relay-room.ts +++ b/hosted/server/relay-room.ts @@ -11,6 +11,8 @@ import { RELAY_PING, RELAY_PONG, UNKNOWN_BURROW_TOKEN_ERROR, + WS_CLOSE_BURROW_NOT_ENTITLED, + WS_CLOSE_BURROW_NOT_ENTITLED_REASON, WS_CLOSE_BURROW_REPLACED, WS_CLOSE_BURROW_REPLACED_REASON, WS_CLOSE_BURROW_REVOKED, @@ -272,10 +274,10 @@ export class RelayRoom extends DurableObject implements RelayRoomRpc { } /** - * The backstop to `closeBurrow`: every held Burrow whose row is gone, is - * another account's, or whose owner is no longer entitled closes 4001, its - * Clients told `burrow-gone`. One query for them all; a failed or timed-out - * read leaves them to the next sweep. + * The backstop to `closeBurrow`: every held Burrow whose row is gone or is + * another account's closes 4001, and one whose owner is no longer entitled + * 4002, its Clients told `burrow-gone`. One query for them all; a failed or + * timed-out read leaves them to the next sweep. */ async #sweep() { const account = await this.ctx.storage.get(ACCOUNT_KEY); @@ -287,13 +289,19 @@ export class RelayRoom extends DurableObject implements RelayRoomRpc { console.error("RelayRoom could not sweep its Burrows"); return; } - const standing = new Set( - rows.filter((row) => row.userId === account && row.entitled).map((row) => row.burrowId), + const owned = new Map( + rows.filter((row) => row.userId === account).map((row) => [row.burrowId, row.entitled]), ); for (const burrowId of burrowIds) { - if (standing.has(burrowId)) continue; + const entitled = owned.get(burrowId); + if (entitled) continue; const held = this.#burrow(burrowId); - if (held) this.#retire(held, WS_CLOSE_BURROW_REVOKED, WS_CLOSE_BURROW_REVOKED_REASON); + if (!held) continue; + if (entitled === false) { + this.#retire(held, WS_CLOSE_BURROW_NOT_ENTITLED, WS_CLOSE_BURROW_NOT_ENTITLED_REASON); + } else { + this.#retire(held, WS_CLOSE_BURROW_REVOKED, WS_CLOSE_BURROW_REVOKED_REASON); + } } } diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts new file mode 100644 index 000000000..85bd52ff9 --- /dev/null +++ b/hosted/server/tests/migrations.test.ts @@ -0,0 +1,29 @@ +import { test, expect } from "vitest"; +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; + +/** + * Every Dormouse migration, by the SHA-256 of its LF-normalized text + * (`docs/specs/hosted.md`: never edit a merged migration). A database that ran + * one never runs it again, so an edit reaches only fresh databases; a change + * appends the next numbered file and pins it here. + */ +const PINNED: Record = { + "001_voice_tokens.sql": "8dddec53bca7244411f3e8a007ccea665ede176c9c0bead6a0987af41c366cae", + "002_relay.sql": "54651083ba1aac934ac0dcb5f62cc4f032d017f2c3be2161c0812ebc76f338c3", + "003_relay_push.sql": "ee8cca19bb70eb89fcba708b54d8a188347caf0f32ec23551c56d27676ab094f", + "004_relay_enrollment_redeemed.sql": + "dd36852ac3efbdc7f0dc2b9ee449f8f213c3c63982c4ab176a27e4a19e08a74d", +}; + +const directory = new URL("../dormouse-migrations/", import.meta.url); + +test("no merged Dormouse migration changes, and every one is pinned", () => { + const files = readdirSync(directory).filter((name) => name.endsWith(".sql")).sort(); + expect(files).toEqual(Object.keys(PINNED).sort()); + for (const name of files) { + const text = readFileSync(new URL(name, directory), "utf8").replace(/\r\n/g, "\n"); + const digest = createHash("sha256").update(text).digest("hex"); + expect(digest, `${name} was edited; append a new migration instead`).toBe(PINNED[name]); + } +}); diff --git a/hosted/server/tests/pocket.test.ts b/hosted/server/tests/pocket.test.ts index 02aa93006..6de9bd79b 100644 --- a/hosted/server/tests/pocket.test.ts +++ b/hosted/server/tests/pocket.test.ts @@ -6,6 +6,7 @@ import { WS_ROUTES, fromBase64Url, isEnrollUserCode, + isBurrowEnrollBeginResponse, isRelayBearer, pocketContentSecurityPolicy, toBase64Url, @@ -189,7 +190,10 @@ test("only a Node Burrow begins or polls an enrollment, per-address limited, and // a code whose expiry it carries; an expired code is answered the same way. const begun = await post(API_ROUTES.burrowEnrollBegin, { origin }, { "cf-connecting-ip": "192.0.2.4" }); expect(begun.status).toBe(200); - const { deviceCode, userCode } = (await begun.json()) as { deviceCode: string; userCode: string }; + const answer = await begun.json(); + // What the desktop's Burrow holds an answer to before it polls. + expect(isBurrowEnrollBeginResponse(answer)).toBe(true); + const { deviceCode, userCode } = answer as { deviceCode: string; userCode: string }; expect(isRelayBearer(deviceCode) && isEnrollUserCode(userCode)).toBe(true); const expired = fromBase64Url(deviceCode); expired.set([0, 0, 0, 1]); diff --git a/hosted/server/tests/relay-room.test.ts b/hosted/server/tests/relay-room.test.ts index bbd753ea8..a860533bc 100644 --- a/hosted/server/tests/relay-room.test.ts +++ b/hosted/server/tests/relay-room.test.ts @@ -14,6 +14,7 @@ import { RELAY_PONG, UNAUTHORIZED_ERROR, UNKNOWN_BURROW_TOKEN_ERROR, + WS_CLOSE_BURROW_NOT_ENTITLED, WS_CLOSE_BURROW_REPLACED, WS_CLOSE_BURROW_REVOKED, WS_CLOSE_IDLE, @@ -22,7 +23,10 @@ import { WS_CLOSE_UNAUTHORIZED_REASON, WS_ROUTES, WS_TOKEN_PARAM, + REMOTE_METHODS, + SESSION_END_V1, generateNoiseKeyPair, + utf8Encode, } from "remote-lib-common"; import { SimAuthenticator, @@ -480,7 +484,7 @@ test("the alarm is the earliest Client expiry or Burrow sweep; a close leaves it expect((await room.probe()).alarm).toBe(null); }); -test("the sweep closes a Burrow removed or de-entitled behind its socket's back, and keeps the rest", async ({ +test("the sweep closes a Burrow removed (4001) or de-entitled (4002) behind its socket's back, and keeps the rest", async ({ onTestFinished, }) => { onTestFinished(closeAll); @@ -500,10 +504,11 @@ test("the sweep closes a Burrow removed or de-entitled behind its socket's back, expect(await room.onlineBurrows(owner.userId)).toEqual([kept.burrowId]); expect(await kept.socket.quiet(150)).toBe(true); - // The owner loses the entitlement: every Burrow socket it holds goes. + // The owner loses the entitlement: every Burrow socket it holds goes, on + // the code that says why. await entitled(); await room.fire(); - expect((await kept.socket.closed).code).toBe(WS_CLOSE_BURROW_REVOKED); + expect((await kept.socket.closed).code).toBe(WS_CLOSE_BURROW_NOT_ENTITLED); expect(await room.onlineBurrows(owner.userId)).toEqual([]); }); @@ -716,3 +721,88 @@ test("one object per account: another account's session never reaches this accou expect((await roomA.probe()).storage).toEqual({ account: a.userId }); expect(await roomA.onlineBurrows(a.userId)).toEqual([burrowId]); }); + +test("a Burrow enrolled by device code pairs and connects a phone, and under Local networks ends the session its first request relays", async ({ + onTestFinished, +}) => { + const peers: { close(): void }[] = []; + onTestFinished(() => { + for (const peer of peers) peer.close(); + }); + const userId = await entitled(); + + // The Burrow asks the relay for a code, as `beginHostedEnrollment` does … + const begun = await call(API_ROUTES.burrowEnrollBegin, { body: { origin } }); + expect(begun.status).toBe(200); + // … its account approves it on the account origin, as the enroll page posts … + const account = new Hono(); + relayAccountRoutes(account, () => ({ + databaseUrl: context.database.url, + auth: async () => + Response.json({ + user: { id: userId, email: ADMIN_EMAIL, emailVerified: true }, + session: { createdAt: new Date().toISOString() }, + }), + approveLimit: { limit: async () => ({ success: true }) } as unknown as RateLimit, + closeBurrow: async () => true, + })); + const approved = await account.request(`${ORIGINS.account}/api/relay/enrollments/approve`, { + method: "POST", + headers: { origin: ORIGINS.account, "content-type": "application/json" }, + body: JSON.stringify({ userCode: begun.json.userCode }), + }); + expect(approved.status).toBe(204); + // … and its poll answers the enrollment, once. + const polled = await call(API_ROUTES.burrowEnrollPoll, { body: { deviceCode: begun.json.deviceCode } }); + expect(polled.json.status).toBe("enrolled"); + const { burrowId, burrowToken } = polled.json.enrollment as { burrowId: string; burrowToken: string }; + + // The Burrow under Local networks (`BurrowRuntime` with the path policy + // held), on the relay socket that token opens. + const burrowStatic = await generateNoiseKeyPair(); + const burrow = harness(FakeBurrow, { + relayUrl: wsBase, + burrowToken, + burrowId, + origin, + rpId, + noiseStaticKeyPair: burrowStatic, + directOnly: true, + }); + peers.push(burrow); + await burrow.ready; + + // A phone whose passkey joined the account off this Burrow's setup code, + // paired and connected through the account's object. + const { authenticator, sessionToken } = await signedIn(burrowToken); + const client = harness(FakeClient, { + relayUrl: base.href.replace(/\/$/, ""), + sessionToken, + burrowId, + staticKeyPair: await generateNoiseKeyPair(), + burrowStaticPublicKey: burrowStatic.publicKey, + origin, + rpId, + socketInit: POCKET, + }); + peers.push(client); + await client.ready; + const paired = await client.pair({ invitation: await burrow.mintInvitation(), authenticator, accountId: userId }); + expect(paired.ok).toBe(true); + const connected = await client.connect({ authenticator }); + expect(connected.outcome).toEqual({ ok: true, burrowLabel: burrow.label, directOnly: true }); + + // A keepalive is no application message; the first request is, and the + // Burrow ends the session unread, saying goodbye through the relay. + const answered: unknown[] = []; + burrow.on("msg", (event: unknown) => answered.push(event)); + const relayed = new Promise((resolve) => burrow.once("relayed-app", resolve)); + client.sendKeepalive(); + client.sendApp( + utf8Encode(JSON.stringify({ requestId: "r1", method: REMOTE_METHODS.hello, params: { protocolVersion: 1, viewer: "phone" } })), + ); + await relayed; + const goodbye = client.receiveFrame(await client.nextTransport()); + expect(goodbye).toEqual({ kind: "control", value: SESSION_END_V1 }); + expect(answered).toEqual([]); +}); diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index b789f7500..3f24d1f47 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -879,10 +879,23 @@ test("a device-code enrollment: begin stores nothing, pending until approved, th expect(await f.sql(`SELECT "burrowId", "userId", "tokenHash" FROM dormouse_relay_burrows`)).toEqual([ { burrowId: enrollment.burrowId, userId: owner, tokenHash: digest(enrollment.burrowToken) }, ]); - // Spent: the code has nothing left to redeem, and the Burrow's token acts. + // Spent: the approval stays, marked with the Burrow it minted, so a poll + // whose answer was lost learns it was redeemed; nothing redeems twice. + expect( + await f.sql(`SELECT "userId", "redeemedBurrowId", "redeemedAt" IS NOT NULL AS redeemed FROM dormouse_relay_enrollment_approvals`), + ).toEqual([{ userId: owner, redeemedBurrowId: enrollment.burrowId, redeemed: true }]); + // Naming the Burrow it minted, for the Burrow to tell the account to remove. + expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "redeemed", burrowId: enrollment.burrowId }); + expect((await f.mint(enrollment.burrowToken)).status).toBe(200); + // Removing the Burrow never makes its approval redeemable again. + await f.sql(`DELETE FROM dormouse_relay_burrows`); + expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "redeemed", burrowId: enrollment.burrowId }); + expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_burrows`)).toEqual([{ n: 0 }]); + // Expired, the marker is swept with the rest. + await f.sql(`UPDATE dormouse_relay_enrollment_approvals SET "expiresAt" = now() - interval '1 second'`); expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "pending" }); + await f.cron(); expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_enrollment_approvals`)).toEqual([{ n: 0 }]); - expect((await f.mint(enrollment.burrowToken)).status).toBe(200); }); test("an approval redeems only the device code its user code is derived from", async ({ onTestFinished }) => { @@ -919,7 +932,14 @@ test("two polls racing one approval mint one Burrow", async ({ onTestFinished }) const begun = await f.begin(); await f.approve(begun.userCode, owner); const answers = await Promise.all(Array.from({ length: 4 }, () => f.poll(begun.deviceCode))); - expect(answers.filter(({ json }) => json!.status === "enrolled")).toHaveLength(1); + const won = answers.filter(({ json }) => json!.status === "enrolled"); + expect(won).toHaveLength(1); + // Every other poll learns the approval was spent, and on what, whether it + // read it spent or lost the race at the lock. + const { burrowId } = (won[0]!.json as { enrollment: { burrowId: string } }).enrollment; + expect(answers.filter(({ json }) => json!.status === "redeemed").map(({ json }) => json)).toEqual( + Array(3).fill({ status: "redeemed", burrowId }), + ); } expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_burrows`)).toEqual([{ n: 5 }]); }); @@ -958,6 +978,11 @@ test("an enrollment expires, is refused past its approver's entitlement, and wai // A removed Burrow makes room. await f.sql(`DELETE FROM dormouse_relay_burrows WHERE "burrowId" = $1`, [enrolled[0].burrowId]); expect((await f.poll(begun.deviceCode)).json).toMatchObject({ status: "enrolled" }); + // Full again, a poll whose answer was lost still learns it was redeemed, + // and so does one past its approver's entitlement. + expect(await f.poll(begun.deviceCode)).toMatchObject({ status: 200, json: { status: "redeemed", burrowId: expect.any(String) } }); + await f.sql(`UPDATE "user" SET "emailVerified" = false WHERE id = $1`, [owner]); + expect(await f.poll(begun.deviceCode)).toMatchObject({ status: 200, json: { status: "redeemed", burrowId: expect.any(String) } }); }); test("begin refuses another origin", async ({ onTestFinished }) => { diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts index cf4a489f8..9b89e3e06 100644 --- a/hosted/server/tests/workers.test.ts +++ b/hosted/server/tests/workers.test.ts @@ -1018,6 +1018,23 @@ test("enrollment: only a recent admin login from this origin approves, and the B await queryDatabase(f.database.url, `SELECT "burrowId" FROM dormouse_relay_burrows ORDER BY "burrowId"`), ).toEqual([{ burrowId: foreign }]); expect((await admin.remove(burrowId)).status).toBe(404); + + // A redeemed approval is approved again only once it has expired, and then unredeemed. + expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "redeemed", burrowId }); + expect((await admin.approve(begun.userCode)).status).toBe(409); + await queryDatabase( + f.database.url, + `UPDATE dormouse_relay_enrollment_approvals SET "expiresAt" = now() - interval '1 second' WHERE "userCode" = $1`, + [begun.userCode], + ); + expect((await admin.approve(begun.userCode)).status).toBe(204); + expect( + await queryDatabase( + f.database.url, + `SELECT "redeemedBurrowId", "redeemedAt" FROM dormouse_relay_enrollment_approvals WHERE "userCode" = $1`, + [begun.userCode], + ), + ).toEqual([{ redeemedBurrowId: null, redeemedAt: null }]); }); test("enrollment approval needs a login from the recent-login window, and attempts are limited per account", async ({ diff --git a/lib/src/components/NetworkSettings.test.tsx b/lib/src/components/NetworkSettings.test.tsx index 37448bf54..5edfde4c1 100644 --- a/lib/src/components/NetworkSettings.test.tsx +++ b/lib/src/components/NetworkSettings.test.tsx @@ -34,10 +34,13 @@ import { NetworkPhones, NetworkSettings, NetworkUpdates, + PATH_REFUSAL_LABEL, connectionsFor, policyForLevel, type NetworkFacts, } from './NetworkSettings'; +import { pathRefusalSentence } from './remote-control-shared'; +import type { PathRefusal } from '../remote/direct/path-refusal'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -98,10 +101,59 @@ describe('connectionsFor', () => { { to: 'Your phone, on any network', when: 'While connected', carries: 'Terminal traffic, end-to-end encrypted.' }, ]; expect(connectionsFor(facts({ policy: ANYWHERE }))).toEqual(rows); - // Anywhere runs no persistent Burrow, so an enrollment with a paired phone - // adds no Relay or push row; the networks left allowed add nothing either. + // The networks left allowed add nothing. + expect(connectionsFor(facts({ policy: { ...ANYWHERE, allowed: [LAN] } }))).toEqual(rows); + }); + + it('lists Hosted always once enrolled, terminal traffic through it only under Anywhere, and push once a phone is paired', () => { const enrolled = { ...UNENROLLED_STATUS, enrolled: true, pairedClients: 1 }; - expect(connectionsFor(facts({ policy: { ...ANYWHERE, allowed: [LAN] }, status: enrolled }))).toEqual(rows); + const push = { + to: 'relay.dormouse.sh → your phone’s push service', + when: 'When an alert goes unattended, where push is on', + carries: 'An end-to-end encrypted notification.', + }; + expect(connectionsFor(facts({ policy: ANYWHERE, status: enrolled }))).toEqual([ + { + to: 'relay.dormouse.sh', + when: 'Always', + carries: + 'Encrypted handshakes and one-time links, requests for setup codes and the push device list, and terminal traffic when a phone can’t connect directly.', + }, + { + to: CLOUDFLARE_STUN_HOST, + when: 'When a phone connects', + carries: 'A lookup that shows Cloudflare this computer’s public IP address.', + }, + { to: 'Your phone, directly', when: 'While connected', carries: 'Terminal traffic, end-to-end encrypted.' }, + push, + ]); + // Local networks holds a paired phone to the direct path: never terminal + // traffic through Hosted, and the phone only on an allowed network. + expect(connectionsFor(facts({ status: enrolled }))).toEqual([ + { + to: 'relay.dormouse.sh', + when: 'Always', + carries: + 'Encrypted handshakes and one-time links, requests for setup codes and the push device list. Never terminal traffic.', + }, + { + to: 'Your phone, directly, on an allowed network', + when: 'While connected', + carries: 'Terminal traffic, end-to-end encrypted.', + }, + push, + ]); + // With nothing allowed no phone connects and no link opens, but the + // socket still runs. + expect(connectionsFor(facts({ policy: { ...LOCAL, allowed: [] }, status: enrolled }))).toEqual([ + { + to: 'relay.dormouse.sh', + when: 'Always', + carries: 'Encrypted handshakes, requests for setup codes and the push device list. Never terminal traffic.', + }, + push, + ]); + expect(destinations({ policy: ANYWHERE, status: { ...enrolled, pairedClients: 0 } })).not.toContain(push.to); }); it('lists the Relay always, once enrolled, and the phone directly, under My Relay only', () => { @@ -113,6 +165,20 @@ describe('connectionsFor', () => { expect(connectionsFor(unenrolled)[0]!.when).toBe('Always, once this computer is enrolled'); }); + it('lists no relay socket while the Relay refuses this Burrow, which opens nothing', () => { + for (const connection of ['removed', 'not-entitled'] as const) { + // Hosted: only the one-time links that still ride its origin. + expect( + connectionsFor(facts({ status: { ...UNENROLLED_STATUS, enrolled: true, pairedClients: 1, connection } })), + connection, + ).toEqual(connectionsFor(facts())); + // Self-host under My Relay only: nothing reaches this computer at all. + expect(connectionsFor(facts({ ...relayFacts(1), status: enrolledStatus({ pairedClients: 1, connection }) }))).toEqual([]); + } + // A socket merely down is still the standing connection. + expect(connectionsFor(facts({ ...relayFacts(1), status: enrolledStatus({ pairedClients: 1, connection: 'disconnected' }) }))[0]!.when).toBe('Always'); + }); + it('lists push through the Relay once a phone is paired, naming the push setting as its condition', () => { // Push may be on in a Workspace, some in other windows, with the default // off, so the list cannot read it and states the condition instead. @@ -179,6 +245,74 @@ describe('policyForLevel', () => { }); }); +/** 10:42 on this machine's clock, whatever its zone. */ +const AT_10_42 = new Date(2026, 9, 1, 10, 42).getTime(); +const CLOCK_10_42 = new Date(AT_10_42).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + +describe('pathRefusalSentence', () => { + const observed: PathRefusal = { + at: AT_10_42, + kind: 'path-refused', + end: 'remote', + address: '172.58.12.9', + addressSource: 'observed', + }; + const reported: PathRefusal = { ...observed, kind: 'given-up', addressSource: 'reported' }; + const remote: PathRefusal = { at: AT_10_42, kind: 'path-refused', end: 'remote' }; + const local: PathRefusal = { at: AT_10_42, kind: 'path-refused', end: 'local', localAddress: '10.0.0.2' }; + const localUnnamed: PathRefusal = { at: AT_10_42, kind: 'path-refused', end: 'local' }; + const none: PathRefusal = { at: AT_10_42, kind: 'deadline' }; + const SAME_DAY = AT_10_42 + 60 * 60 * 1000; + + it('says when, and which end was off the networks allowed below', () => { + expect(pathRefusalSentence(observed, 'network-panel', SAME_DAY)).toBe( + `At ${CLOCK_10_42} a phone tried to connect from 172.58.12.9, which isn’t on a network allowed below.`, + ); + // The phone's own claim is named as its claim, never as off the networks. + expect(pathRefusalSentence(reported, 'network-panel', SAME_DAY)).toBe( + `At ${CLOCK_10_42} a phone couldn’t connect directly over an allowed network (it reported 172.58.12.9).`, + ); + expect(pathRefusalSentence(remote, 'network-panel', SAME_DAY)).toBe( + `At ${CLOCK_10_42} a phone tried to connect from outside the networks allowed below.`, + ); + // This computer's own end: never the phone's network. + expect(pathRefusalSentence(local, 'network-panel', SAME_DAY)).toBe( + `At ${CLOCK_10_42} a phone couldn’t connect: this computer wasn’t on a network allowed below (its address was 10.0.0.2).`, + ); + expect(pathRefusalSentence(localUnnamed, 'network-panel', SAME_DAY)).toBe( + `At ${CLOCK_10_42} a phone couldn’t connect: this computer wasn’t on a network allowed below.`, + ); + expect(pathRefusalSentence(none, 'network-panel', SAME_DAY)).toBe( + `At ${CLOCK_10_42} a phone couldn’t reach this computer over an allowed network.`, + ); + }); + + it('names the date of a refusal from another day', () => { + const day = new Date(AT_10_42).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + const nextDay = new Date(2026, 9, 2, 9, 0).getTime(); + expect(pathRefusalSentence(none, 'network-panel', nextDay)).toBe( + `On ${day} at ${CLOCK_10_42} a phone couldn’t reach this computer over an allowed network.`, + ); + const withYear = new Date(AT_10_42).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + expect(pathRefusalSentence(none, 'network-panel', new Date(2027, 0, 2).getTime())).toContain(`On ${withYear} at`); + }); + + it('words a one-time ending the same way, as the ending', () => { + expect(pathRefusalSentence(observed, 'one-time')).toBe( + 'The phone tried to connect from 172.58.12.9, which isn’t on one of your allowed networks, so the connection ended.', + ); + expect(pathRefusalSentence(reported, 'one-time')).toBe( + 'The phone couldn’t connect directly over one of your allowed networks (it reported 172.58.12.9), so the connection ended.', + ); + expect(pathRefusalSentence(local, 'one-time')).toBe( + 'This computer wasn’t on one of your allowed networks (its address was 10.0.0.2), so the connection ended.', + ); + expect(pathRefusalSentence(none, 'one-time')).toBe( + 'The phone couldn’t reach this computer over one of your allowed networks, so the connection ended.', + ); + }); +}); + let container: HTMLDivElement; let root: Root; let platform: FakePtyAdapter; @@ -376,6 +510,25 @@ describe('Settings → Network', () => { expect(sentPolicies()).toEqual([{ ...both, allowed: [LAN, 'fd00:1::/64'] }]); }); + it('shows the last refused phone above the allowed networks until it is dismissed', async () => { + const refusal: PathRefusal = { at: AT_10_42, kind: 'path-refused', end: 'remote', address: '172.58.12.9', addressSource: 'observed' }; + link({ status: UNENROLLED_STATUS, network: networkPolicyResult(LOCAL, 'hosted', INTERFACES, refusal) }); + await render(); + const notice = () => container.querySelector(`[role="status"][aria-label="${PATH_REFUSAL_LABEL}"]`); + expect(notice()?.textContent).toContain(pathRefusalSentence(refusal, 'network-panel')); + + await act(async () => button('Dismiss').click()); + expect(command.mock.calls.map(([cmd]) => cmd)).toContain('dismissPathRefusal'); + expect(notice()).toBeNull(); + }); + + it('shows no refused phone under a level that holds no path', async () => { + const refusal: PathRefusal = { at: AT_10_42, kind: 'deadline' }; + link({ status: UNENROLLED_STATUS, network: networkPolicyResult(ANYWHERE, 'hosted', INTERFACES, refusal) }); + await render(); + expect(text()).not.toContain('couldn’t reach this computer'); + }); + it('says how many ranges may be allowed rather than sending one too many', async () => { const full = Array.from({ length: MAX_ALLOWED_NETWORKS }, (_, i) => `10.${i}.0.0/16`); link({ status: UNENROLLED_STATUS, network: networkPolicyResult({ ...LOCAL, allowed: full }, 'hosted', INTERFACES) }); diff --git a/lib/src/components/NetworkSettings.tsx b/lib/src/components/NetworkSettings.tsx index dd4d56819..0d01c762e 100644 --- a/lib/src/components/NetworkSettings.tsx +++ b/lib/src/components/NetworkSettings.tsx @@ -12,16 +12,18 @@ import { modalActionButton, } from './design'; import { useManagedVoiceConfigured } from './ManagedVoiceSection'; -import { hostOf, useBusyAction } from './remote-control-shared'; +import { hostOf, pathRefusalSentence, useBusyAction } from './remote-control-shared'; import { HeldEnrollment, RemoteControlSection } from './RemoteControlSection'; -import type { BurrowConsoleStatus } from '../host/remote/service-protocol'; +import { relayRefuses, type BurrowConsoleStatus } from '../host/remote/service-protocol'; import { HOSTED_VOICE_ORIGIN } from '../host/relay-origin'; import { getPlatform } from '../lib/platform'; import { CLOUDFLARE_STUN_HOST } from '../remote/direct/ice-servers'; import type { UpdatesPort, UpdatesSnapshot } from '../lib/platform/types'; import { getBurrowStatusSnapshot, subscribeToBurrowStatus } from '../remote/burrow/burrow-status-store'; +import type { PathRefusal } from '../remote/direct/path-refusal'; import { changeNetworkPolicy, + dismissPathRefusal, getNetworkPolicySnapshot, refreshNetworkPolicy, subscribeToNetworkPolicy, @@ -147,8 +149,8 @@ interface ConnectionRow { /** What {@link connectionsFor} reads. */ export interface NetworkFacts { policy: NetworkPolicy; - /** The build's relay origin and mode, the enrollment, and its paired phones. */ - status: Pick; + /** The build's relay origin and mode, the enrollment, its relay socket, and its paired phones. */ + status: Pick; /** A managed-voice token is saved. */ managedVoice: boolean; /** This build checks for its own updates ({@link updatesItself}). */ @@ -167,6 +169,27 @@ function updatesItself(status: Pick): boolean return status.relayMode !== 'self-host' && !getPlatform().hostOwnsUpdates; } +/** + * What the relay socket carries under `policy`: one-time links only where one + * opens, and under Local networks never terminal traffic, which a paired + * phone's session may not take through the relay + * (`docs/specs/remote-network.md` -> "Local networks"). + */ +function persistentRelayCarries(policy: NetworkPolicy): string { + const handshakes = opensOneTimeLinks(policy) ? 'Encrypted handshakes and one-time links' : 'Encrypted handshakes'; + const requests = `${handshakes}, requests for setup codes and the push device list`; + return holdsToAllowedNetworks(policy.level) + ? `${requests}. Never terminal traffic.` + : `${requests}, and terminal traffic when a phone can’t connect directly.`; +} + +/** Where the phone row says the phone is: on any network, an allowed one, or simply directly. */ +function phoneRowFor(policy: NetworkPolicy, persistent: boolean): string { + if (policy.level === 'relay') return 'Your phone, directly'; + if (phoneOnAnyNetwork(policy)) return persistent ? 'Your phone, directly' : 'Your phone, on any network'; + return persistent ? 'Your phone, directly, on an allowed network' : 'Your phone, on an allowed network'; +} + /** * Every connection Dormouse opens on its own under `facts`, and nothing * else — each row backed by code (`docs/specs/remote-network.md` -> "Settings → @@ -178,53 +201,52 @@ export function connectionsFor(facts: NetworkFacts): ConnectionRow[] { if (policy.level === 'nothing') return []; const relay = hostOf(status.relayOrigin); const rows: ConnectionRow[] = []; - if (opensOneTimeLinks(policy)) { - rows.push( - { - to: relay, - when: 'Only while a one-time link is open', - carries: 'Encrypted handshakes. Never terminal traffic.', - }, - // The transport's own predicate, so the row and the gathering never disagree. - ...(burrowUsesStun(policy.level) - ? [ - { - to: CLOUDFLARE_STUN_HOST, - when: 'When a phone connects', - carries: 'A lookup that shows Cloudflare this computer’s public IP address.', - }, - ] - : []), - { - to: phoneOnAnyNetwork(policy) ? 'Your phone, on any network' : 'Your phone, on an allowed network', - when: 'While connected', - carries: 'Terminal traffic, end-to-end encrypted.', - }, - ); + const oneTime = opensOneTimeLinks(policy); + // The relay socket: a self-host build's, enrolled or about to be; a Hosted + // build's once it is enrolled, the one-time links riding the same origin. + // None while the Relay no longer takes this Burrow, which asks it nothing. + const refused = status.enrolled && relayRefuses(status.connection); + const persistent = + runsBurrow(policy.level) && (status.relayMode === 'self-host' || status.enrolled) && !refused; + if (persistent) { + rows.push({ + to: relay, + when: status.enrolled ? 'Always' : 'Always, once this computer is enrolled', + carries: persistentRelayCarries(policy), + }); + } else if (oneTime) { + rows.push({ + to: relay, + when: 'Only while a one-time link is open', + carries: 'Encrypted handshakes. Never terminal traffic.', + }); } - if (runsBurrow(policy.level)) { - rows.push( - { - to: relay, - when: status.enrolled ? 'Always' : 'Always, once this computer is enrolled', - carries: - 'Encrypted handshakes, requests for setup codes and the push device list, and terminal traffic when a phone can’t connect directly.', - }, - { - to: 'Your phone, directly', - when: 'While connected', - carries: 'Terminal traffic, end-to-end encrypted.', - }, - ); - // Whether push is on is the application's default and every Workspace's - // own, some in other windows, so the row names the condition instead. - if (status.pairedClients > 0) { - rows.push({ - to: `${relay} → your phone’s push service`, - when: 'When an alert goes unattended, where push is on', - carries: 'An end-to-end encrypted notification.', - }); - } + // The transport's own predicate, so the row and the gathering never disagree. + if (oneTime && burrowUsesStun(policy.level)) { + rows.push({ + to: CLOUDFLARE_STUN_HOST, + when: 'When a phone connects', + carries: 'A lookup that shows Cloudflare this computer’s public IP address.', + }); + } + // A phone reaches this computer directly wherever one can connect at all — + // not under Local networks with nothing allowed, nor under My Relay only + // once that Relay refuses this Burrow. + if (oneTime || (policy.level === 'relay' && !refused)) { + rows.push({ + to: phoneRowFor(policy, persistent), + when: 'While connected', + carries: 'Terminal traffic, end-to-end encrypted.', + }); + } + // Whether push is on is the application's default and every Workspace's + // own, some in other windows, so the row names the condition instead. + if (persistent && status.pairedClients > 0) { + rows.push({ + to: `${relay} → your phone’s push service`, + when: 'When an alert goes unattended, where push is on', + carries: 'An end-to-end encrypted notification.', + }); } if (facts.managedVoice && status.relayMode === 'hosted') { rows.push({ @@ -404,6 +426,7 @@ function AllowedNetworks({ network }: { network: NetworkPolicyResult }) {
Allowed networks
A phone connects only when both ends of its connection are on one of these.
+ {network.refusal ? : null}
{interfaces.map((item) => ( + {pathRefusalSentence(refusal, 'network-panel')}{' '} + + {error ?
{error}
: null} +
+ ); +} + function NetworkRow({ control, name, prefixes }: { control: ReactNode; name: string; prefixes: string[] }) { return (
diff --git a/lib/src/components/OneTimeConnection.tsx b/lib/src/components/OneTimeConnection.tsx index 5f9189b78..431e86efb 100644 --- a/lib/src/components/OneTimeConnection.tsx +++ b/lib/src/components/OneTimeConnection.tsx @@ -6,6 +6,7 @@ import { hostOf, oneTimeControlSentence, own, + pathRefusalSentence, revealPanel, useNetworkPolicy, } from './remote-control-shared'; @@ -48,7 +49,10 @@ const UNAVAILABLE_COPY: Record = { * build does not. `host` is this build's relay host, which the rendezvous runs on. * `anyNetwork` ({@link phoneOnAnyNetwork}) has no allowed network to name, so * `direct-failed` suggests another network instead; `network-not-allowed` keeps - * its sentence, since no path is held there to end a connection. + * its sentence, since no path is held there to end a connection. A + * `network-not-allowed` ending that carries its refusal reads + * `pathRefusalSentence` instead, naming the address; this is the fallback for + * one that does not. * * `user-ended` has no sentence: this machine ended it (End, Cancel), so there is * nothing to report, and the panel goes straight back to its button. @@ -269,7 +273,9 @@ function OneTimePanel({ aria-label={ONE_TIME_OUTCOME_LABEL} className="mt-1 text-sm leading-relaxed text-foreground" > - {own(oneTimeEndedCopy(relayHost, anyNetwork), state.reason) ?? ENDED_FALLBACK} + {state.reason === 'network-not-allowed' && state.refusal + ? pathRefusalSentence(state.refusal, 'one-time') + : (own(oneTimeEndedCopy(relayHost, anyNetwork), state.reason) ?? ENDED_FALLBACK)}
); actions = ( diff --git a/lib/src/components/RemoteControlSection.test.tsx b/lib/src/components/RemoteControlSection.test.tsx index 83230a06c..8eaa36246 100644 --- a/lib/src/components/RemoteControlSection.test.tsx +++ b/lib/src/components/RemoteControlSection.test.tsx @@ -34,12 +34,21 @@ vi.mock('./QrCode', async (importOriginal) => { }); import { ONE_TIME_OUTCOME_LABEL, oneTimeEndedCopy } from './OneTimeConnection'; -import { PAIRING_OUTCOME_LABEL, RemoteControlSection } from './RemoteControlSection'; -import { hostOf } from './remote-control-shared'; +import { + HOSTED_ENROLLMENT_CODE_LABEL, + HOSTED_ENROLLMENT_ENDED_COPY, + HOSTED_ENROLLMENT_REDEEMING_COPY, + NOT_ENTITLED_COPY, + PAIRING_OUTCOME_LABEL, + RemoteControlSection, + removedCopy, +} from './RemoteControlSection'; +import { hostOf, pathRefusalSentence } from './remote-control-shared'; import { DEFAULT_RELAY_ORIGIN } from '../host/relay-origin'; import { isOneTimeState, type BurrowConsoleStatus, + type HostedEnrollmentState, type SetupQrResult, } from '../host/remote/service-protocol'; import { @@ -60,6 +69,7 @@ import { networkPolicyResult, type NetworkPolicy } from '../remote/network-polic import { refreshBurrowStatus } from '../remote/burrow/burrow-status-store'; import { getOneTimeSnapshot, subscribeToOneTime } from '../remote/burrow/one-time-store'; import { TEST_SETUP_PASSWORD } from '../remote/test-setup-password'; +import { SETUP_BUTTON } from '../remote/setup-copy'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -88,6 +98,31 @@ function qr(over: Partial = {}): SetupQrResult { }; } +/** A Hosted enrollment waiting for approval, ten minutes out. */ +function hostedWaiting(over: { accountFull?: boolean } = {}): HostedEnrollmentState { + return { + status: 'waiting', + userCode: '23AB-YZ9K', + verificationUrl: 'https://hosted.dormouse.sh/enroll#23AB-YZ9K', + expiresAt: Date.now() + 10 * 60_000, + accountFull: over.accountFull ?? false, + }; +} + +/** The code a waiting Hosted enrollment shows, by its accessible name. */ +function codeShown(): string | null { + return container.querySelector(`[aria-label="${HOSTED_ENROLLMENT_CODE_LABEL}"]`)?.textContent ?? null; +} + +/** The name-and-button form a Hosted build begins with, always mounted once unfolded. */ +function hostedForm(): HTMLFormElement { + const form = [...container.querySelectorAll('form')].find((candidate) => + candidate.textContent?.includes('Name for this Burrow'), + ); + if (!form) throw new Error('the Hosted enroll form is not mounted'); + return form; +} + /** The shared fixture, keeping this file's own Relay/burrow values. */ const enrolled = (over: Partial = {}) => enrolledStatus({ @@ -286,23 +321,191 @@ describe('RemoteControlSection', () => { await openPersistent(); expect(buttonLabelled('Persistent Relay')!.getAttribute('aria-expanded')).toBe('true'); expect(persistentPanel().hidden).toBe(false); - // Its one Relay is Hosted's (docs/specs/relay.md → "Relay origin"): no form, - // no offer card, and the self-host path named as a build. - expect(container.querySelector('form')).toBeNull(); + // Its one Relay is Hosted's (docs/specs/relay.md → "Relay origin"): no + // password form, no offer card, and the self-host path named as a build. + expect(container.querySelector('input[type="password"]')).toBeNull(); expect(buttonLabelled('Connect')).toBeUndefined(); expect(text()).toContain('A self-hosted Relay takes a Dormouse built for its address.'); await act(async () => buttonLabelled('self-hosted Relay')!.click()); expect(openExternal).toHaveBeenCalledWith('https://dormouse.sh/self-host/'); }); - it('offers hosted.dormouse.sh as coming soon, linking the Hosted preview', async () => { + it('begins a Hosted enrollment under the name typed for this machine', async () => { + let status: BurrowConsoleStatus = NOT_ENROLLED; + const link = makeLink(async (cmd) => { + if (cmd === 'beginHostedEnrollment') { + status = { ...NOT_ENROLLED, hostedEnrollment: hostedWaiting() }; + return status.hostedEnrollment; + } + return status; + }); + platform = { burrow: link }; + await render(); + await openPersistent(); + expect(hostedForm().hidden).toBe(false); + await type('input:not([type])', 'Work laptop'); + + await act(async () => buttonLabelled('Enroll with hosted.dormouse.sh')!.click()); + + expect(link.command).toHaveBeenCalledWith('beginHostedEnrollment', { label: 'Work laptop' }); + expect(codeShown()).toBe('23AB-YZ9K'); + // Hidden while the code waits, never unmounted: what was typed survives. + expect(hostedForm().hidden).toBe(true); + }); + + it('shows the code waiting in large type, opens the account page the service composed, and cancels', async () => { const openExternal = vi.fn(); - platform = { burrow: makeLink(async () => NOT_ENROLLED), openExternal }; + const link = makeLink(async () => ({ ...NOT_ENROLLED, hostedEnrollment: hostedWaiting() })); + platform = { burrow: link, openExternal }; + await render(); + + // Unfolded from the start: the dialog reopened on a code waiting. + expect(persistentPanel().hidden).toBe(false); + expect(codeShown()).toBe('23AB-YZ9K'); + expect(text()).toContain('Expires in 10 min.'); + expect(text()).not.toContain('already has as many computers'); + await act(async () => buttonLabelled('Open hosted.dormouse.sh to approve')!.click()); + expect(openExternal).toHaveBeenCalledWith('https://hosted.dormouse.sh/enroll#23AB-YZ9K'); + + await act(async () => buttonLabelled('Cancel')!.click()); + expect(link.command).toHaveBeenCalledWith('cancelHostedEnrollment'); + }); + + it('says a full account enrolls on its own once a computer is removed there', async () => { + const openExternal = vi.fn(); + platform = { + burrow: makeLink(async () => ({ ...NOT_ENROLLED, hostedEnrollment: hostedWaiting({ accountFull: true }) })), + openExternal, + }; + await render(); + + expect(text()).toContain('That account already has as many computers as it can enroll.'); + await act(async () => buttonLabelled('Remove one')!.click()); + expect(openExternal).toHaveBeenCalledWith('https://hosted.dormouse.sh/account'); + }); + + it('says in fixed copy why an enrollment ended, and offers a new code or Done', async () => { + let ended: HostedEnrollmentState = { status: 'ended', reason: 'not-entitled' }; + const link = makeLink(async () => ({ ...NOT_ENROLLED, hostedEnrollment: ended })); + platform = { burrow: link }; + await render(); + expect(text()).toContain(HOSTED_ENROLLMENT_ENDED_COPY['not-entitled']); + + ended = { status: 'ended', reason: 'expired' }; + await act(async () => refreshBurrowStatus()); + expect(text()).toContain(HOSTED_ENROLLMENT_ENDED_COPY.expired); + + ended = { status: 'ended', reason: 'failed', message: 'keychain is locked' }; + await act(async () => refreshBurrowStatus()); + expect(text()).toContain(HOSTED_ENROLLMENT_ENDED_COPY.failed); + expect(text()).toContain('keychain is locked'); + + // A new code is just a begin, which the service answers with a fresh one. + await act(async () => buttonLabelled('Get a new code')!.click()); + expect(link.command).toHaveBeenCalledWith('beginHostedEnrollment', { label: NOT_ENROLLED.suggestedLabel }); + await act(async () => buttonLabelled('Done')!.click()); + expect(link.command).toHaveBeenCalledWith('cancelHostedEnrollment'); + }); + + it('disables Open once the code’s countdown reaches zero', async () => { + const openExternal = vi.fn(); + platform = { + burrow: makeLink(async () => ({ + ...NOT_ENROLLED, + hostedEnrollment: { ...hostedWaiting(), expiresAt: Date.now() - 1 } as HostedEnrollmentState, + })), + openExternal, + }; + await render(); + expect(text()).toContain('This code has expired.'); + expect(buttonLabelled('Open hosted.dormouse.sh to approve')!.disabled).toBe(true); + }); + + it('says a redeemed code is enrolling, with nothing to cancel or begin', async () => { + platform = { burrow: makeLink(async () => ({ ...NOT_ENROLLED, hostedEnrollment: { status: 'redeeming' } })) }; + await render(); + expect(persistentPanel().hidden).toBe(false); + expect(text()).toContain(HOSTED_ENROLLMENT_REDEEMING_COPY); + expect(hostedForm().hidden).toBe(true); + expect(buttonLabelled('Cancel')).toBeUndefined(); + }); + + it('sends a lost answer to the account page to remove the Burrow it added, by name', async () => { + const openExternal = vi.fn(); + platform = { + burrow: makeLink(async () => ({ + ...NOT_ENROLLED, + hostedEnrollment: { status: 'ended', reason: 'answer-lost', burrowId: 'T7lzkkrPT8nx4m9zf90V4h' }, + })), + openExternal, + }; + await render(); + expect(text()).toContain(HOSTED_ENROLLMENT_ENDED_COPY['answer-lost']); + expect(text()).toContain('Remove Burrow T7lzkkrPT8nx4m9zf90V4h from your account'); + await act(async () => buttonLabelled('Manage computers at hosted.dormouse.sh')!.click()); + expect(openExternal).toHaveBeenCalledWith('https://hosted.dormouse.sh/account'); + }); + + it('shows an enrolled machine why a second redemption could not be kept, until dismissed', async () => { + const message = 'Your account holds Burrow T7lzkkrPT8nx4m9zf90V4h, which this computer could not keep.'; + const link = makeLink(async () => + enrolled({ + relayOrigin: DEFAULT_RELAY_ORIGIN, + relayMode: 'hosted', + accountOrigin: 'https://hosted.dormouse.sh', + hostedEnrollment: { status: 'ended', reason: 'failed', message }, + }), + ); + platform = { burrow: link }; + await render(); + expect(text()).toContain(message); + await act(async () => buttonLabelled('Dismiss')!.click()); + expect(link.command).toHaveBeenCalledWith('cancelHostedEnrollment'); + }); + + it('shows a refused begin where the button is', async () => { + platform = { + burrow: makeLink(async (cmd) => { + if (cmd === 'beginHostedEnrollment') throw new Error('Settings → Network is set to Nothing'); + return NOT_ENROLLED; + }), + }; await render(); await openPersistent(); - expect(buttonLabelled('Use hosted.dormouse.sh')!.disabled).toBe(true); - await act(async () => buttonLabelled('Get updates on Hosted.')!.click()); - expect(openExternal).toHaveBeenCalledWith('https://dormouse.sh/hosted/#remote-control'); + await act(async () => buttonLabelled('Enroll with hosted.dormouse.sh')!.click()); + expect(text()).toContain('Settings → Network is set to Nothing'); + }); + + it('links a Hosted enrollment to the account that manages it, and a self-host one to nothing', async () => { + const openExternal = vi.fn(); + platform = { + burrow: makeLink(async () => + enrolled({ relayOrigin: DEFAULT_RELAY_ORIGIN, relayMode: 'hosted', accountOrigin: 'https://hosted.dormouse.sh' }), + ), + openExternal, + }; + await render(); + await act(async () => buttonLabelled('Manage computers at hosted.dormouse.sh')!.click()); + expect(openExternal).toHaveBeenCalledWith('https://hosted.dormouse.sh/account'); + await act(async () => root.unmount()); + + // A dev Hosted build's account is where its waiting view sent the approval. + root = createRoot(container); + platform = { + burrow: makeLink(async () => + enrolled({ relayOrigin: 'http://localhost:8787', relayMode: 'hosted', accountOrigin: 'http://localhost:5173' }), + ), + openExternal, + }; + await render(); + await act(async () => buttonLabelled('Manage computers at localhost:5173')!.click()); + expect(openExternal).toHaveBeenLastCalledWith('http://localhost:5173/account'); + await act(async () => root.unmount()); + + root = createRoot(container); + platform = { burrow: makeLink(async () => enrolled()) }; + await render(); + expect(buttonLabelled('Manage computers at hosted.dormouse.sh')).toBeUndefined(); }); it('offers a self-host build its form under the origin it was built for, and no Hosted button', async () => { @@ -314,7 +517,7 @@ describe('RemoteControlSection', () => { expect(typedForm().textContent).toContain(SELF_HOST_RELAY_ORIGIN); // The origin is named, never typed: no field for it. expect(container.querySelector('input[type="url"]')).toBeNull(); - expect(buttonLabelled('Use hosted.dormouse.sh')).toBeUndefined(); + expect(buttonLabelled('Enroll with hosted.dormouse.sh')).toBeUndefined(); expect(buttonLabelled('Connect')).toBeTruthy(); }); @@ -635,6 +838,80 @@ describe('RemoteControlSection', () => { expect(link.command).toHaveBeenCalledWith('reconnect'); }); + it('says a self-host Burrow was removed from its Relay, with Disconnect alone', async () => { + platform = { burrow: makeLink(async () => enrolled({ connection: 'removed' })) }; + await render(); + expect(text()).toContain(removedCopy(enrolled())); + expect(text()).toContain('removed from laptop.tailnet.ts.net'); + expect(buttonLabelled('Disconnect')).toBeTruthy(); + for (const absent of ['Reconnect', 'Enroll again', SETUP_BUTTON]) { + expect(buttonLabelled(absent), absent).toBeUndefined(); + } + }); + + it('enrolls a removed Hosted Burrow again: the dead enrollment cleared, then a code begun', async () => { + let enrolledNow = true; + const hosted = { + relayOrigin: DEFAULT_RELAY_ORIGIN, + relayMode: 'hosted' as const, + accountOrigin: 'https://hosted.dormouse.sh', + }; + const link = makeLink(async (cmd) => { + if (cmd === 'clearEnrollment') { + enrolledNow = false; + return {}; + } + if (cmd === 'beginHostedEnrollment') return hostedWaiting(); + return enrolledNow + ? enrolled({ ...hosted, connection: 'removed' }) + : { ...NOT_ENROLLED, hostedEnrollment: hostedWaiting() }; + }); + platform = { burrow: link }; + await render(); + expect(text()).toContain('This computer was removed from your account at hosted.dormouse.sh.'); + expect(buttonLabelled(SETUP_BUTTON)).toBeUndefined(); + expect(buttonLabelled('Reconnect')).toBeUndefined(); + expect(buttonLabelled('Disconnect')).toBeTruthy(); + + await act(async () => buttonLabelled('Enroll again')!.click()); + const order = link.command.mock.calls + .map(([cmd]) => cmd) + .filter((cmd) => cmd === 'clearEnrollment' || cmd === 'beginHostedEnrollment'); + expect(order).toEqual(['clearEnrollment', 'beginHostedEnrollment']); + expect(link.command).toHaveBeenCalledWith('beginHostedEnrollment', { label: 'ned-mac' }); + // The code is in view, not folded behind Persistent Relay. + expect(codeShown()).toBe('23AB-YZ9K'); + }); + + it('shows a begin refused after Enroll again cleared the enrollment', async () => { + let enrolledNow = true; + const link = makeLink(async (cmd) => { + if (cmd === 'clearEnrollment') { + enrolledNow = false; + return {}; + } + if (cmd === 'beginHostedEnrollment') throw new Error('Couldn’t reach relay.dormouse.sh: the name doesn’t resolve.'); + return enrolledNow ? enrolled({ relayMode: 'hosted', connection: 'removed' }) : NOT_ENROLLED; + }); + platform = { burrow: link }; + await render(); + await act(async () => buttonLabelled('Enroll again')!.click()); + expect(text()).toContain('Couldn’t reach relay.dormouse.sh: the name doesn’t resolve.'); + expect(hostedForm().hidden).toBe(false); + }); + + it('says a Hosted plan no longer includes remote control, with Reconnect and Disconnect', async () => { + const link = makeLink(async () => enrolled({ relayMode: 'hosted', connection: 'not-entitled' })); + platform = { burrow: link }; + await render(); + expect(text()).toContain(NOT_ENTITLED_COPY); + expect(buttonLabelled(SETUP_BUTTON)).toBeUndefined(); + expect(buttonLabelled('Enroll again')).toBeUndefined(); + expect(buttonLabelled('Disconnect')).toBeTruthy(); + await act(async () => buttonLabelled('Reconnect')!.click()); + expect(link.command).toHaveBeenCalledWith('reconnect'); + }); + it('confirms before disconnecting, because paired phones must re-pair', async () => { const link = makeLink(async () => enrolled()); platform = { burrow: link }; @@ -1747,6 +2024,13 @@ describe('One-time connection', () => { expect(copy['direct-failed']).toContain('allowed network'); }); + it('names where the phone connected from when the path ended it, in the Network panel’s words', async () => { + const refusal = { at: NOW, kind: 'path-refused', end: 'remote', address: '172.58.12.9', addressSource: 'observed' } as const; + await renderOneTime(oneTimeService({ status: 'ended', reason: 'network-not-allowed', refusal })); + expect(oneTimeOutcome()?.textContent).toBe(pathRefusalSentence(refusal, 'one-time')); + expect(oneTimeOutcome()?.textContent).toContain('172.58.12.9'); + }); + it('names the rendezvous by this build’s relay host, a dev build’s included', async () => { const status = { ...NOT_ENROLLED, relayOrigin: 'http://localhost:8787' }; for (const [reason, sentence] of [ diff --git a/lib/src/components/RemoteControlSection.tsx b/lib/src/components/RemoteControlSection.tsx index 2ff6fb01a..c0b3fb280 100644 --- a/lib/src/components/RemoteControlSection.tsx +++ b/lib/src/components/RemoteControlSection.tsx @@ -3,9 +3,25 @@ import { DEFAULT_PAIRING_TTL_MS } from 'remote-lib-common'; import { ModalReviewBlock, TextInput, modalActionButton } from './design'; import { ExternalTextLink } from './ExternalTextLink'; import { OneTimeConnection } from './OneTimeConnection'; -import { FIELD_HINT, FIELD_LABEL, own, revealPanel, useBusyAction } from './remote-control-shared'; +import { + FIELD_HINT, + FIELD_LABEL, + hostOf, + own, + revealPanel, + useBusyAction, + useMinutesLeft, +} from './remote-control-shared'; import { ExpiringCode } from './ScannableCode'; -import type { BurrowConsoleStatus, SetupQrResult } from '../host/remote/service-protocol'; +import { ACCOUNT_PAGE_PATH, HOSTED_ACCOUNT_ORIGIN } from '../host/relay-origin'; +import { + relayRefuses, + type BurrowConsoleStatus, + type HostedEnrollmentEndReason, + type HostedEnrollmentState, + type SetupQrResult, +} from '../host/remote/service-protocol'; +import { getPlatform } from '../lib/platform'; import type { PairingOutcome, BurrowStatus, @@ -13,6 +29,8 @@ import type { } from '../remote/burrow/burrow-runtime'; import { BURROW_IS_AN_APP, SCAN_LABEL, SETUP_BUTTON } from '../remote/setup-copy'; import { + beginHostedEnrollment, + cancelHostedEnrollment, clearBurrowEnrollment, enrollOfferBurrow, enrollBurrow, @@ -24,12 +42,28 @@ import { subscribeToInvitation, } from '../remote/burrow/burrow-status-store'; +/** What `not-entitled` reads, in a Hosted build, the only kind that can latch it. */ +export const NOT_ENTITLED_COPY = 'Your Hosted plan doesn’t include remote control right now.'; + +/** What `removed` reads: the account it was removed from, or the self-host Relay. */ +export function removedCopy( + status: Pick, +): string { + return status.relayMode === 'hosted' + ? `This computer was removed from your account at ${hostOf(status.accountOrigin ?? HOSTED_ACCOUNT_ORIGIN)}.` + : `This computer was removed from ${hostOf(status.relayOrigin)}. Disconnect to enroll it again.`; +} + /** * How each relay-socket state reads to someone who is not holding the spec. - * `displaced` is the only one that needs the user to act, so it is the only one - * that gets a button (`docs/specs/relay.md`, "Relay socket policy"). + * The latched ones are the ones that need the user to act, so only they get a + * button of their own (`docs/specs/relay.md`, "Remote control, in the Settings + * dialog"). */ -function describeConnection(connection: BurrowStatus): { text: string; tone: 'ok' | 'warn' | 'muted' } { +function describeConnection( + connection: BurrowStatus, + status: Pick, +): { text: string; tone: 'ok' | 'warn' | 'muted' } { switch (connection) { case 'connected': return { text: 'Connected', tone: 'ok' }; @@ -42,6 +76,10 @@ function describeConnection(connection: BurrowStatus): { text: string; tone: 'ok text: 'Another Dormouse instance took this Relay’s slot. This machine stood down and will not retry on its own.', tone: 'warn', }; + case 'removed': + return { text: removedCopy(status), tone: 'warn' }; + case 'not-entitled': + return { text: NOT_ENTITLED_COPY, tone: 'warn' }; case 'stopped': return { text: 'Stopped', tone: 'muted' }; case 'idle': @@ -55,7 +93,6 @@ const TONE_CLASS = { muted: 'text-muted', } as const; -const HOSTED_REMOTE_URL = 'https://dormouse.sh/hosted/#remote-control'; const SELF_HOST_URL = 'https://dormouse.sh/self-host/'; /** @@ -460,8 +497,9 @@ function BurrowNameField({ * form would promise something the build cannot do. Nor before the first * status, which `NetworkPhones` already waited for. * - * This is the same `enroll` / `enrollOffer` / `status` / `reconnect` / - * `clearEnrollment` surface as the `window.dormouseBurrow` console hook, + * This is the same `enroll` / `enrollOffer` / `beginHostedEnrollment` / + * `cancelHostedEnrollment` / `status` / `reconnect` / `clearEnrollment` + * surface as the `window.dormouseBurrow` console hook, * which stays as the scripting seam (`docs/specs/relay.md`, "Burrow side"). * Pairing approval is *not* here — it is a modal, because it must interrupt * (`docs/specs/remote-security-model.md`, Pairing Ceremony). @@ -490,6 +528,15 @@ export function RemoteControlSection() { * {@link UnenrolledRelay}. */ function RelayChoices({ status }: { status: BurrowConsoleStatus }) { + // Enroll again spans the flip from enrolled to un-enrolled, so its busy and + // error live here, above both views: a begin refused after the clear still + // has somewhere to say so. + const enrollAgain = useBusyAction(); + const onEnrollAgain = () => + void enrollAgain.run(async () => { + await clearBurrowEnrollment(); + await beginHostedEnrollment(status.suggestedLabel); + }); return (
Control this Dormouse from your phone.
@@ -505,13 +552,18 @@ function RelayChoices({ status }: { status: BurrowConsoleStatus }) { setup code, or an error, belonging to the one we just left. */} ) : ( - + )}
@@ -525,8 +577,22 @@ function RelayChoices({ status }: { status: BurrowConsoleStatus }) { * dialog"). **Folding hides the enroll view, never unmounts it**, for the same * reason {@link EnrollView} folds its own. */ -function UnenrolledRelay({ status }: { status: BurrowConsoleStatus }) { - const [unfolded, setUnfolded] = useState(false); +function UnenrolledRelay({ + status, + enrollingAgain, + enrollAgainError, +}: { + status: BurrowConsoleStatus; + /** An Enroll again under way, whose begin this view will render. */ + enrollingAgain: boolean; + enrollAgainError: string | null; +}) { + // Unfolded from the start only over an enrollment already begun — the + // dialog reopened on a code waiting for approval, which folded would hide — + // or being begun by Enroll again. + const [unfolded, setUnfolded] = useState( + () => status.hostedEnrollment !== null || enrollingAgain || enrollAgainError !== null, + ); const [hint, choices] = status.relayMode === 'self-host' ? [ @@ -538,17 +604,16 @@ function UnenrolledRelay({ status }: { status: BurrowConsoleStatus }) { />, ] : [ - 'Enroll this Dormouse with hosted.dormouse.sh, so paired phones can reconnect any time.', + `Enroll this Dormouse with your ${hostOf(status.accountOrigin ?? HOSTED_ACCOUNT_ORIGIN)} account, so paired phones can reconnect any time.`, <> -
- - - Coming soon.{' '} - Get updates on Hosted. - -
+ + {enrollAgainError ? ( +
{enrollAgainError}
+ ) : null}
A self-hosted Relay takes a Dormouse built for its address. @@ -575,6 +640,205 @@ function UnenrolledRelay({ status }: { status: BurrowConsoleStatus }) { ); } +/** + * The accessible name of the code a Hosted enrollment shows, which the account + * page shows beside Approve for the person to compare. + */ +export const HOSTED_ENROLLMENT_CODE_LABEL = 'Enrollment code'; + +/** + * What an enrollment that ended short of enrolling says. **Fixed copy chosen + * by code**, as {@link PAIRING_OUTCOME_COPY} is: `failed` alone adds the + * service's own sentence. + */ +export const HOSTED_ENROLLMENT_ENDED_COPY: Record = { + expired: 'That code expired before it was approved, so this computer was not enrolled.', + 'not-entitled': + 'The account that approved that code can’t use the Hosted Relay, so this computer was not enrolled.', + 'answer-lost': + 'That code was approved and used, but the answer never reached this computer, so it was not enrolled.', + failed: 'This computer could not finish enrolling.', +}; + +/** The copy a status's `redeeming` shows: approved, and the enrollment being saved. */ +export const HOSTED_ENROLLMENT_REDEEMING_COPY = 'Approved. Enrolling this computer…'; + +/** The account page the service names in `status.accountOrigin`, where computers are removed. */ +function accountPage(accountOrigin: string | null): string | null { + return accountOrigin === null ? null : `${accountOrigin}${ACCOUNT_PAGE_PATH}`; +} + +/** What a lost answer asks of the person: the Burrow it enrolled, named where the service knows it. */ +export function answerLostRemoval(burrowId: string | undefined): string { + return burrowId + ? `Remove Burrow ${burrowId} from your account, then enroll again.` + : 'Remove the computer it added from your account, then enroll again.'; +} + +/** Why the last Hosted enrollment ended, with the account page where it says to go there. */ +function HostedEnrollmentEnded({ + ended, + accountOrigin, +}: { + ended: Extract; + accountOrigin: string | null; +}) { + const lost = ended.reason === 'answer-lost'; + const page = lost ? accountPage(accountOrigin) : null; + return ( +
+
{HOSTED_ENROLLMENT_ENDED_COPY[ended.reason]}
+ {lost ?
{answerLostRemoval(ended.burrowId)}
: null} + {ended.reason === 'failed' && ended.message ?
{ended.message}
: null} + {page ? ( +
+ Manage computers at {hostOf(page)} +
+ ) : null} +
+ ); +} + +/** + * A Hosted build's enrollment (`docs/specs/hosted.md` -> "Burrow enrollment"): + * the name to keep for this machine and a button that begins it; then the + * code, in large type, a button that opens the account page the service + * composed to approve it, the time left, and Cancel; ended, why, with a new + * code a click away. The service polls; this renders its `status`, never a + * state of its own. + * + * **The name field is hidden while a code waits, never unmounted**, so what + * was typed survives a Cancel. + */ +function HostedEnrollView({ + enrollment, + accountOrigin, + suggestedLabel, +}: { + enrollment: HostedEnrollmentState | null; + accountOrigin: string | null; + suggestedLabel: string; +}) { + const [label, setLabel] = useState(suggestedLabel); + const { busy, error, run } = useBusyAction(); + /** Which of the actions sharing {@link useBusyAction}'s gate is the begin, for its label. */ + const [beginning, setBeginning] = useState(false); + const waiting = enrollment?.status === 'waiting' ? enrollment : null; + const redeeming = enrollment?.status === 'redeeming'; + const ended = enrollment?.status === 'ended' ? enrollment : null; + const begin = () => + void run(async () => { + setBeginning(true); + try { + // A code another window has waiting is answered; one that ended is replaced. + await beginHostedEnrollment(label.trim()); + } finally { + setBeginning(false); + } + }); + + return ( +
+ {waiting ? ( + void run(cancelHostedEnrollment)} + /> + ) : null} + {redeeming ? ( +
+ {HOSTED_ENROLLMENT_REDEEMING_COPY} +
+ ) : null} + {ended ? : null} +
+ ); +} + +/** The code waiting for approval, and the way to approve it. */ +function HostedEnrollmentCode({ + waiting, + accountOrigin, + busy, + onCancel, +}: { + waiting: Extract; + accountOrigin: string | null; + busy: boolean; + onCancel: () => void; +}) { + const minutesLeft = useMinutesLeft(waiting.expiresAt) ?? 0; + const account = hostOf(waiting.verificationUrl); + const page = accountPage(accountOrigin); + return ( +
+
Approve this computer at your account. Check that it shows this code:
+
+ {waiting.userCode} +
+
+ {minutesLeft > 0 ? `Expires in ${minutesLeft} min.` : 'This code has expired.'} +
+ {waiting.accountFull ? ( +
+ That account already has as many computers as it can enroll.{' '} + {page ? Remove one : 'Remove one'} and this computer + enrolls on its own. +
+ ) : null} +
+ {/* An expired code approves nothing, so it opens nothing. */} + + +
+
+ ); +} + /** * Un-enrolled in a self-host build, with or without an installer's offer for * its Relay on this machine. @@ -781,22 +1045,29 @@ export function HeldEnrollment({ relayOrigin }: { relayOrigin: string }) { } function EnrolledView({ - relayOrigin, - connection, - pairedClients, + status, + enrollingAgain, + enrollAgainError, + onEnrollAgain, }: { - relayOrigin: string; - connection: BurrowStatus; - pairedClients: number; + status: BurrowConsoleStatus; + enrollingAgain: boolean; + enrollAgainError: string | null; + onEnrollAgain: () => void; }) { - const { busy, error, run } = useBusyAction(); + const { relayOrigin, accountOrigin, hostedEnrollment, connection, pairedClients } = status; + const { busy: ownBusy, error: ownError, run } = useBusyAction(); + const busy = ownBusy || enrollingAgain; + const error = ownError ?? enrollAgainError; + // A Relay that no longer takes this Burrow mints no setup code. + const refused = relayRefuses(connection); // Disconnecting drops every paired phone until they pair again, so it asks // once rather than acting on the first click. const [confirmingDisconnect, setConfirmingDisconnect] = useState(false); // Its own busy and error, unlike every other action here: the mint also fires // on a timer, and this view's one error slot belongs to what the user clicked. const setup = useSetupQr(); - const described = describeConnection(connection); + const described = describeConnection(connection, status); /** * Where the one pairing report goes, decided here rather than half in each * place that can draw it: **the panel owns it only where it has a sentence to @@ -816,13 +1087,35 @@ function EnrolledView({ ? 'No phone has paired with this machine yet.' : `${pairedClients} paired ${pairedClients === 1 ? 'phone' : 'phones'}.`}
+ {accountOrigin !== null ? ( +
+ + Manage computers at {hostOf(accountOrigin)} + +
+ ) : null} + {/* A second code redeemed onto an enrolled machine leaves a Burrow the + account must remove, which is said here until dismissed. */} + {hostedEnrollment?.status === 'ended' ? ( +
+ + +
+ ) : null} {setup.report && !reportInPanel ? : null} {error ?
{error}
: null}
- {connection === 'displaced' ? ( + {connection === 'displaced' || connection === 'not-entitled' ? ( + ) : null} {confirmingDisconnect ? ( setConfirmingDisconnect(false)} /> ) : ( <> - + {refused ? null : ( + + )} {/* Removal is local and always available: it is how a phone forgets a computer it will not see again, and it is what - queues the delivery row's deletion. */} - + queues the delivery row's deletion. A removed row's + Forget is this, so it is not offered twice. */} + {burrow.removed ? null : ( + + )}
); diff --git a/lib/src/remote/pocket-app/deployment.test.ts b/lib/src/remote/pocket-app/deployment.test.ts new file mode 100644 index 000000000..5e23c9c26 --- /dev/null +++ b/lib/src/remote/pocket-app/deployment.test.ts @@ -0,0 +1,186 @@ +/** + * Pocket's deployment (`docs/specs/remote-network.md` -> "Anywhere"): one + * bundle gathers through Cloudflare STUN where Hosted serves it and through + * nothing where a self-host Relay does, told apart by the file Hosted's + * staging writes. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { HOSTED_POCKET_DEPLOYMENT, POCKET_DEPLOYMENT_FILE } from 'remote-lib-common'; + +import { CLOUDFLARE_STUN_URL } from '../direct/ice-servers'; +import { + POCKET_DEPLOYMENT_PATH, + POCKET_DEPLOYMENT_READ_TIMEOUT_MS, + deploymentDirectPeer, + deploymentUnreachableMessage, + parsePocketDeployment, + pocketDeploymentSource, + readPocketDeployment, +} from './deployment'; + +/** A fetch answering only {@link POCKET_DEPLOYMENT_PATH}, with `respond`. */ +function serving( + respond: (init?: RequestInit) => Response | Promise, +): typeof globalThis.fetch { + return async (input, init) => { + expect(input).toBe(POCKET_DEPLOYMENT_PATH); + return respond(init); + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('Pocket’s deployment', () => { + it('reads Hosted from exactly what Hosted’s staging writes, at the path it writes it', async () => { + expect(POCKET_DEPLOYMENT_PATH).toBe(`/${POCKET_DEPLOYMENT_FILE}`); + // As `hosted/scripts/stage-relay.mjs` writes it. + expect( + await readPocketDeployment(serving(() => new Response(`${JSON.stringify(HOSTED_POCKET_DEPLOYMENT)}\n`))), + ).toBe('hosted'); + }); + + it('reads self-host from a self-host Relay’s shell, a 404, and any other complete body', async () => { + const answers: Array<() => Response | Promise> = [ + // The SPA fallback a self-host Relay answers every unknown path with. + () => new Response('Pocket', { headers: { 'content-type': 'text/html' } }), + () => new Response('Not Found', { status: 404 }), + () => new Response(JSON.stringify(HOSTED_POCKET_DEPLOYMENT), { status: 403 }), + () => new Response('{"deployment":"Hosted"}'), + () => new Response('{"deployment":["hosted"]}'), + () => new Response('null'), + ]; + for (const answer of answers) { + expect(await readPocketDeployment(serving(answer))).toBe('self-host'); + } + expect(parsePocketDeployment({ deployment: 'hosted' })).toBe('hosted'); + expect(parsePocketDeployment('hosted')).toBe('self-host'); + }); + + it('reads nothing from a read that does not complete: a failure, a 5xx, a body lost mid-read', async () => { + const lostBody = () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"deploy')); + controller.error(new TypeError('connection reset')); + }, + }), + ); + const answers: Array<() => Response | Promise> = [ + () => Promise.reject(new TypeError('offline')), + () => new Response(JSON.stringify(HOSTED_POCKET_DEPLOYMENT), { status: 500 }), + () => new Response('Bad Gateway', { status: 502 }), + lostBody, + ]; + for (const answer of answers) { + expect(await readPocketDeployment(serving(answer))).toBeNull(); + } + }); + + it('reads nothing past its bound, and aborts the read', async () => { + vi.useFakeTimers(); + let signal: AbortSignal | undefined; + const read = readPocketDeployment(async (_input, init) => { + signal = init?.signal ?? undefined; + return new Promise(() => {}); + }); + await vi.advanceTimersByTimeAsync(POCKET_DEPLOYMENT_READ_TIMEOUT_MS); + expect(await read).toBeNull(); + expect(signal?.aborted).toBe(true); + }); +}); + +describe('Pocket’s deployment source', () => { + const HOST = 'relay.dormouse.sh'; + + /** A fetch answering each read with the next of `answers`, counting reads. */ + function answering(answers: Array<() => Response | Promise>) { + const reads = { count: 0 }; + const fetch = serving(() => { + reads.count += 1; + return answers.shift()!(); + }); + return { fetch, reads }; + } + + it('caches a self-host shell at once, and reads it once', async () => { + const { fetch, reads } = answering([() => new Response('')]); + const source = pocketDeploymentSource(fetch, HOST); + expect(source.known).toBeNull(); + expect(await source.require()).toBe('self-host'); + expect(source.known).toBe('self-host'); + expect(await source.require()).toBe('self-host'); + expect(reads.count).toBe(1); + }); + + it('caches nothing from a failed read, and reads again on the next require', async () => { + const { fetch, reads } = answering([ + () => Promise.reject(new TypeError('offline')), + () => new Response(JSON.stringify(HOSTED_POCKET_DEPLOYMENT)), + ]); + const source = pocketDeploymentSource(fetch, HOST); + await expect(source.require()).rejects.toThrow(deploymentUnreachableMessage(HOST)); + expect(source.known).toBeNull(); + expect(await source.require()).toBe('hosted'); + expect(reads.count).toBe(2); + }); + + it('caches nothing from a timed-out read', async () => { + vi.useFakeTimers(); + const { fetch, reads } = answering([ + () => new Promise(() => {}), + () => new Response(JSON.stringify(HOSTED_POCKET_DEPLOYMENT)), + ]); + const source = pocketDeploymentSource(fetch, HOST); + const first = source.require(); + const failed = expect(first).rejects.toThrow(deploymentUnreachableMessage(HOST)); + await vi.advanceTimersByTimeAsync(POCKET_DEPLOYMENT_READ_TIMEOUT_MS); + await failed; + expect(source.known).toBeNull(); + expect(await source.require()).toBe('hosted'); + expect(reads.count).toBe(2); + }); + + it('shares one read between concurrent requires', async () => { + const { fetch, reads } = answering([() => new Response(JSON.stringify(HOSTED_POCKET_DEPLOYMENT))]); + const source = pocketDeploymentSource(fetch, HOST); + expect(await Promise.all([source.require(), source.require()])).toEqual(['hosted', 'hosted']); + expect(reads.count).toBe(1); + }); +}); + +describe('Pocket’s direct-peer factory', () => { + it('builds no peer before the deployment is known, then the one it names', async () => { + const built: unknown[] = []; + vi.stubGlobal( + 'RTCPeerConnection', + class { + constructor(config: unknown) { + built.push(config); + } + }, + ); + let answer!: (response: Response) => void; + const hosted = pocketDeploymentSource( + serving(() => new Promise((resolve) => (answer = resolve))), + 'relay.dormouse.sh', + ); + const factory = deploymentDirectPeer(hosted); + const read = hosted.require(); + // An unresolved read never gathers through no ICE server. + expect(factory()).toBeNull(); + answer(new Response(JSON.stringify(HOSTED_POCKET_DEPLOYMENT))); + await read; + factory(); + + const selfHost = pocketDeploymentSource(serving(() => new Response('Not Found', { status: 404 })), 'relay.example'); + await selfHost.require(); + deploymentDirectPeer(selfHost)(); + expect(built).toEqual([{ iceServers: [{ urls: CLOUDFLARE_STUN_URL }] }, { iceServers: [] }]); + }); +}); diff --git a/lib/src/remote/pocket-app/deployment.ts b/lib/src/remote/pocket-app/deployment.ts new file mode 100644 index 000000000..d72b003d0 --- /dev/null +++ b/lib/src/remote/pocket-app/deployment.ts @@ -0,0 +1,127 @@ +/** + * Who serves this Pocket, which decides the ICE servers its direct path gathers + * through (`docs/specs/remote-network.md` -> "Anywhere"): one bundle, served by + * Hosted and by every self-host Relay. **Hosted's relay staging writes + * `POCKET_DEPLOYMENT_FILE`** (`remote-lib-common`), and nothing else does — a self-host Relay + * answers that path with its shell or a 404 — so no policy crosses the wire and + * any other complete answer reads as self-host, which gathers through no ICE + * server. A read that does not complete answers nothing, so Hosted's Pocket + * never gathers through no ICE server because its own origin was slow. + */ + +import { HOSTED_POCKET_DEPLOYMENT, POCKET_DEPLOYMENT_FILE } from 'remote-lib-common'; + +import type { DirectPeerFactory } from '../direct/direct-peer'; +import { hostedDirectPeer, selfHostDirectPeer } from '../client/browser-direct-peer'; +import { isRecord } from '../../lib/is-record'; + +/** Where this page reads the file Hosted stages beside Pocket: its own origin's root. */ +export const POCKET_DEPLOYMENT_PATH = `/${POCKET_DEPLOYMENT_FILE}`; + +/** How long a Connect or a pairing waits on the deployment read before it fails, retryably. */ +export const POCKET_DEPLOYMENT_READ_TIMEOUT_MS = 3_000; + +export type PocketDeployment = 'hosted' | 'self-host'; + +/** The sentence a Connect or a pairing fails with when the deployment read does not complete. */ +export function deploymentUnreachableMessage(host: string): string { + return `Couldn’t reach ${host} to start the connection. Try again.`; +} + +/** `hosted` for exactly `HOSTED_POCKET_DEPLOYMENT`'s shape, else `self-host`. */ +export function parsePocketDeployment(body: unknown): PocketDeployment { + return isRecord(body) && body.deployment === HOSTED_POCKET_DEPLOYMENT.deployment ? 'hosted' : 'self-host'; +} + +/** + * Who serves this page, read off its own origin within `timeoutMs`. **Null + * when the read does not complete** — a network failure, the timeout, a 5xx, or + * a body lost mid-read — since none says who serves it; every complete answer + * is definite. Never rejects. + */ +export async function readPocketDeployment( + fetch: typeof globalThis.fetch, + timeoutMs = POCKET_DEPLOYMENT_READ_TIMEOUT_MS, +): Promise { + const abort = new AbortController(); + let timer: ReturnType | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => { + abort.abort(); + resolve(null); + }, timeoutMs); + }); + const read = (async (): Promise => { + try { + const response = await fetch(POCKET_DEPLOYMENT_PATH, { cache: 'no-store', signal: abort.signal }); + if (response.status >= 500) return null; + const text = await response.text(); + if (!response.ok) return 'self-host'; + try { + return parsePocketDeployment(JSON.parse(text)); + } catch { + return 'self-host'; + } + } catch { + return null; + } + })(); + try { + return await Promise.race([read, timedOut]); + } finally { + clearTimeout(timer); + } +} + +/** This page's deployment, cached once a read answers definitely. */ +export interface PocketDeploymentSource { + /** The definite answer, or null until one arrives. */ + readonly known: PocketDeployment | null; + /** + * The definite answer, reading it if none is cached; concurrent callers + * share one read. **Rejects with {@link deploymentUnreachableMessage}** when + * the read does not complete, caching nothing, so the next call reads again. + */ + require(): Promise; +} + +export function pocketDeploymentSource( + fetch: typeof globalThis.fetch, + host: string, + timeoutMs = POCKET_DEPLOYMENT_READ_TIMEOUT_MS, +): PocketDeploymentSource { + let known: PocketDeployment | null = null; + let inFlight: Promise | null = null; + return { + get known() { + return known; + }, + require() { + if (known !== null) return Promise.resolve(known); + inFlight ??= readPocketDeployment(fetch, timeoutMs) + .then((which) => { + if (which === null) throw new Error(deploymentUnreachableMessage(host)); + known = which; + return which; + }) + .finally(() => { + inFlight = null; + }); + return inFlight; + }, + }; +} + +/** + * Pocket's direct-peer factory: the one `source`'s answer names. **Builds no + * peer before the answer is known**; App awaits + * {@link PocketDeploymentSource.require} before every Connect and pairing, so + * the null here is a guard. + */ +export function deploymentDirectPeer(source: Pick): DirectPeerFactory { + return (pathPolicy) => { + const which = source.known; + if (which === null) return null; + return which === 'hosted' ? hostedDirectPeer(pathPolicy) : selfHostDirectPeer(pathPolicy); + }; +} diff --git a/lib/src/stories/BurrowsView.stories.tsx b/lib/src/stories/BurrowsView.stories.tsx index f3561209b..ad7623fb2 100644 --- a/lib/src/stories/BurrowsView.stories.tsx +++ b/lib/src/stories/BurrowsView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { within } from 'storybook/test'; // Importing from App.tsx runs `pocket-chrome`'s `index.css` side-effect import, // so Tailwind's utilities load for these stories. Storybook manages the theme // tokens (`--vscode-*`) itself. @@ -75,6 +76,23 @@ export const MixedListKimbieDark: Story = { globals: { theme: 'Kimbie Dark' }, }; +// A computer removed from the account: the Relay's list no longer names it, so +// its row says so and offers Forget alone, beside one merely offline. +export const RemovedFromAccount: Story = { + args: { + deployment: 'hosted', + burrows: [ + { burrowId: 'burrow-studio', label: 'Studio iMac', online: false, needsPairing: false, removed: true }, + { burrowId: 'burrow-nas', label: 'Basement NAS', online: false, needsPairing: false }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Removed from your account'); + await canvas.findByRole('button', { name: 'Forget' }); + }, +}; + // Small-phone stress case: paired+offline, burrow-id fallback, and long labels. export const NarrowLongLabels: Story = { args: { burrows: STRESS_BURROWS }, diff --git a/lib/src/stories/NetworkSettings.stories.tsx b/lib/src/stories/NetworkSettings.stories.tsx index dd206281f..97acbf4b5 100644 --- a/lib/src/stories/NetworkSettings.stories.tsx +++ b/lib/src/stories/NetworkSettings.stories.tsx @@ -11,6 +11,7 @@ import { enrolledStatus, } from '../host/remote/test-burrow-link'; import { CLOUDFLARE_STUN_HOST } from '../remote/direct/ice-servers'; +import type { PathRefusal } from '../remote/direct/path-refusal'; import { networkPolicyResult, nothingPolicy, @@ -55,10 +56,13 @@ const INTERFACES = [WIFI, TAILSCALE, DOCKER]; const DAY = 86_400_000; /** A Hosted build holding `policy`, on a laptop with Wi-Fi, a tailnet, and Docker. */ -function hosted(policy: NetworkPolicy, status = UNENROLLED_STATUS) { - return { status, network: networkPolicyResult(policy, 'hosted', INTERFACES) }; +function hosted(policy: NetworkPolicy, status = UNENROLLED_STATUS, refusal: PathRefusal | null = null) { + return { status, network: networkPolicyResult(policy, 'hosted', INTERFACES, refusal) }; } +/** 10:42 this morning, on whatever clock renders the story. */ +const AT_10_42 = new Date(2026, 9, 1, 10, 42).getTime(); + /** A self-host build holding `policy`. */ function selfHost(policy: NetworkPolicy, status = SELF_HOST_UNENROLLED_STATUS) { return { status, network: networkPolicyResult(policy, 'self-host', INTERFACES) }; @@ -163,6 +167,81 @@ export const LocalNetworksTypedRange: Story = { }, }; +/** + * A phone on cellular reached the code and no further: the Burrow saw its + * address on the selected pair, and says so above the networks it could join. + * Dismiss forgets it. + */ +export const LocalNetworksRefusedObserved: Story = { + parameters: { + primedBurrow: hosted( + { level: 'local', allowed: WIFI.prefixes, autoUpdate: false }, + UNENROLLED_STATUS, + { at: AT_10_42, kind: 'path-refused', end: 'remote', address: '172.58.12.9', addressSource: 'observed' }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = await settled(canvasElement); + const notice = await canvas.findByRole('status', { name: 'Last refused phone' }); + await expect(notice).toHaveTextContent(/a phone tried to connect from 172\.58\.12\.9, which isn’t on a network allowed below\./); + await userEvent.click(within(notice).getByRole('button', { name: 'Dismiss' })); + await waitFor(() => expect(canvas.queryByRole('status', { name: 'Last refused phone' })).toBeNull()); + }, +}; + +/** No pair formed, so the address is the one the phone's offer reported, named as its claim and never as off the networks. */ +export const LocalNetworksRefusedReported: Story = { + parameters: { + primedBurrow: hosted( + { level: 'local', allowed: WIFI.prefixes, autoUpdate: false }, + UNENROLLED_STATUS, + { at: AT_10_42, kind: 'given-up', end: 'remote', address: '2607:fb90:1:2::9', addressSource: 'reported' }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = await settled(canvasElement); + await expect(await canvas.findByRole('status', { name: 'Last refused phone' })).toHaveTextContent( + /a phone couldn’t connect directly over an allowed network \(it reported 2607:fb90:1:2::9\)\./, + ); + }, +}; + +/** This computer's own end was off the allowed networks: the panel says so, and blames no phone's network. */ +export const LocalNetworksRefusedThisComputer: Story = { + parameters: { + primedBurrow: hosted( + { level: 'local', allowed: WIFI.prefixes, autoUpdate: false }, + UNENROLLED_STATUS, + { at: AT_10_42, kind: 'path-refused', end: 'local', localAddress: '10.0.0.2' }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = await settled(canvasElement); + const notice = await canvas.findByRole('status', { name: 'Last refused phone' }); + await expect(notice).toHaveTextContent( + /a phone couldn’t connect: this computer wasn’t on a network allowed below \(its address was 10\.0\.0\.2\)\./, + ); + await expect(notice).not.toHaveTextContent(/tried to connect from/); + }, +}; + +/** Nothing to name: the phone offered no public address and no pair formed. */ +export const LocalNetworksRefusedNoAddress: Story = { + parameters: { + primedBurrow: hosted( + { level: 'local', allowed: WIFI.prefixes, autoUpdate: false }, + UNENROLLED_STATUS, + { at: AT_10_42, kind: 'deadline' }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = await settled(canvasElement); + await expect(await canvas.findByRole('status', { name: 'Last refused phone' })).toHaveTextContent( + /a phone couldn’t reach this computer over an allowed network\./, + ); + }, +}; + /** Every network switched off: say so, and list no connection that cannot happen. */ export const LocalNetworksNoneAllowed: Story = { parameters: { primedBurrow: hosted({ level: 'local', allowed: [], autoUpdate: false }) }, @@ -189,6 +268,35 @@ export const Anywhere: Story = { }, }; +/** A Hosted build, enrolled, with one phone paired. */ +const HOSTED_ENROLLED = { ...UNENROLLED_STATUS, enrolled: true, serving: true, burrowId: 'burrow-6f1c2a90', connection: 'connected', pairedClients: 1 } as const; + +/** + * Enrolled under Local networks: Hosted always, but never terminal traffic — + * a paired phone connects only directly, on an allowed network. + */ +export const LocalNetworksEnrolled: Story = { + parameters: { primedBurrow: hosted({ level: 'local', allowed: WIFI.prefixes, autoUpdate: false }, HOSTED_ENROLLED) }, + play: async ({ canvasElement }) => { + const canvas = await settled(canvasElement); + await canvas.findByText(/Never terminal traffic\./); + await canvas.findByText('Your phone, directly, on an allowed network'); + await canvas.findByText('relay.dormouse.sh → your phone’s push service'); + await expect(canvas.queryByText(/Only while a one-time link is open\./)).toBeNull(); + }, +}; + +/** Enrolled under Anywhere: a phone that can't connect directly relays through Hosted. */ +export const AnywhereEnrolled: Story = { + parameters: { primedBurrow: hosted(ANYWHERE_ON, HOSTED_ENROLLED) }, + play: async ({ canvasElement }) => { + const canvas = await settled(canvasElement); + await canvas.findByText(/terminal traffic when a phone can’t connect directly\./); + await canvas.findByText(CLOUDFLARE_STUN_HOST); + await canvas.findByText('Your phone, directly'); + }, +}; + /** Walks the choices and checks that the connection list follows. */ export const SwitchingLevels: Story = { parameters: { primedBurrow: hosted(nothingPolicy()) }, diff --git a/lib/src/stories/RemoteControlSection.stories.tsx b/lib/src/stories/RemoteControlSection.stories.tsx index 57cc46160..f4c53e3ab 100644 --- a/lib/src/stories/RemoteControlSection.stories.tsx +++ b/lib/src/stories/RemoteControlSection.stories.tsx @@ -10,6 +10,8 @@ import { SELF_HOST_UNENROLLED_STATUS, UNENROLLED_STATUS, } from '../host/remote/test-burrow-link'; +import { DEFAULT_RELAY_ORIGIN } from '../host/relay-origin'; +import type { HostedEnrollmentState } from '../host/remote/service-protocol'; import { networkPolicyResult } from '../remote/network-policy'; import { TEST_SETUP_PASSWORD } from '../remote/test-setup-password'; @@ -101,19 +103,120 @@ export const Choices: Story = { }; /** - * A stock build, Persistent Relay unfolded: its one Relay is Hosted's, coming - * soon, so there is nothing to enroll — a Relay you run takes a build made for - * its origin (`docs/specs/relay.md` → "Relay origin"). + * A stock build, Persistent Relay unfolded: its one Relay is Hosted's, which + * it enrolls with by a code approved at the account (`docs/specs/hosted.md` → + * "Burrow enrollment") — the name to keep, and the button that gets one. */ export const HostedPersistentRelay: Story = { parameters: { primedBurrow: { status: UNENROLLED_STATUS }, - docs: { story: { height: '420px' } }, + docs: { story: { height: '470px' } }, }, play: async ({ canvasElement }) => { await openPersistent(canvasElement); - await within(canvasElement).findByRole('button', { name: 'Use hosted.dormouse.sh' }); + await within(canvasElement).findByRole('button', { name: 'Enroll with hosted.dormouse.sh' }); + }, +}; + +/** A Hosted enrollment waiting at the account, as `status` reports it. */ +function hostedWaiting(accountFull = false): HostedEnrollmentState { + return { + status: 'waiting', + userCode: '7KQM-X4TD', + verificationUrl: 'https://hosted.dormouse.sh/enroll#7KQM-X4TD', + expiresAt: STORY_NOW + 10 * 60_000, + accountFull, + }; +} + +/** + * The code waiting for approval, in large type: the account page shows the + * same one beside Approve. Open opens the page the service composed; the + * service polls, so nothing here waits on a click but the person's. + */ +export const HostedEnrollWaiting: Story = { + parameters: { + primedBurrow: { status: { ...UNENROLLED_STATUS, hostedEnrollment: hostedWaiting() } }, + docs: { story: { height: '500px' } }, + }, + play: settled('7KQM-X4TD'), +}; + +/** + * Approved by an account that already has as many computers as it may enroll: + * the Relay keeps the approval, so the service polls on and this computer + * enrolls once one is removed. + */ +export const HostedEnrollAccountFull: Story = { + parameters: { + primedBurrow: { status: { ...UNENROLLED_STATUS, hostedEnrollment: hostedWaiting(true) } }, + docs: { story: { height: '540px' } }, + }, + play: settled(/already has as many computers/), +}; + +/** Redeemed, and then refused here — the service's own sentence under the fixed one. */ +export const HostedEnrollFailed: Story = { + parameters: { + primedBurrow: { + status: { + ...UNENROLLED_STATUS, + hostedEnrollment: { + status: 'ended', + reason: 'failed', + message: + 'keychain is locked Your account holds Burrow T7lzkkrPT8nx4m9zf90V4h, which this computer could ' + + 'not keep; remove it at https://hosted.dormouse.sh/account.', + }, + }, + }, + docs: { story: { height: '560px' } }, + }, + play: settled(/keychain is locked/), +}; + +/** Approved, and the enrollment being saved and started: no code, nothing to cancel. */ +export const HostedEnrollRedeeming: Story = { + parameters: { + primedBurrow: { status: { ...UNENROLLED_STATUS, hostedEnrollment: { status: 'redeeming' } } }, + docs: { story: { height: '420px' } }, + }, + play: settled('Approved. Enrolling this computer…'), +}; + +/** + * The Relay says an earlier poll redeemed the code, whose answer never + * arrived: the Burrow it names is the account's to remove. + */ +export const HostedEnrollAnswerLost: Story = { + parameters: { + primedBurrow: { + status: { + ...UNENROLLED_STATUS, + hostedEnrollment: { status: 'ended', reason: 'answer-lost', burrowId: 'T7lzkkrPT8nx4m9zf90V4h' }, + }, + }, + docs: { story: { height: '560px' } }, + }, + play: settled('Manage computers at hosted.dormouse.sh'), +}; + +/** + * Enrolled with Hosted: the self-host view, with the account that manages + * this computer a link away. Held without a socket under Local networks. + */ +export const HostedEnrolled: Story = { + parameters: { + primedBurrow: { + status: enrolledStatus({ + relayOrigin: DEFAULT_RELAY_ORIGIN, + relayMode: 'hosted', + accountOrigin: 'https://hosted.dormouse.sh', + connection: 'stopped', + }), + }, }, + play: settled('Manage computers at hosted.dormouse.sh'), }; /** @@ -219,7 +322,7 @@ export const ConnectedManyDevices: Story = { }; /** - * The only connection state with a button. `displaced` is terminal by design — + * A latched state, so it gets a button. `displaced` is terminal by design — * another instance took the relay slot and this one stood down — so nothing * brings it back on its own. */ @@ -231,6 +334,52 @@ export const Displaced: Story = { play: settled(/Another Dormouse instance took/), }; +/** The Hosted status a removed or de-entitled Burrow reports. */ +const HOSTED_ENROLLED = { + relayOrigin: DEFAULT_RELAY_ORIGIN, + relayMode: 'hosted', + accountOrigin: 'https://hosted.dormouse.sh', + pairedClients: 1, +} as const; + +/** + * Removed from the account page: the Relay closed the socket 4001, and this + * machine stood down. Enroll again clears the dead enrollment and begins a + * new code. + */ +export const RemovedHosted: Story = { + parameters: { + primedBurrow: { status: enrolledStatus({ ...HOSTED_ENROLLED, connection: 'removed' }) }, + docs: { story: { height: '450px' } }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('This computer was removed from your account at hosted.dormouse.sh.'); + await canvas.findByRole('button', { name: 'Enroll again' }); + }, +}; + +/** A self-host Burrow its operator removed: Disconnect, then enroll again from the form. */ +export const RemovedSelfHost: Story = { + parameters: { + primedBurrow: { status: enrolledStatus({ connection: 'removed', pairedClients: 1 }) }, + }, + play: settled(/This computer was removed from .* Disconnect to enroll it again\./), +}; + +/** The account lost its plan: the Relay closed the socket 4002. Reconnect tries again. */ +export const NotEntitled: Story = { + parameters: { + primedBurrow: { status: enrolledStatus({ ...HOSTED_ENROLLED, connection: 'not-entitled' }) }, + docs: { story: { height: '450px' } }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Your Hosted plan doesn’t include remote control right now.'); + await canvas.findByRole('button', { name: 'Reconnect' }); + }, +}; + /** Disconnect asks first: it drops every paired phone until each pairs again. */ export const ConfirmingDisconnect: Story = { parameters: { primedBurrow: { status: enrolledStatus({ pairedClients: 2 }) } }, @@ -500,6 +649,27 @@ export const OneTimeEndedDirectFailedAnywhere: Story = { play: settled(/such as cellular/), }; +/** + * Local networks ended it for the path: the sentence names the address the + * Burrow saw, in Settings → Network's words. + */ +export const OneTimeEndedNetworkNotAllowed: Story = { + parameters: { + primedBurrow: { + status: UNENROLLED_STATUS, + oneTime: { + status: 'ended', + reason: 'network-not-allowed', + refusal: { at: Date.now(), kind: 'path-refused', end: 'remote', address: '172.58.12.9', addressSource: 'observed' }, + }, + }, + docs: { story: { height: '360px' } }, + }, + play: settled( + 'The phone tried to connect from 172.58.12.9, which isn’t on one of your allowed networks, so the connection ended.', + ), +}; + /** The one attempt was spent on digits the phone was not showing. */ export const OneTimeEndedMismatch: Story = { parameters: { diff --git a/relay/test/static.test.mjs b/relay/test/static.test.mjs index b2984f268..7aab0bde5 100644 --- a/relay/test/static.test.mjs +++ b/relay/test/static.test.mjs @@ -51,6 +51,15 @@ test('SPA fallback returns index.html for an unknown non-file path', async () => assert.match(await res.text(), /pocket-root/); }); +test('the deployment file Hosted stages is the shell here, so Pocket reads self-host', async () => { + // `lib/src/remote/pocket-app/deployment.ts`: only Hosted's staging writes + // `/deployment.json`; a self-host Relay answers it as any unknown path. + const { app: hono } = app({ pocketDir: await makePocketDir() }); + const res = await hono.request('/deployment.json'); + assert.match(res.headers.get('content-type') ?? '', /text\/html/); + assert.match(await res.text(), /pocket-root/); +}); + test('API routes still win over static serving', async () => { const { app: hono } = app({ pocketDir: await makePocketDir() }); // No bearer token → the session-gated API route answers, not the static app. diff --git a/remote-lib-common/src/index.ts b/remote-lib-common/src/index.ts index ccaeb7d22..d05692e04 100644 --- a/remote-lib-common/src/index.ts +++ b/remote-lib-common/src/index.ts @@ -15,6 +15,7 @@ export * from './remote/wire.js'; export * from './remote/one-time-wire.js'; +export * from './remote/pocket-deployment.js'; export * from './remote/enroll-offer.js'; export * from './remote/origin.js'; export * from './remote/enroll-code.js'; diff --git a/remote-lib-common/src/remote/one-time-wire.ts b/remote-lib-common/src/remote/one-time-wire.ts index 274fdbf04..da5dcedc4 100644 --- a/remote-lib-common/src/remote/one-time-wire.ts +++ b/remote-lib-common/src/remote/one-time-wire.ts @@ -151,16 +151,10 @@ export const ONE_TIME_LINK_TTL_MS = DEFAULT_PAIRING_TTL_MS; /** * How long past the link's expiry a joined room may run. The room's hard * deadline is `expiresAt + ONE_TIME_EXPIRY_GRACE_MS`: the join and the - * confirmation finish by the expiry, and the grace holds the direct deadline of - * a confirmation made at the link's last second. + * confirmation finish by the expiry, and the grace holds the direct deadline + * (`DIRECT_ONLY_DEADLINE_MS`) of a confirmation made at the link's last second. */ -export const ONE_TIME_EXPIRY_GRACE_MS = 30_000; - -/** - * How long after a confirmed outcome the direct path has to carry both - * directions before the Burrow ends the session. - */ -export const ONE_TIME_DIRECT_DEADLINE_MS = 15_000; +export const ONE_TIME_EXPIRY_GRACE_MS = 45_000; // --------------------------------------------------------------------------- // Close codes, in the 4000-4999 application-private range beside diff --git a/remote-lib-common/src/remote/pocket-deployment.ts b/remote-lib-common/src/remote/pocket-deployment.ts new file mode 100644 index 000000000..0621589eb --- /dev/null +++ b/remote-lib-common/src/remote/pocket-deployment.ts @@ -0,0 +1,16 @@ +/** + * How one Pocket bundle learns that Hosted serves it + * (`docs/specs/remote-network.md` -> "Anywhere"): Hosted's relay staging + * (`hosted/scripts/stage-relay.mjs`) writes {@link HOSTED_POCKET_DEPLOYMENT} as + * JSON to {@link POCKET_DEPLOYMENT_FILE} beside it, and Pocket + * (`lib/src/remote/pocket-app/deployment.ts`) reads it back. **Never in a + * Pocket build**, which a self-host Relay serves as it is. + * + * Imports nothing, so the staging script loads this file as source. + */ + +/** The file beside Pocket's shell, at the root of the origin that serves it. */ +export const POCKET_DEPLOYMENT_FILE = 'deployment.json'; + +/** What {@link POCKET_DEPLOYMENT_FILE} holds where Hosted serves Pocket. */ +export const HOSTED_POCKET_DEPLOYMENT = { deployment: 'hosted' } as const; diff --git a/remote-lib-common/src/remote/wire.ts b/remote-lib-common/src/remote/wire.ts index ca591fbb4..dc20252b3 100644 --- a/remote-lib-common/src/remote/wire.ts +++ b/remote-lib-common/src/remote/wire.ts @@ -12,6 +12,7 @@ import { isExactBase64Url, } from '../security/bytes.js'; import { NOISE_MAX_MESSAGE_LENGTH } from '../security/noise.js'; +import { isEnrollUserCode } from './enroll-code.js'; import type { PasskeyAssertion } from '../security/passkey.js'; import type { PresenceBinding } from '../security/presence.js'; import type { SealedPushV1 } from '../security/push-seal.js'; @@ -73,7 +74,9 @@ export function pushSubscriptionDeletePath(deliveryId: string): string { * unknown or expired. Shared because Pocket keys recovery on it: a 401 alone is * ambiguous (a spent setup token answers 401 too), and only this one means * "sign in again". Changing the string on one side without the other would - * silently strand users on a dead session. + * silently strand users on a dead session. A Burrow-gated route answers it + * too, for a burrow token that names no Burrow, and a Burrow's standing probe + * reads it as removal (`docs/specs/relay.md` -> "Burrow side"). */ export const UNAUTHORIZED_ERROR = 'unauthorized'; @@ -175,18 +178,27 @@ export const WS_CLOSE_BURROW_REPLACED = 4000; export const WS_CLOSE_BURROW_REPLACED_REASON = 'replaced by a newer burrow connection'; /** - * The Burrow's `burrows.json` row is gone, so its bearer token names nothing. - * - * A distinct code from {@link WS_CLOSE_BURROW_REPLACED} because the two mean - * opposite things to a reconnect: a replaced Burrow must stand down, while a - * revoked one may retry as often as it likes — the upgrade will simply 401, - * which is the whole of what revocation is. + * The Burrow's row is gone — removed from the account, or deleted from + * `burrows.json` — so its bearer token names nothing. Terminal at the Burrow, + * which reports `removed` rather than retrying an upgrade that can only 401 + * (`docs/specs/relay.md` -> "Burrow side"). */ export const WS_CLOSE_BURROW_REVOKED = 4001; /** Human-readable reason paired with {@link WS_CLOSE_BURROW_REVOKED}. */ export const WS_CLOSE_BURROW_REVOKED_REASON = 'this burrow is no longer enrolled'; +/** + * The Burrow is still enrolled, but its owner is no longer entitled to the + * Hosted Relay. Only Hosted sends it; terminal at the Burrow, which reports + * `not-entitled`. Distinct from {@link WS_CLOSE_BURROW_REVOKED} because the + * fix differs: a plan, not a re-enrollment. + */ +export const WS_CLOSE_BURROW_NOT_ENTITLED = 4002; + +/** Human-readable reason paired with {@link WS_CLOSE_BURROW_NOT_ENTITLED}. */ +export const WS_CLOSE_BURROW_NOT_ENTITLED_REASON = 'this account is not entitled to the Hosted Relay'; + /** The selfhost mode has exactly one account. */ export const SELFHOST_ACCOUNT_ID = 'owner'; @@ -384,19 +396,77 @@ export interface BurrowEnrollBeginResponse { interval: number; } +/** The shortest poll interval a Burrow accepts, in seconds. */ +export const MIN_ENROLL_POLL_INTERVAL_S = 1; +/** The longest, which also caps a Burrow slowing down after a 429. */ +export const MAX_ENROLL_POLL_INTERVAL_S = 60; +/** The longest `verificationUrl` a Burrow reads; Hosted's is under fifty characters. */ +const MAX_ENROLL_VERIFICATION_URL_LENGTH = 512; + +/** + * Structural validation of a {@link BurrowEnrollBeginResponse}, beside the type + * so a field added here cannot be silently accepted by the Burrow that reads + * one. The device code is a bearer, the user code goes on screen in large + * type, and `interval` and `expiresAt` go straight into timers, so each is + * held to its shape and bounds: an integer `interval` of + * {@link MIN_ENROLL_POLL_INTERVAL_S}–{@link MAX_ENROLL_POLL_INTERVAL_S} seconds, + * a finite positive `expiresAt`. `verificationUrl` is only bounded: the Burrow + * composes the URL it opens (`docs/specs/hosted.md` -> "Burrow enrollment"). + */ +export function isBurrowEnrollBeginResponse(value: unknown): value is BurrowEnrollBeginResponse { + if (!value || typeof value !== 'object') return false; + const candidate = value as Record; + const { interval, expiresAt, verificationUrl } = candidate; + return ( + isRelayBearer(candidate.deviceCode) && + isEnrollUserCode(candidate.userCode) && + (verificationUrl === undefined || isBoundedString(verificationUrl, MAX_ENROLL_VERIFICATION_URL_LENGTH)) && + typeof expiresAt === 'number' && + Number.isFinite(expiresAt) && + expiresAt > 0 && + Number.isInteger(interval) && + (interval as number) >= MIN_ENROLL_POLL_INTERVAL_S && + (interval as number) <= MAX_ENROLL_POLL_INTERVAL_S + ); +} + export interface BurrowEnrollPollRequest { deviceCode: string; } /** * `expired` also answers an unknown device code. `enrolled` answers once: the - * redemption is single-use. + * redemption is single-use. `redeemed` answers every later poll of that code + * until the approval expires: an earlier poll enrolled `burrowId`, whose answer + * never reached this one, and which the account must remove. */ export type BurrowEnrollPollResponse = | { status: 'pending' } | { status: 'expired' } + | { status: 'redeemed'; burrowId: string } | { status: 'enrolled'; enrollment: BurrowEnrollResponse }; +/** + * A poll answer of a known status, `redeemed` naming a routing-id-shaped + * Burrow. `enrolled`'s enrollment is only an object here: the Burrow holds it + * to its own enrollment guard. + */ +export function isBurrowEnrollPollResponse(value: unknown): value is BurrowEnrollPollResponse { + if (!value || typeof value !== 'object') return false; + const answer = value as Record; + switch (answer.status) { + case 'pending': + case 'expired': + return true; + case 'redeemed': + return isE2eId(answer.burrowId); + case 'enrolled': + return !!answer.enrollment && typeof answer.enrollment === 'object'; + default: + return false; + } +} + /** * Burrow-token auth. The single-use setup credential an enrolled Burrow mints for * its pairing QR: the token only, since the Burrow composes the URL itself from diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index 4bf5fd79f..d0eb4514b 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -113,6 +113,17 @@ export const MAX_DIRECT_PENDING_FRAMES = 8192; */ export const DIRECT_HANDOFF_TIMEOUT_MS = DIRECT_SETUP_TIMEOUT_MS; +/** + * How long a direct-only session has, from its promotion, for the direct path + * to carry both directions before the Burrow ends it — a one-time connection, + * and a paired phone's under Local networks (`ConnectionOutcomeV1.directOnly`). + * **Never shorter than the phone can need**: it arms its own + * {@link DIRECT_SETUP_TIMEOUT_MS} only once the outcome reaches it, and its + * switch then crosses the relay, which {@link DIRECT_HANDOFF_TIMEOUT_MS} + * bounds. Both ends wait this long. + */ +export const DIRECT_ONLY_DEADLINE_MS = DIRECT_SETUP_TIMEOUT_MS + DIRECT_HANDOFF_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 @@ -279,6 +290,52 @@ export function isDirectSignalV1(value: unknown): value is DirectSignalV1 { } } +/** + * The longest address a path refusal names: an IPv6 literal with an IPv4 tail, + * every group spelled in full. + */ +export const MAX_PATH_ADDRESS_LENGTH = 45; + +/** + * Where the address a path refusal names came from + * (`docs/specs/remote-network.md` -> "Local networks"): `observed` is the + * remote end of the selected candidate pair, as the Burrow's own ICE agent + * reported it; `reported` is an address the phone put in its offer, which is + * a diagnostic and never path evidence. + */ +export const PATH_ADDRESS_SOURCES = Object.freeze(['observed', 'reported'] as const); +export type PathAddressSource = (typeof PATH_ADDRESS_SOURCES)[number]; + +const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'; +const IPV4_LITERAL = new RegExp(`^${IPV4_OCTET}(?:\\.${IPV4_OCTET}){3}$`); +const IPV6_GROUP = /^[0-9a-fA-F]{1,4}$/; + +/** + * Whether `value` is one IP literal, at most {@link MAX_PATH_ADDRESS_LENGTH} + * characters: dotted-quad IPv4, or IPv6 with at most one `::` and an optional + * IPv4 tail. Never a hostname, an mDNS name, a zone, or a port, so a string + * that passes is safe to show as it is. + */ +export function isIpLiteral(value: unknown): value is string { + if (!isBoundedString(value, MAX_PATH_ADDRESS_LENGTH) || value.length === 0) return false; + if (IPV4_LITERAL.test(value)) return true; + const halves = value.split('::'); + if (halves.length > 2) return false; + const head = halves[0] === '' ? [] : halves[0]!.split(':'); + const tail = halves.length === 2 && halves[1] !== '' ? halves[1]!.split(':') : []; + const groups = [...head, ...tail]; + // An IPv4 tail ends the literal; one before a trailing `::` does not. + const tailAllowed = halves.length === 1 || tail.length > 0; + let count = 0; + for (let i = 0; i < groups.length; i += 1) { + const group = groups[i]!; + if (IPV6_GROUP.test(group)) count += 1; + else if (tailAllowed && i === groups.length - 1 && IPV4_LITERAL.test(group)) count += 2; + else return false; + } + return halves.length === 2 ? count <= 7 : count === 8; +} + /** What a relay transport frame may do once this end has read the peer's switch. */ export type DirectRelayOutcome = 'process' | 'violation'; diff --git a/remote-lib-common/src/security/e2e-ceremony.ts b/remote-lib-common/src/security/e2e-ceremony.ts index dfa118b05..061ffa86e 100644 --- a/remote-lib-common/src/security/e2e-ceremony.ts +++ b/remote-lib-common/src/security/e2e-ceremony.ts @@ -11,6 +11,7 @@ */ import { isBoundedString } from './bytes.js'; +import { PATH_ADDRESS_SOURCES, isIpLiteral, type PathAddressSource } from './direct-path.js'; import { hashPasskeyPublicKey, verifyPasskeyAssertion, @@ -292,16 +293,28 @@ const CONNECTION_DENIALS = [ export type ConnectionDenialCode = (typeof CONNECTION_DENIALS)[number]; -/** The single Burrow→Client control message that ends a connection attempt. */ +/** + * The single Burrow→Client control message that ends a connection attempt. + * `directOnly`, present only as `true`, says the Burrow ends this session + * unless the direct path carries it: no application message may cross the + * relay, and the switch has `DIRECT_ONLY_DEADLINE_MS` + * (`docs/specs/remote-network.md` -> "Local networks"). Inside the Noise + * session, so no Relay can add or strip it; a Client that ignores it is ended + * at its first relayed request. + */ export type ConnectionOutcomeV1 = - | { readonly ok: true; readonly burrowLabel: string } + | { readonly ok: true; readonly burrowLabel: string; readonly directOnly?: true } | { readonly ok: false; readonly code: ConnectionDenialCode }; export function isConnectionOutcomeV1(value: unknown): value is ConnectionOutcomeV1 { if (!value || typeof value !== 'object') return false; const outcome = value as Record; if (outcome.ok === false) return includesCode(CONNECTION_DENIALS, outcome.code); - return outcome.ok === true && bounded(outcome.burrowLabel); + return ( + outcome.ok === true && + bounded(outcome.burrowLabel) && + (outcome.directOnly === undefined || outcome.directOnly === true) + ); } // --------------------------------------------------------------------------- @@ -390,22 +403,38 @@ export function isOneTimeOutcomeV1(value: unknown): value is OneTimeOutcomeV1 { /** * The Burrow's goodbye: it is ending this established session on purpose, so * the Client reports the session over rather than waiting on requests nothing - * will answer (`docs/specs/remote-api.md` → Transport). **Exact keys and no - * payload**, like the direct path's signals, so nothing can ride on it; a - * Client that does not know it ignores it, as it does every unknown control - * shape. + * will answer (`docs/specs/remote-api.md` → Transport). **Exact keys**, like + * the direct path's signals, so nothing can ride on it: bare, or — for a + * direct-only session the path ended (`docs/specs/remote-network.md` -> "Local + * networks") — `reason: 'network-not-allowed'`, with the one IP literal the + * Burrow can name and where it came from, or none. A Client that does not know + * it ignores it, as it does every unknown control shape. */ -export interface SessionEndV1 { - readonly v: 1; - readonly t: 'session-end'; -} +export type SessionEndV1 = + | { readonly v: 1; readonly t: 'session-end' } + | { readonly v: 1; readonly t: 'session-end'; readonly reason: 'network-not-allowed' } + | { + readonly v: 1; + readonly t: 'session-end'; + readonly reason: 'network-not-allowed'; + /** An IP literal ({@link isIpLiteral}), never a name. */ + readonly address: string; + readonly addressSource: PathAddressSource; + }; export const SESSION_END_V1: SessionEndV1 = Object.freeze({ v: 1, t: 'session-end' }); export function isSessionEndV1(value: unknown): value is SessionEndV1 { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; const message = value as Record; - return message.v === 1 && message.t === 'session-end' && Object.keys(message).length === 2; + if (message.v !== 1 || message.t !== 'session-end') return false; + const keys = Object.keys(message).length; + if (keys === 2) return true; + if (message.reason !== 'network-not-allowed') return false; + if (keys === 3) return true; + return ( + keys === 5 && isIpLiteral(message.address) && includesCode(PATH_ADDRESS_SOURCES, message.addressSource) + ); } /** Membership in a denial list, without widening the list's literal type. */ diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index 5ad8ad708..f876a6f3b 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -19,6 +19,7 @@ import { DIRECT_DISCONNECTED_GRACE_MS, DIRECT_GATHER_TIMEOUT_MS, DIRECT_HANDOFF_TIMEOUT_MS, + DIRECT_ONLY_DEADLINE_MS, DIRECT_SETUP_TIMEOUT_MS, DIRECT_SRFLX_GRACE_MS, DirectCutover, @@ -26,14 +27,65 @@ import { MAX_DIRECT_PENDING_BYTES, MAX_DIRECT_PENDING_FRAMES, MAX_DIRECT_SDP_LENGTH, + MAX_PATH_ADDRESS_LENGTH, encodeTransportPlaintext, isDirectSdp, isDirectSignalV1, + isIpLiteral, 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'; +// --- Path addresses ---------------------------------------------------------- + +test('isIpLiteral takes one IP literal and never a name, a zone, or a port', () => { + for (const address of [ + '172.58.12.9', + '0.0.0.0', + '255.255.255.255', + '::', + '::1', + 'fe80::1', + '2607:fb90:1:2::9', + '2001:db8:0:0:0:0:0:1', + '::ffff:192.168.1.20', + '64:ff9b::192.0.2.33', + 'ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255', + ]) { + assert.equal(isIpLiteral(address), true, address); + } + assert.equal('ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255'.length, MAX_PATH_ADDRESS_LENGTH); + for (const value of [ + '', + '1.2.3', + '1.2.3.4.5', + '256.1.1.1', + '01.2.3.4', + '1.2.3.4:443', + 'phone.local', + '0b1c5f3a-1d2e-4c1b-9a1e-1234567890ab.local', + 'fe80::1%en0', + '1::2::3', + '1:2:3:4:5:6:7', + '1:2:3:4:5:6:7:8:9', + '1:2:3:4:5:6:7::8', + '12345::1', + ':1::2', + '1::2:', + '1.2.3.4::', + '1.2.3.4::1', + '::1.2.3.4:1', + '[::1]', + ' 1.2.3.4', + `${'0:'.repeat(20)}1`, + 42, + null, + ]) { + assert.equal(isIpLiteral(value), false, String(value)); + } +}); + // --- The signaling guard ---------------------------------------------------- test('accepts the four signals and nothing else', () => { @@ -125,6 +177,11 @@ test('the timings the spec names are the values that ship', () => { // 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); + // A direct-only session's deadline runs from the Burrow's outcome; the phone + // arms its own setup bound only once that outcome reaches it, and its switch + // then crosses the relay. A deadline any shorter beats a phone that is + // within both of its own bounds. + assert.equal(DIRECT_ONLY_DEADLINE_MS, DIRECT_SETUP_TIMEOUT_MS + DIRECT_HANDOFF_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); diff --git a/remote-lib-common/test/e2e-ceremony.test.mjs b/remote-lib-common/test/e2e-ceremony.test.mjs index 18f1e4411..33e7a5687 100644 --- a/remote-lib-common/test/e2e-ceremony.test.mjs +++ b/remote-lib-common/test/e2e-ceremony.test.mjs @@ -411,6 +411,7 @@ test('isConnectionRequestV1 is the presence proof and nothing else', async () => test('isConnectionOutcomeV1 takes a labelled success or one of five fixed denials', () => { assert.equal(isConnectionOutcomeV1({ ok: true, burrowLabel: 'Laptop' }), true); + assert.equal(isConnectionOutcomeV1({ ok: true, burrowLabel: 'Laptop', directOnly: true }), true); for (const code of ['pairing-required', 'presence-rejected', 'protocol-rejected', 'burrow-busy', 'burrow-error']) { assert.equal(isConnectionOutcomeV1({ ok: false, code }), true, code); } @@ -421,6 +422,9 @@ test('isConnectionOutcomeV1 takes a labelled success or one of five fixed denial // failed is owner-local, so there is no wire spelling for it. ['an ACL miss as a denial code', { ok: false, code: 'client-not-paired' }], ['a denial code from the other ceremony', { ok: false, code: 'user-denied' }], + // Present only as `true`: a Client must never read a falsy spelling as one. + ['a directOnly that is not true', { ok: true, burrowLabel: 'Laptop', directOnly: false }], + ['a directOnly that is a string', { ok: true, burrowLabel: 'Laptop', directOnly: 'yes' }], ]) { assert.equal(isConnectionOutcomeV1(value), false, why); } @@ -490,20 +494,43 @@ test('isOneTimeOutcomeV1 takes a labelled success or one of four fixed denials', } }); -test('isSessionEndV1 is the exact two-key goodbye and nothing else', () => { +test('isSessionEndV1 is the goodbye in its three exact shapes and nothing else', () => { assert.equal(isSessionEndV1(SESSION_END_V1), true); assert.equal(isSessionEndV1({ v: 1, t: 'session-end' }), true); + assert.equal(isSessionEndV1({ v: 1, t: 'session-end', reason: 'network-not-allowed' }), true); + for (const [address, addressSource] of [ + ['172.58.12.9', 'observed'], + ['2607:fb90:1:2::9', 'reported'], + ['::ffff:192.168.1.20', 'observed'], + ['ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255', 'reported'], + ]) { + assert.equal( + isSessionEndV1({ v: 1, t: 'session-end', reason: 'network-not-allowed', address, addressSource }), + true, + address, + ); + } // Frozen, so no sender can grow the shared value into a shape the guard refuses. assert.equal(Object.isFrozen(SESSION_END_V1), true); + const refused = { v: 1, t: 'session-end', reason: 'network-not-allowed' }; for (const [why, value] of [ ['not an object', 'session-end'], ['null', null], ['an array', [{ v: 1, t: 'session-end' }]], ['a future version', { v: 2, t: 'session-end' }], ['no version', { t: 'session-end' }], - // It carries nothing, so a field on it is one no reader has — the shape a - // smuggler would pick. - ['an extra key', { v: 1, t: 'session-end', reason: 'take-back' }], + // A reason no reader has is the shape a smuggler would pick. + ['another reason', { v: 1, t: 'session-end', reason: 'take-back' }], + ['an extra key', { ...refused, note: 'hi' }], + ['an address with no source', { ...refused, address: '172.58.12.9' }], + ['a source with no address', { ...refused, addressSource: 'observed' }], + ['an address with no reason', { v: 1, t: 'session-end', address: '1.2.3.4', addressSource: 'observed', x: 1 }], + ['an unknown source', { ...refused, address: '172.58.12.9', addressSource: 'guessed' }], + ['a hostname', { ...refused, address: 'phone.local', addressSource: 'observed' }], + ['an mDNS name', { ...refused, address: '0b1c5f3a-1d2e-4c1b-9a1e-1234567890ab.local', addressSource: 'observed' }], + ['a zoned address', { ...refused, address: 'fe80::1%en0', addressSource: 'observed' }], + ['an address with a port', { ...refused, address: '172.58.12.9:443', addressSource: 'observed' }], + ['markup', { ...refused, address: '1.2.3.4', addressSource: 'observed' }], ['a direct-path signal', { v: 1, t: 'direct-switch' }], ['a ceremony outcome', { ok: true, burrowLabel: 'Laptop' }], ]) { @@ -579,4 +606,7 @@ test('the goodbye is the same size on the wire as every other control message', const burrow = await established(); const outcome = burrow.sendControl({ ok: true, burrowLabel: "Ned's MacBook Pro (16-inch, 2025)" }); assert.equal(burrow.sendControl({ ...SESSION_END_V1 }).length, outcome.length); + const longest = 'ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255'; + const refused = { ...SESSION_END_V1, reason: 'network-not-allowed', address: longest, addressSource: 'reported' }; + assert.equal(burrow.sendControl(refused).length, outcome.length); }); diff --git a/remote-lib-common/test/harness/fake-burrow.mjs b/remote-lib-common/test/harness/fake-burrow.mjs index 54c5efec0..2f7552a89 100644 --- a/remote-lib-common/test/harness/fake-burrow.mjs +++ b/remote-lib-common/test/harness/fake-burrow.mjs @@ -10,14 +10,17 @@ * the same token) models a Burrow restart: its ACL starts empty again. * * Constructor: `{ relayUrl, burrowToken, burrowId, origin, rpId, label, - * autoApprove, requireUserVerification, noiseStaticKeyPair, socket, + * autoApprove, requireUserVerification, noiseStaticKeyPair, directOnly, socket, * socketInit }`. `relayUrl` may be `http(s)://…` or `ws(s)://…`. With * `autoApprove` the Burrow types back whatever code the request displayed; * otherwise call `confirmPairing(clientId, code)` / `denyPairing(clientId)`. + * `directOnly` is `BurrowRuntime` under a held path policy (Local networks): + * the outcome says so, and — this Burrow having no direct path — any + * application message ends its session unread, with the goodbye. * * Events, for logs and assertions: `open`, `close`, `frame`, `invitation`, * `e2e-open`, `e2e-receive`, `e2e-error`, `pairing-request`, `paired`, - * `denied`, `decision`, `msg`, `client-gone`. + * `denied`, `decision`, `msg`, `relayed-app`, `client-gone`. */ import { EventEmitter } from 'node:events'; @@ -31,6 +34,7 @@ import { NoiseTransportSession, REMOTE_EVENTS, REMOTE_METHODS, + SESSION_END_V1, WS_ROUTES, WS_TOKEN_PARAM, boundedPairingLabel, @@ -72,11 +76,13 @@ export class FakeBurrow extends EventEmitter { autoApprove = true, requireUserVerification, noiseStaticKeyPair, + directOnly = false, socket, socketInit, }) { super(); this.burrowId = burrowId; + this.directOnly = directOnly; this.label = label; this.autoApprove = autoApprove; this.noiseStaticKeyPair = noiseStaticKeyPair; @@ -624,7 +630,11 @@ export class FakeBurrow extends EventEmitter { const state = this.#clientState(clientId); state.connection = undefined; state.established = pending; - this.#sendControl(pending, { ok: true, burrowLabel: this.label }); + this.#sendControl(pending, { + ok: true, + burrowLabel: this.label, + ...(this.directOnly ? { directOnly: true } : {}), + }); this.emit('decision', { clientId, allowed: true, record }); } @@ -649,6 +659,17 @@ export class FakeBurrow extends EventEmitter { } this.emit('e2e-receive', { clientId, kind: 'connection', id: frame.id, receipt, entry: established }); if (receipt.kind !== 'app') return; + // Every frame here crossed the relay: a direct-only session ends unread. + if (this.directOnly) { + this.#sendControl(established, { ...SESSION_END_V1 }); + const state = this.clients.get(clientId); + if (state?.established === established) { + state.established = undefined; + this.#pruneClient(clientId); + } + this.emit('relayed-app', { clientId, id: frame.id }); + return; + } const send = (payload) => { for (const ciphertext of established.session.sendApp(utf8Encode(JSON.stringify(payload)))) { this.e2eSendCiphertext(established, ciphertext); diff --git a/remote-lib-common/test/one-time-wire.test.mjs b/remote-lib-common/test/one-time-wire.test.mjs index a847aab84..edb80ad65 100644 --- a/remote-lib-common/test/one-time-wire.test.mjs +++ b/remote-lib-common/test/one-time-wire.test.mjs @@ -10,11 +10,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + DIRECT_ONLY_DEADLINE_MS, DEFAULT_PAIRING_TTL_MS, MAX_E2E_CIPHERTEXT_LENGTH, MAX_ONE_TIME_FORWARDED, MAX_ONE_TIME_FRAME_LENGTH, - ONE_TIME_DIRECT_DEADLINE_MS, ONE_TIME_EXPIRY_GRACE_MS, ONE_TIME_LINK_TTL_MS, RELAY_PING, @@ -172,11 +172,10 @@ test('the room forwards a handshake, never a session', () => { test('the timings: a pairing-length link, a short grace, and a direct deadline inside it', () => { assert.equal(ONE_TIME_LINK_TTL_MS, DEFAULT_PAIRING_TTL_MS); assert.equal(ONE_TIME_LINK_TTL_MS, 5 * 60 * 1000); - assert.equal(ONE_TIME_EXPIRY_GRACE_MS, 30_000); - assert.equal(ONE_TIME_DIRECT_DEADLINE_MS, 15_000); + assert.equal(ONE_TIME_EXPIRY_GRACE_MS, 45_000); // A confirmation at the last second of the link still has its whole direct // deadline before the room's hard deadline closes the rendezvous. - assert.ok(ONE_TIME_DIRECT_DEADLINE_MS < ONE_TIME_EXPIRY_GRACE_MS); + assert.ok(DIRECT_ONLY_DEADLINE_MS < ONE_TIME_EXPIRY_GRACE_MS); }); test('the rendezvous keepalive is the relay socket\'s: two fixed strings no frame can be', () => { diff --git a/remote-lib-common/test/wire.test.mjs b/remote-lib-common/test/wire.test.mjs index 4053b9fee..dd99eaa7f 100644 --- a/remote-lib-common/test/wire.test.mjs +++ b/remote-lib-common/test/wire.test.mjs @@ -22,6 +22,11 @@ import { isE2eId, isE2eRelayToBurrowFrame, isSetupTokenResponse, + isBurrowEnrollBeginResponse, + isBurrowEnrollPollResponse, + MAX_ENROLL_POLL_INTERVAL_S, + MIN_ENROLL_POLL_INTERVAL_S, + RELAY_BEARER_LENGTH, pushSubscriptionDeletePath, } from '../dist/index.js'; @@ -299,3 +304,62 @@ test('enrollUserCode is the HMAC of the device code, five bits a character, past ); await assert.rejects(enrollUserCode(secret, deviceCode, fake(macOf([0, 1, 2, 3, 4, 5, 6]))), /No user code/); }); + +test('isBurrowEnrollBeginResponse holds each field to its shape and bounds', () => { + const begin = { + deviceCode: 'D'.repeat(RELAY_BEARER_LENGTH), + userCode, + verificationUrl: `https://hosted.dormouse.sh/enroll#${userCode}`, + expiresAt: 1_800_000_000_000, + interval: 5, + }; + assert.ok(isBurrowEnrollBeginResponse(begin)); + // A deployment naming no account origin sends no verificationUrl. + const { verificationUrl: _omitted, ...bare } = begin; + assert.ok(isBurrowEnrollBeginResponse(bare)); + assert.ok(isBurrowEnrollBeginResponse({ ...begin, interval: MIN_ENROLL_POLL_INTERVAL_S })); + assert.ok(isBurrowEnrollBeginResponse({ ...begin, interval: MAX_ENROLL_POLL_INTERVAL_S })); + for (const wrong of [ + null, + 'begin', + { ...begin, deviceCode: undefined }, + { ...begin, deviceCode: 'D'.repeat(RELAY_BEARER_LENGTH - 1) }, + { ...begin, deviceCode: `${'D'.repeat(RELAY_BEARER_LENGTH - 1)}=` }, + { ...begin, userCode: undefined }, + { ...begin, userCode: '23ab-yz9k' }, + { ...begin, userCode: '23ABYZ9K' }, + { ...begin, verificationUrl: 7 }, + { ...begin, verificationUrl: `https://hosted.dormouse.sh/enroll#${'x'.repeat(512)}` }, + { ...begin, expiresAt: undefined }, + { ...begin, expiresAt: '1800000000000' }, + { ...begin, expiresAt: Number.POSITIVE_INFINITY }, + { ...begin, expiresAt: 0 }, + { ...begin, interval: undefined }, + { ...begin, interval: '5' }, + { ...begin, interval: 2.5 }, + { ...begin, interval: MIN_ENROLL_POLL_INTERVAL_S - 1 }, + { ...begin, interval: MAX_ENROLL_POLL_INTERVAL_S + 1 }, + ]) { + assert.equal(isBurrowEnrollBeginResponse(wrong), false, JSON.stringify(wrong)); + } +}); + +test('isBurrowEnrollPollResponse knows four answers, a redeemed one naming its Burrow', () => { + const burrowId = 'A'.repeat(22); + assert.ok(isBurrowEnrollPollResponse({ status: 'pending' })); + assert.ok(isBurrowEnrollPollResponse({ status: 'expired' })); + assert.ok(isBurrowEnrollPollResponse({ status: 'redeemed', burrowId })); + assert.ok(isBurrowEnrollPollResponse({ status: 'enrolled', enrollment: {} })); + for (const wrong of [ + null, + 'pending', + { status: 'redeemed' }, + { status: 'redeemed', burrowId: 'short' }, + { status: 'redeemed', burrowId: 7 }, + { status: 'enrolled' }, + { status: 'enrolled', enrollment: 'x' }, + { status: 'approved' }, + ]) { + assert.equal(isBurrowEnrollPollResponse(wrong), false, JSON.stringify(wrong)); + } +}); diff --git a/scripts/direct-interop/run.mjs b/scripts/direct-interop/run.mjs index 895a81361..8fab2af2e 100644 --- a/scripts/direct-interop/run.mjs +++ b/scripts/direct-interop/run.mjs @@ -241,6 +241,7 @@ const pathPolicy = policy && { path.acceptedOffer = describeSdp(accepted).candidates.map((candidate) => candidate.address); return accepted; }, + reportedAddress: policy.reportedAddress, refusal: (pair) => { const refusal = policy.refusal(pair); path.checks.push({ pair: addonPair(), refusal }); diff --git a/scripts/e2e-lint.mjs b/scripts/e2e-lint.mjs index cd3b75062..f9c565c2d 100644 --- a/scripts/e2e-lint.mjs +++ b/scripts/e2e-lint.mjs @@ -16,7 +16,8 @@ * checked-in service worker shadowing the built one, no one-time frame the * Relay or `BurrowRuntime` could read, no parse in * the Hosted room that forwards one, no grant a one-time connection could - * leave behind, and no store a one-time phone could keep anything in. An + * leave behind, no store a one-time phone could keep anything in, and no + * relayed application message a Local-networks session would read. 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 @@ -127,6 +128,9 @@ const FRAME_MODULES = [ 'lib/src/remote/one-time-rendezvous.ts', ]; +/** The paired Burrow's runtime, which holds a session to the direct path under Local networks. */ +const BURROW_RUNTIME = 'lib/src/remote/burrow/burrow-runtime.ts'; + /** The laptop's one-time runtime, which authorizes one session and writes nothing. */ const ONE_TIME_RUNTIME = 'lib/src/remote/burrow/one-time-runtime.ts'; @@ -521,6 +525,35 @@ export const RULES = [ violationFile: 'lib/src/remote/burrow/burrow-runtime.ts', violation: "\nimport { isOneTimeClientFrame } from 'remote-lib-common';\n", }, + { + rule: '`BurrowRuntime` makes a session direct-only exactly where the path policy is held', + security: 'must derive `directOnly` from the path policy alone', + kind: 'require', + file: BURROW_RUNTIME, + // Local networks' path policy checks the direct path; the relay is a path + // it does not check, so a held policy is what makes a paired session + // direct-only — never the level, a Client, or the Relay. + pattern: /^ const directOnly = this\.#directPeering\.pathPolicy !== undefined;$/m, + }, + { + rule: '`BurrowRuntime` hands its session that derivation and no other', + security: 'must derive `directOnly` from the path policy alone', + kind: 'require', + file: BURROW_RUNTIME, + // `EstablishedE2eSession` owns every direct-only rule — the deadline, the + // given-up attempt, the relayed application message — so what this + // runtime decides is the one flag, as derived above. + pattern: /^ directOnly,$/m, + }, + { + rule: '`OneTimeRuntime` makes its one session direct-only', + security: 'must make its one session `directOnly`', + kind: 'require', + file: ONE_TIME_RUNTIME, + // A one-time connection has no relayed fallback at all: the rendezvous + // carries a handshake, never a session. + pattern: /^ directOnly: true,$/m, + }, { rule: '`OneTimeRuntime` names nothing that grants or persists', security: 'must name no ACL, ACL store, delivery id, or presence verifier', diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 4207d5c82..da6862558 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,21 +12,21 @@ "docs/specs/dor-tools-builtin.md": 1050, "docs/specs/dor-tools-lib.md": 400, "docs/specs/glossary.md": 3000, - "docs/specs/hosted.md": 4750, + "docs/specs/hosted.md": 4800, "docs/specs/layout.md": 11500, "docs/specs/mobile-terminal-ui.md": 2300, "docs/specs/mouse-and-clipboard.md": 5250, "docs/specs/one-time.md": 3850, - "docs/specs/pocket-app.md": 5100, - "docs/specs/relay.md": 10250, - "docs/specs/remote-api.md": 5250, - "docs/specs/remote-network.md": 2450, + "docs/specs/pocket-app.md": 5200, + "docs/specs/relay.md": 11100, + "docs/specs/remote-api.md": 5300, + "docs/specs/remote-network.md": 2850, "docs/specs/remote-security-model.md": 5450, "docs/specs/security-audit.md": 2100, "docs/specs/security-ci.md": 2950, "docs/specs/security-hosted.md": 2350, "docs/specs/security-local.md": 3950, - "docs/specs/security-remote.md": 7050, + "docs/specs/security-remote.md": 7300, "docs/specs/security-supply-chain.md": 1250, "docs/specs/security.md": 2150, "docs/specs/shortcuts.md": 1100, diff --git a/vscode-ext/src/burrow.ts b/vscode-ext/src/burrow.ts index 37ce9a869..34a1bd3e5 100644 --- a/vscode-ext/src/burrow.ts +++ b/vscode-ext/src/burrow.ts @@ -396,6 +396,7 @@ function drainQueuedCommands(): void { const CONTENTION_STARTERS: ReadonlySet = new Set([ 'enroll', 'enrollOffer', + 'beginHostedEnrollment', 'oneTimeOpen', 'setNetworkPolicy', ]); @@ -414,10 +415,12 @@ const CONTENTION_STARTERS: ReadonlySet = new Set([ * an enrolled machine's webview it has no Burrow moments before it gets one, * leaving the gates that arm on that answer down. * - * `enroll`, `enrollOffer`, `oneTimeOpen`, and `setNetworkPolicy` are the - * commands that may start the contention: they are how an installation with no - * Burrow at all bootstraps — `enrollOffer` from the one-click card an idle - * `status` advertises ({@link idleStatus}), `oneTimeOpen` from the idle + * `enroll`, `enrollOffer`, `beginHostedEnrollment`, `oneTimeOpen`, and + * `setNetworkPolicy` are the commands that may start the contention: they are + * how an installation with no Burrow at all bootstraps — `enrollOffer` from the + * one-click card an idle `status` advertises ({@link idleStatus}), + * `beginHostedEnrollment` from a Hosted build's enroll button, whose service + * then polls the approval, `oneTimeOpen` from the idle * one-time panel, which needs no enrollment, and `setNetworkPolicy` because the * service is the policy's only writer: a window writing it with a service * elsewhere would leave that service holding the old one. Everything else @@ -543,7 +546,9 @@ function refuse(burrowRequestId: string): void { * what one with no enrollment returns (`lib/src/host/remote/service.ts`). The * one-time pair is the same: no service means no connection, so its status is * the idle one this build's origin and policy allow, and ending it is already - * done; and with no service there is no session to take a pane back from. + * done; a Hosted enrollment is polled by a service, so with none there is + * nothing to cancel; and with no service there is no session to take a pane + * back from. */ async function idleAnswer(cmd: string): Promise<{ result: unknown } | null> { switch (cmd) { @@ -562,11 +567,15 @@ async function idleAnswer(cmd: string): Promise<{ result: unknown } | null> { ); return { result: idleOneTimeState(hostedOrigin(bakedRelay()), level) }; } + // With no service there is no session for the path to have ended. case 'networkPolicy': + case 'dismissPathRefusal': return { result: networkPolicyResult(await idleNetworkPolicy(), bakedRelay().mode, listNetworkInterfaces()), }; case 'oneTimeEnd': + // Nor an enrollment awaiting approval: the service that began one holds it. + case 'cancelHostedEnrollment': return { result: {} }; // No service holds any session, so none holds a pane: the strip clears itself. case 'takeBack': diff --git a/vscode-ext/test/burrow.test.ts b/vscode-ext/test/burrow.test.ts index b4bcec25f..df73190ae 100644 --- a/vscode-ext/test/burrow.test.ts +++ b/vscode-ext/test/burrow.test.ts @@ -723,6 +723,27 @@ describe('burrow service glue', () => { expect(rendezvous.room().burrowUrl).toBe('wss://relay.dormouse.sh/api/one-time/burrow'); }); + it('bootstraps the contention on beginHostedEnrollment, whose service polls the approval', async () => { + // A Hosted build's enroll button on a machine no window has a Burrow for: + // refused "no Burrow is reachable", it could never be pressed. + const mod = await freshBurrow(); + const bound = fakeDeps(); + mod.configureBurrow(bound.deps()); + mod.initBurrow(fakeContext().context); + expect(opened!.isPeerBroker()).toBe(false); + + mod.handleBurrowCommand({ burrowRequestId: 'rh-1', cmd: 'beginHostedEnrollment', params: { label: 'Laptop' } }); + + await waitFor(() => results(bound.posted).length > 0); + expect(opened!.isPeerBroker()).toBe(true); + // The service ran it and refused, a new install's network being Nothing — + // which is the proof it reached a service at all. + expect(results(bound.posted)[0]).toMatchObject({ + burrowRequestId: 'rh-1', + error: expect.stringContaining('set to Nothing'), + }); + }); + it('bootstraps the contention on setNetworkPolicy, so the service is its one writer', async () => { // Written from a window with no service, it would leave a service in // another window holding the policy it replaced. @@ -887,6 +908,8 @@ describe('burrow service glue', () => { pairedClients: 0, suggestedLabel: `${hostname()} (VS Code)`, offer: true, + hostedEnrollment: null, + accountOrigin: null, }, }, ]); @@ -1008,12 +1031,13 @@ describe('burrow service glue', () => { mod.handleBurrowCommand({ burrowRequestId: 'rh-oneTimeStatus', cmd: 'oneTimeStatus' }); mod.handleBurrowCommand({ burrowRequestId: 'rh-networkPolicy', cmd: 'networkPolicy' }); mod.handleBurrowCommand({ burrowRequestId: 'rh-oneTimeEnd', cmd: 'oneTimeEnd' }); + mod.handleBurrowCommand({ burrowRequestId: 'rh-cancelHostedEnrollment', cmd: 'cancelHostedEnrollment' }); // No service holds a pane either, so a Take back ends nothing and the // strip clears itself. mod.handleBurrowCommand({ burrowRequestId: 'rh-takeBack', cmd: 'takeBack', params: { holder: 'nobody' } }); // Everything else still says there is nothing to reach. mod.handleBurrowCommand({ burrowRequestId: 'rh-clear', cmd: 'clearEnrollment' }); - await waitFor(() => results(bound.posted).length === 8); + await waitFor(() => results(bound.posted).length === 9); expect(results(bound.posted).find((r) => r.burrowRequestId === 'rh-clear')).toEqual({ burrowRequestId: 'rh-clear', error: 'no Burrow is reachable', @@ -1052,6 +1076,7 @@ describe('burrow service glue', () => { 'oneTimeStatus', 'networkPolicy', 'oneTimeEnd', + 'cancelHostedEnrollment', 'takeBack', ]) { await idle.handleCommand({ burrowRequestId: `rh-${cmd}`, cmd, params: { holder: 'nobody' } }); @@ -1604,6 +1629,52 @@ describe('serving the other windows', () => { expect(bound.posted).toEqual([]); }); + it('answers another window’s Enroll with the code this one is showing', async () => { + // A window that never contended answers `status` idle and sees no code; + // its Enroll lands on this service, and replacing the code would leave the + // first window showing one the account can no longer approve. + let begun = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + // `API_ROUTES.burrowEnrollBegin`, which this package does not import. + if (!String(input).endsWith('/api/burrow/enroll/begin')) { + return new Response(JSON.stringify({ status: 'pending' }), { status: 200 }); + } + begun += 1; + return new Response( + JSON.stringify({ + // The bearer shape: 32 bytes, base64url. + deviceCode: String(begun).repeat(43), + userCode: '23AB-YZ9K', + expiresAt: Date.now() + 10 * 60_000, + interval: 5, + }), + { status: 200 }, + ); + }), + ); + const mod = await freshBurrow(); + const bound = fakeDeps(); + mod.configureBurrow(bound.deps()); + bridgeLinkToBurrow(mod, opened!, bound); + const window = mod.initBurrow(localNetworkContext().context); + try { + mod.handleBurrowCommand({ burrowRequestId: 'rh-0', cmd: 'beginHostedEnrollment', params: { label: 'Laptop' } }); + await waitFor(() => results(bound.posted).length > 0); + expect(results(bound.posted)[0]).toMatchObject({ result: { status: 'waiting', userCode: '23AB-YZ9K' } }); + + const far = fakeWindow(); + const link = await openFarWindow(far); + link.forwardCommand({ burrowRequestId: 'rh-1', cmd: 'beginHostedEnrollment', params: { label: 'Other' } }); + await waitFor(() => far.results.length > 0); + expect(far.results[0]).toMatchObject({ result: { status: 'waiting', userCode: '23AB-YZ9K' } }); + expect(begun).toBe(1); + } finally { + window.dispose(); + } + }); + it('answers a forwarded command over the link and nowhere else', async () => { const mod = await freshBurrow(); const bound = fakeDeps();