From 30494af558ff360b0f4bc00fe3aad6f0790d2e6f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 1 Oct 2026 01:06:39 -0700 Subject: [PATCH 01/13] Enroll a Hosted build from Settings by device code The service begins and polls the device-code enrollment itself, holding the device code out of every webview, and composes the approval link at the fixed hosted.dormouse.sh (a dev Hosted build follows the Relay's checked link). The Phones section replaces the disabled Hosted button with the code, a link to approve, and the enrolled view. Co-Authored-By: Claude Opus 5.5 --- SELF_HOST.md | 4 +- docs/specs/hosted.md | 4 +- docs/specs/relay.md | 98 +++-- docs/specs/remote-network.md | 5 +- docs/specs/security-local.md | 2 +- docs/specs/security-remote.md | 3 +- docs/specs/vscode.md | 6 +- docs/stories/pairing.mdx | 4 +- hosted/server/tests/pocket.test.ts | 6 +- .../components/RemoteControlSection.test.tsx | 147 ++++++- lib/src/components/RemoteControlSection.tsx | 207 +++++++++- lib/src/host/relay-origin.test.ts | 21 +- lib/src/host/relay-origin.ts | 30 +- lib/src/host/remote/service-protocol.ts | 38 ++ lib/src/host/remote/service.test.ts | 367 +++++++++++++++++- lib/src/host/remote/service.ts | 352 ++++++++++++++++- lib/src/host/remote/test-burrow-link.ts | 1 + .../remote/burrow/burrow-status-store.test.ts | 49 +++ lib/src/remote/burrow/burrow-status-store.ts | 50 +++ lib/src/remote/burrow/enrollment.test.ts | 126 +++++- lib/src/remote/burrow/enrollment.ts | 138 ++++++- .../stories/RemoteControlSection.stories.tsx | 96 ++++- remote-lib-common/src/remote/wire.ts | 35 ++ remote-lib-common/test/wire.test.mjs | 43 ++ scripts/spec-word-budgets.json | 4 +- vscode-ext/src/burrow.ts | 17 +- vscode-ext/test/burrow.test.ts | 26 +- 27 files changed, 1768 insertions(+), 111 deletions(-) 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 e01c9d1fa..09858e407 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -154,7 +154,7 @@ 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 }`. @@ -223,4 +223,4 @@ 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. +4. Hosted Relay beyond "Relay", "Relay sockets", and "Burrow enrollment": the path rule for paired phones — **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/relay.md b/docs/specs/relay.md index 435ff686c..43d8ab45c 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -121,7 +121,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 +151,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 +161,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 +186,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`, `enrollmentMethod`, 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`. @@ -804,6 +807,35 @@ 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'`. + - **The device code never leaves the service**, as `burrowToken` does not: + `status` carries `hostedEnrollment` — `waiting` with `userCode`, + `verificationUrl`, `expiresAt`, and `accountFull`, or `ended` with a + reason — and 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 a begin replacing it, 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, a 5xx, or a 429 + polls again**, a 429 adding 5 s to the interval, up to 60 s; **a full + account's 409 polls on with `accountFull`**, the Relay keeping the approval. + - **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. One redeemed after a Cancel is + dropped with a warning naming the Burrow, for the account to remove. * **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 @@ -864,10 +896,13 @@ 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`, `enrollVerificationUrl`, `#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). @@ -878,9 +913,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,8 +930,7 @@ 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 @@ -951,9 +984,22 @@ 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 hosted.dormouse.sh"; 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" and Done, which cancels. **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 + hosted.dormouse.sh", linking `HOSTED_ACCOUNT_ORIGIN/account`. - **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`. - **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,15 +1009,17 @@ 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`. diff --git a/docs/specs/remote-network.md b/docs/specs/remote-network.md index 63f9caac2..b96e429de 100644 --- a/docs/specs/remote-network.md +++ b/docs/specs/remote-network.md @@ -27,7 +27,7 @@ - **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 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. @@ -101,7 +101,7 @@ Source of truth: `NetworkSettings`, `NetworkPhones`, `NetworkUpdates`, `connecti **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. +2. **Hosted persistent**: the path rule for paired phones, beyond the routes, push, sockets, and enrollment 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; Local networks and Anywhere then cover paired phones. ### Allowed networks @@ -112,5 +112,4 @@ Source of truth: `NetworkSettings`, `NetworkPhones`, `NetworkUpdates`, `connecti - **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/security-local.md b/docs/specs/security-local.md index adb4a94e6..3cf09f758 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -161,7 +161,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 any level but `relay`; 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..7642df2c1 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 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..26a87a467 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. --- 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/lib/src/components/RemoteControlSection.test.tsx b/lib/src/components/RemoteControlSection.test.tsx index 83230a06c..90f3a55b2 100644 --- a/lib/src/components/RemoteControlSection.test.tsx +++ b/lib/src/components/RemoteControlSection.test.tsx @@ -34,12 +34,18 @@ vi.mock('./QrCode', async (importOriginal) => { }); import { ONE_TIME_OUTCOME_LABEL, oneTimeEndedCopy } from './OneTimeConnection'; -import { PAIRING_OUTCOME_LABEL, RemoteControlSection } from './RemoteControlSection'; +import { + HOSTED_ENROLLMENT_CODE_LABEL, + HOSTED_ENROLLMENT_ENDED_COPY, + PAIRING_OUTCOME_LABEL, + RemoteControlSection, +} from './RemoteControlSection'; import { hostOf } 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 { @@ -88,6 +94,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 +317,119 @@ 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'); + + 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('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' })), + 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()); + + 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 +441,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(); }); diff --git a/lib/src/components/RemoteControlSection.tsx b/lib/src/components/RemoteControlSection.tsx index 2ff6fb01a..372738e75 100644 --- a/lib/src/components/RemoteControlSection.tsx +++ b/lib/src/components/RemoteControlSection.tsx @@ -3,9 +3,24 @@ 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 { HOSTED_ACCOUNT_ORIGIN } from '../host/relay-origin'; +import type { + BurrowConsoleStatus, + HostedEnrollmentEndReason, + HostedEnrollmentState, + SetupQrResult, +} from '../host/remote/service-protocol'; +import { getPlatform } from '../lib/platform'; import type { PairingOutcome, BurrowStatus, @@ -13,6 +28,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, @@ -55,9 +72,11 @@ 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/'; +/** Where a Hosted account lists its enrolled computers, with Remove. */ +const ACCOUNT_PATH = '/account'; + /** * How far ahead of `expiresAt` the phone-setup panel mints a replacement code. * @@ -506,6 +525,7 @@ function RelayChoices({ status }: { status: BurrowConsoleStatus }) { @@ -526,7 +546,9 @@ function RelayChoices({ status }: { status: BurrowConsoleStatus }) { * reason {@link EnrollView} folds its own. */ function UnenrolledRelay({ status }: { status: BurrowConsoleStatus }) { - const [unfolded, setUnfolded] = useState(false); + // Unfolded from the start only over an enrollment already begun — the + // dialog reopened on a code waiting for approval, which folded would hide. + const [unfolded, setUnfolded] = useState(() => status.hostedEnrollment !== null); const [hint, choices] = status.relayMode === 'self-host' ? [ @@ -538,17 +560,12 @@ function UnenrolledRelay({ status }: { status: BurrowConsoleStatus }) { />, ] : [ - 'Enroll this Dormouse with hosted.dormouse.sh, so paired phones can reconnect any time.', + 'Enroll this Dormouse with your hosted.dormouse.sh account, so paired phones can reconnect any time.', <> -
- - - Coming soon.{' '} - Get updates on Hosted. - -
+
A self-hosted Relay takes a Dormouse built for its address. @@ -575,6 +592,159 @@ 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.', + failed: 'This computer could not finish enrolling.', +}; + +/** The account page a URL the service composed belongs to, for "Remove one". */ +function accountPageOf(verificationUrl: string): string { + try { + return `${new URL(verificationUrl).origin}${ACCOUNT_PATH}`; + } catch { + return `${HOSTED_ACCOUNT_ORIGIN}${ACCOUNT_PATH}`; + } +} + +/** + * 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, + suggestedLabel, +}: { + enrollment: HostedEnrollmentState | 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 ended = enrollment?.status === 'ended' ? enrollment : null; + const begin = () => + void run(async () => { + setBeginning(true); + try { + await beginHostedEnrollment(label.trim()); + } finally { + setBeginning(false); + } + }); + + return ( +
+ {waiting ? ( + void run(cancelHostedEnrollment)} /> + ) : null} + {ended ? ( +
+
{own(HOSTED_ENROLLMENT_ENDED_COPY, ended.reason) ?? HOSTED_ENROLLMENT_ENDED_COPY.failed}
+ {ended.reason === 'failed' && ended.message ?
{ended.message}
: null} +
+ ) : null} +
+ ); +} + +/** The code waiting for approval, and the way to approve it. */ +function HostedEnrollmentCode({ + waiting, + busy, + onCancel, +}: { + waiting: Extract; + busy: boolean; + onCancel: () => void; +}) { + const minutesLeft = useMinutesLeft(waiting.expiresAt) ?? 0; + const account = hostOf(waiting.verificationUrl); + 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.{' '} + Remove one and this + computer enrolls on its own. +
+ ) : null} +
+ + +
+
+ ); +} + /** * Un-enrolled in a self-host build, with or without an installer's offer for * its Relay on this machine. @@ -782,10 +952,12 @@ export function HeldEnrollment({ relayOrigin }: { relayOrigin: string }) { function EnrolledView({ relayOrigin, + relayMode, connection, pairedClients, }: { relayOrigin: string; + relayMode: BurrowConsoleStatus['relayMode']; connection: BurrowStatus; pairedClients: number; }) { @@ -816,6 +988,13 @@ function EnrolledView({ ? 'No phone has paired with this machine yet.' : `${pairedClients} paired ${pairedClients === 1 ? 'phone' : 'phones'}.`}
+ {relayMode === 'hosted' ? ( +
+ + Manage computers at {hostOf(HOSTED_ACCOUNT_ORIGIN)} + +
+ ) : null} {setup.report && !reportInPanel ? : null} diff --git a/lib/src/host/relay-origin.test.ts b/lib/src/host/relay-origin.test.ts index 04330fabb..21f10003b 100644 --- a/lib/src/host/relay-origin.test.ts +++ b/lib/src/host/relay-origin.test.ts @@ -15,11 +15,14 @@ import { import { readConfigs } from '../../../hosted/scripts/workers.mjs'; import { DEFAULT_RELAY_ORIGIN, + HOSTED_ACCOUNT_ORIGIN, HOSTED_VOICE_ORIGIN, bakedRelay, bakedRelayMode, + hostedAccountOrigin, hostedOrigin, hostedVoiceOrigin, + isDevHostedBuild, isRelayOrigin, } from './relay-origin'; @@ -70,10 +73,24 @@ describe('the baked relay origin', () => { expect(BUILD_DEFAULT).toBe(DEFAULT_RELAY_ORIGIN); }); - it('names the origins Hosted deploys its relay and voice Workers at', async () => { - const { relay, voice } = await readConfigs(); + it('names the origins Hosted deploys its three Workers at', async () => { + const { account, relay, voice } = await readConfigs(); expect(DEFAULT_RELAY_ORIGIN).toBe(relay.vars.APP_ORIGIN); expect(HOSTED_VOICE_ORIGIN).toBe(voice.vars.APP_ORIGIN); + expect(HOSTED_ACCOUNT_ORIGIN).toBe(account.vars.APP_ORIGIN); + }); + + it('approves enrollment at the fixed account origin, in a Hosted build only', () => { + expect(hostedAccountOrigin({ origin: DEFAULT_RELAY_ORIGIN, mode: 'hosted' })).toBe('https://hosted.dormouse.sh'); + expect(hostedAccountOrigin({ origin: 'http://localhost:8787', mode: 'hosted' })).toBe('https://hosted.dormouse.sh'); + expect(hostedAccountOrigin({ origin: 'https://relay.example.ts.net', mode: 'self-host' })).toBeNull(); + }); + + it('tells a dev Hosted build by its non-default origin', () => { + expect(isDevHostedBuild({ origin: DEFAULT_RELAY_ORIGIN, mode: 'hosted' })).toBe(false); + expect(isDevHostedBuild({ origin: `${DEFAULT_RELAY_ORIGIN}/`, mode: 'hosted' })).toBe(false); + expect(isDevHostedBuild({ origin: 'http://localhost:8787', mode: 'hosted' })).toBe(true); + expect(isDevHostedBuild({ origin: 'https://relay.example.ts.net', mode: 'self-host' })).toBe(false); }); it('reads as the Hosted default where nothing was baked (the test runner)', () => { diff --git a/lib/src/host/relay-origin.ts b/lib/src/host/relay-origin.ts index 6aa507c0c..03de35f35 100644 --- a/lib/src/host/relay-origin.ts +++ b/lib/src/host/relay-origin.ts @@ -2,7 +2,8 @@ * The one relay origin this build was baked with, and the mode it sets * (`docs/specs/relay.md` → "Relay origin"): the Burrow's only Relay, and — in a * Hosted build — where the one-time rendezvous goes too. Managed voice speaks - * at {@link HOSTED_VOICE_ORIGIN} instead. + * at {@link HOSTED_VOICE_ORIGIN} instead, and enrollment is approved at + * {@link HOSTED_ACCOUNT_ORIGIN}. * * Baked by `scripts/relay-origin.mjs` into both host bundles. **Never webview * input** — no command carries an origin. @@ -25,6 +26,14 @@ export const DEFAULT_RELAY_ORIGIN = 'https://relay.dormouse.sh'; */ export const HOSTED_VOICE_ORIGIN = 'https://voice.dormouse.sh'; +/** + * The Hosted account's origin, where a Hosted build's enrollment is approved + * and its computers are managed. A fixed constant, never baked: **the desktop + * never requests it**, and opens it only on the user's click + * (`docs/specs/relay.md` → "Relay origin"). + */ +export const HOSTED_ACCOUNT_ORIGIN = 'https://hosted.dormouse.sh'; + /** * `hosted`: the default origin, or a dev build's `DORMOUSE_RELAY_IS_HOSTED=1`. * `self-host`: any other origin, which is then this build's only Relay and @@ -87,6 +96,25 @@ export function hostedVoiceOrigin(relay: RelayBuild): string | null { return relay.mode === 'hosted' ? HOSTED_VOICE_ORIGIN : null; } +/** + * Where this build's enrollment is approved: {@link HOSTED_ACCOUNT_ORIGIN} in + * a Hosted build, and `null` in a self-host one, which enrolls with its own + * Relay's setup password. + */ +export function hostedAccountOrigin(relay: RelayBuild): string | null { + return relay.mode === 'hosted' ? HOSTED_ACCOUNT_ORIGIN : null; +} + +/** + * Whether this is a dev Hosted build: Hosted mode at an origin other than the + * default, which only `DORMOUSE_RELAY_IS_HOSTED` in a dev build can bake + * (`scripts/relay-origin.mjs`). Its Relay is a local or preview Worker whose + * account page is not {@link HOSTED_ACCOUNT_ORIGIN}. + */ +export function isDevHostedBuild(relay: RelayBuild): boolean { + return relay.mode === 'hosted' && !isRelayOrigin(relay.origin, DEFAULT_RELAY_ORIGIN); +} + /** * Whether a stored Relay URL names `relayOrigin`, compared as origins. An * enrollment for which this is false **reads as none** — it stays on disk, but diff --git a/lib/src/host/remote/service-protocol.ts b/lib/src/host/remote/service-protocol.ts index 8ea1138f2..aceae8591 100644 --- a/lib/src/host/remote/service-protocol.ts +++ b/lib/src/host/remote/service-protocol.ts @@ -300,6 +300,16 @@ export interface EnrollOfferParams { label: string; } +/** + * A Hosted build's device-code enrollment (`docs/specs/hosted.md` -> "Burrow + * enrollment"): `beginHostedEnrollment` takes the name to keep for this + * machine, and the service does the rest, reporting through `status` + * ({@link HostedEnrollmentState}). No origin and no URL: both are the build's. + */ +export interface HostedEnrollParams { + label: string; +} + /** `kind`, `clientId`, and `pairingId` echo the {@link PairingQueueItem} the modal displayed. */ export interface ApproveParams { /** Read through {@link approvalKind}: absent is a `pairing`. */ @@ -383,6 +393,29 @@ export interface SetupQrResult { expiresAt: number; } +/** + * Why a Hosted enrollment stopped short of enrolling, each read as fixed copy + * but `failed`, which carries the service's sentence. A reason this build does + * not know reads as `failed` with no sentence. + */ +export type HostedEnrollmentEndReason = 'expired' | 'not-entitled' | 'failed'; + +/** + * A Hosted build's device-code enrollment as `status` reports it: `waiting` + * for the account to approve `userCode`, or `ended` without an enrollment, + * until the next begin or a cancel. A success reports none: `enrolled` says it. + * `accountFull` is an approval the account cannot redeem until it removes a + * computer, which the Relay keeps, so the service polls on. + * + * **Never the device code**, which is a bearer the service holds as it holds + * `burrowToken` (`docs/specs/security-remote.md` -> "Trust boundary"). + * `verificationUrl` is the account page the service composed, which the panel + * opens on a click; `expiresAt` is this machine's clock. + */ +export type HostedEnrollmentState = + | { status: 'waiting'; userCode: string; verificationUrl: string; expiresAt: number; accountFull: boolean } + | { status: 'ended'; reason: HostedEnrollmentEndReason; message?: string }; + /** * What `window.dormouseBurrow.status()` prints. `docs/specs/relay.md` * documents the console hook, so these field names are user-facing surface. @@ -420,6 +453,11 @@ export interface BurrowConsoleStatus { * the no-`burrowToken`-in-a-webview FAIL IF). */ offer: boolean; + /** + * The device-code enrollment in a Hosted build, or `null` where none was + * begun since the last cancel or success, and always in a self-host build. + */ + hostedEnrollment: HostedEnrollmentState | null; } /** diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index 4d9226817..afdb54650 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -43,8 +43,10 @@ vi.mock('../../remote/burrow/enrollment', async (importOriginal) => { }); import { API_ROUTES, + NOT_ENTITLED_ERROR, ONE_TIME_WS_ROUTES, ORIGIN_MISMATCH_ERROR, + RELAY_BEARER_LENGTH, mintNoiseStaticKeyPair, parseOneTimeLinkUrl, parsePairingInvitationUrl, @@ -87,7 +89,7 @@ import { import { createEphemeralBurrowStateStore, type BurrowStateStore } from './burrow-state-store'; import { DEFAULT_RELAY_ORIGIN } from '../relay-origin'; import type { BurrowDirectPeerFactory } from './native-direct-peer'; -import { BurrowService, type BurrowServiceOptions } from './service'; +import { BurrowService, enrollVerificationUrl, type BurrowServiceOptions } from './service'; import { ANYWHERE_ON, LAN, LOCAL_ON, RELAY_ON } from './test-burrow-link'; import { idleOneTimeState, isOneTimeState } from './service-protocol'; import type { @@ -470,6 +472,7 @@ describe('status', () => { pairedClients: 0, suggestedLabel: `${hostname()} (VS Code)`, offer: false, + hostedEnrollment: null, } satisfies BurrowConsoleStatus); }); @@ -513,6 +516,7 @@ describe('status', () => { pairedClients: 0, suggestedLabel: `${hostname()} (VS Code)`, offer: true, + hostedEnrollment: null, } satisfies BurrowConsoleStatus); // The one-time token is a bearer credential and this is a service→webview // shape (docs/specs/security-remote.md -> "Trust boundary"), so it must not appear anywhere in what was sent. @@ -545,6 +549,7 @@ describe('status', () => { pairedClients: 1, suggestedLabel: `${hostname()} (VS Code)`, offer: false, + hostedEnrollment: null, } satisfies BurrowConsoleStatus); }); @@ -584,7 +589,8 @@ describe('status', () => { describe('enroll', () => { it('refuses in a Hosted build, before the setup password leaves the machine', async () => { - // Its one Relay is Hosted's, which enrolls no Burrow yet (docs/specs/relay.md → "Relay origin"). + // Hosted takes no setup password: its build enrolls by device code + // (docs/specs/relay.md → "Relay origin"). createHostedService(); const result = await command('enroll', { password: 'setup', label: 'Laptop' }); @@ -2405,3 +2411,360 @@ describe('network policy', () => { }); }); }); + +describe('Hosted enrollment', () => { + const USER_CODE = '23AB-YZ9K'; + const DEVICE_CODE = 'D'.repeat(RELAY_BEARER_LENGTH); + const INTERVAL_S = 5; + const TTL_MS = 10 * 60_000; + + /** What the fake Hosted Relay's begin answers, and its queue of poll answers. */ + let begin: Record; + let polls: Array<{ status: number; body: unknown } | Error>; + + const reply = (status: number, body: unknown) => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + }) as Response; + + /** `relay.dormouse.sh` serving begin and poll; an empty poll queue is `pending`. */ + function hostedFetch(): typeof globalThis.fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + requests.push({ url, init }); + if (url.endsWith(API_ROUTES.burrowEnrollBegin)) return reply(200, begin); + if (url.endsWith(API_ROUTES.burrowEnrollPoll)) { + const next = polls.shift() ?? { status: 200, body: { status: 'pending' } }; + if (next instanceof Error) throw next; + return reply(next.status, next.body); + } + throw new Error(`unexpected request ${url}`); + }) as unknown as typeof globalThis.fetch; + } + + const enrolledAnswer = () => ({ + status: 200, + body: { + status: 'enrolled', + enrollment: { + burrowId: BURROW_ID, + burrowToken: 'hosted-token', + origin: HOSTED_ORIGIN, + rpId: new URL(HOSTED_ORIGIN).hostname, + }, + }, + }); + + function hosted(seed?: Seed, over: Partial = {}): BurrowService { + return createHostedService(seed, { fetch: hostedFetch(), ...over }); + } + + const pollRequests = () => requests.filter((request) => request.url.endsWith(API_ROUTES.burrowEnrollPoll)); + + async function hostedEnrollment(): Promise { + return ((await command('status')).result as BurrowConsoleStatus).hostedEnrollment; + } + + /** Let the work a fired timer started finish: fetches, WebCrypto, the store. */ + async function drain(until: () => boolean): Promise { + for (let turn = 0; turn < 500 && !until(); turn += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + } + + /** Fire the next poll and wait for it to have asked. */ + async function nextPoll(): Promise { + const asked = pollRequests().length; + await vi.advanceTimersByTimeAsync(INTERVAL_S * 1000); + await drain(() => pollRequests().length > asked); + // And for what the answer started. + for (let turn = 0; turn < 20; turn += 1) await new Promise((resolve) => setImmediate(resolve)); + } + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + begin = { + deviceCode: DEVICE_CODE, + userCode: USER_CODE, + // Followed in no release build: the account page is the Burrow's to compose. + verificationUrl: `https://hosted.example/enroll#${USER_CODE}`, + expiresAt: Date.now() + TTL_MS, + interval: INTERVAL_S, + }; + polls = []; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('begins at the baked origin and shows a code it composed the account page for, never the device code', async () => { + hosted(); + const { result, error } = await command('beginHostedEnrollment', { label: 'Work laptop' }); + + expect(error).toBeUndefined(); + const waiting = { + status: 'waiting', + userCode: USER_CODE, + verificationUrl: `https://hosted.dormouse.sh/enroll#${USER_CODE}`, + expiresAt: Date.now() + TTL_MS, + accountFull: false, + }; + expect(result).toEqual(waiting); + expect(requests.map((request) => request.url)).toEqual([`${HOSTED_ORIGIN}${API_ROUTES.burrowEnrollBegin}`]); + expect(requestBody(0)).toEqual({ origin: HOSTED_ORIGIN }); + expect(await hostedEnrollment()).toEqual(waiting); + expect(statusEvents()).toEqual([false]); + // The device code is a bearer, held like `burrowToken`. + expect(JSON.stringify(sent)).not.toContain(DEVICE_CODE); + }); + + it('polls every interval and holds the enrollment it redeems, under Local networks without a socket', async () => { + hosted(); + await service.start(); + await command('beginHostedEnrollment', { label: 'Work laptop' }); + + await nextPoll(); + expect(pollRequests()).toHaveLength(1); + expect(JSON.parse(pollRequests()[0]!.init!.body as string)).toEqual({ deviceCode: DEVICE_CODE }); + expect(pollRequests()[0]!.init!.redirect).toBe('error'); + + polls.push(enrolledAnswer()); + await nextPoll(); + await drain(() => store.enrollment !== null); + + expect(store.enrollment).toMatchObject({ + relayUrl: HOSTED_ORIGIN, + burrowId: BURROW_ID, + burrowToken: 'hosted-token', + origin: HOSTED_ORIGIN, + label: 'Work laptop', + }); + expect(store.enrollment?.noiseStaticPublicKey).toEqual(expect.any(String)); + expect((await command('status')).result).toMatchObject({ + enrolled: true, + connection: 'stopped', + hostedEnrollment: null, + }); + expect(statusEvents().at(-1)).toBe(true); + // Only `relay` runs the persistent Burrow, and polling is over. + expect(sockets).toEqual([]); + await vi.advanceTimersByTimeAsync(INTERVAL_S * 3000); + expect(pollRequests()).toHaveLength(2); + }); + + it('is refused in a self-host build, and under Nothing, before any request', async () => { + createService({ network: RELAY_ON }, { fetch: hostedFetch() }); + expect((await command('beginHostedEnrollment', { label: 'x' })).error).toContain('setup password'); + + hosted({ network: NOTHING }); + expect((await command('beginHostedEnrollment', { label: 'x' })).error).toContain('set to Nothing'); + expect(requests).toEqual([]); + }); + + it('refuses a begin answer that is not an enrollment code, waiting on nothing', async () => { + begin = { ...begin, interval: 0 }; + hosted(); + + expect((await command('beginHostedEnrollment', { label: 'x' })).error).toContain('not an enrollment code'); + expect(await hostedEnrollment()).toBeNull(); + }); + + it('ends on an account not entitled, and polls on through a full one, which the Relay keeps', async () => { + hosted(); + await command('beginHostedEnrollment', { label: 'x' }); + polls.push({ status: 409, body: { error: 'this account already has 32 computers enrolled' } }); + await nextPoll(); + expect(await hostedEnrollment()).toMatchObject({ status: 'waiting', accountFull: true }); + + // A computer removed at the account, and the kept approval redeems. + polls.push(enrolledAnswer()); + await nextPoll(); + await drain(() => store.enrollment !== null); + expect(store.enrollment?.burrowId).toBe(BURROW_ID); + + await command('beginHostedEnrollment', { label: 'x' }); + polls.push({ status: 403, body: { error: NOT_ENTITLED_ERROR } }); + await nextPoll(); + expect(await hostedEnrollment()).toEqual({ status: 'ended', reason: 'not-entitled' }); + const asked = pollRequests().length; + await vi.advanceTimersByTimeAsync(INTERVAL_S * 3000); + expect(pollRequests()).toHaveLength(asked); + }); + + it('keeps polling through an unreachable Relay and a 5xx, and slows down on a 429', async () => { + hosted(); + await command('beginHostedEnrollment', { label: 'x' }); + polls.push(new Error('offline'), { status: 503, body: {} }, { status: 429, body: {} }); + await nextPoll(); + await nextPoll(); + await nextPoll(); + expect(pollRequests()).toHaveLength(3); + expect(await hostedEnrollment()).toMatchObject({ status: 'waiting' }); + + // The interval grew by five seconds. + await vi.advanceTimersByTimeAsync(INTERVAL_S * 1000); + await drain(() => false); + expect(pollRequests()).toHaveLength(3); + await vi.advanceTimersByTimeAsync(5_000); + await drain(() => pollRequests().length > 3); + expect(pollRequests()).toHaveLength(4); + }); + + it('ends at the Relay’s expired, and at its own deadline', async () => { + hosted(); + await command('beginHostedEnrollment', { label: 'x' }); + polls.push({ status: 200, body: { status: 'expired' } }); + await nextPoll(); + expect(await hostedEnrollment()).toEqual({ status: 'ended', reason: 'expired' }); + + // A Relay whose code would outlive the bound is held to it by this clock. + begin = { ...begin, expiresAt: Date.now() + 24 * 60 * 60_000 }; + const { result } = await command('beginHostedEnrollment', { label: 'x' }); + expect((result as { expiresAt: number }).expiresAt).toBe(Date.now() + 15 * 60_000); + await vi.advanceTimersByTimeAsync(15 * 60_000); + await drain(() => false); + expect(await hostedEnrollment()).toEqual({ status: 'ended', reason: 'expired' }); + const asked = pollRequests().length; + await vi.advanceTimersByTimeAsync(INTERVAL_S * 3000); + expect(pollRequests()).toHaveLength(asked); + }); + + it('stops polling on cancel, on Nothing, and on dispose', async () => { + hosted(); + await command('beginHostedEnrollment', { label: 'x' }); + expect((await command('cancelHostedEnrollment')).result).toEqual({}); + expect(await hostedEnrollment()).toBeNull(); + await vi.advanceTimersByTimeAsync(INTERVAL_S * 3000); + expect(pollRequests()).toEqual([]); + + await command('beginHostedEnrollment', { label: 'x' }); + await setPolicy(NOTHING); + expect(await hostedEnrollment()).toBeNull(); + await vi.advanceTimersByTimeAsync(INTERVAL_S * 3000); + expect(pollRequests()).toEqual([]); + + await setPolicy(LOCAL_ON); + await command('beginHostedEnrollment', { label: 'x' }); + expect(vi.getTimerCount()).toBe(1); + service.dispose(); + // No timer left to ask, even of a transport that would refuse it. + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(INTERVAL_S * 3000); + expect(pollRequests()).toEqual([]); + }); + + it('keeps nothing a poll in flight redeems after a cancel', async () => { + hosted(); + await command('beginHostedEnrollment', { label: 'x' }); + let answer: (response: Response) => void = () => {}; + const inFlight = new Promise((resolve) => { + answer = resolve; + }); + const fetch = hostedFetch(); + service.dispose(); + hosted(undefined, { + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => + String(input).endsWith(API_ROUTES.burrowEnrollPoll) + ? (requests.push({ url: String(input), init }), inFlight) + : fetch(input, init)) as typeof globalThis.fetch, + }); + await command('beginHostedEnrollment', { label: 'x' }); + await nextPoll(); + await command('cancelHostedEnrollment'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + answer(reply(200, enrolledAnswer().body)); + await drain(() => warn.mock.calls.length > 0); + + expect(store.enrollment).toBeNull(); + expect(await hostedEnrollment()).toBeNull(); + expect(String(warn.mock.calls[0]![0])).toContain(BURROW_ID); + warn.mockRestore(); + }); + + it('refuses a redemption for another origin, saving nothing, and says so', async () => { + hosted(); + await command('beginHostedEnrollment', { label: 'x' }); + const answer = enrolledAnswer(); + (answer.body.enrollment as { origin: string }).origin = 'https://relay.example.com'; + polls.push(answer); + await nextPoll(); + await drain(() => store.enrollment !== null); + + expect(store.enrollment).toBeNull(); + expect(await hostedEnrollment()).toEqual({ + status: 'ended', + reason: 'failed', + message: expect.stringContaining('The Relay says its origin is https://relay.example.com'), + }); + }); + + it('ends failed when the redeemed enrollment cannot be saved', async () => { + hosted(); + await command('beginHostedEnrollment', { label: 'x' }); + store.saveEnrollment = async () => { + throw new Error('keychain is locked'); + }; + polls.push(enrolledAnswer()); + await nextPoll(); + + expect(await hostedEnrollment()).toEqual({ status: 'ended', reason: 'failed', message: 'keychain is locked' }); + expect((await command('status')).result).toMatchObject({ enrolled: false }); + }); + + it('replaces one waiting with the next begin', async () => { + hosted(); + await command('beginHostedEnrollment', { label: 'x' }); + begin = { ...begin, deviceCode: 'E'.repeat(RELAY_BEARER_LENGTH), userCode: '9999-ZZZZ' }; + await command('beginHostedEnrollment', { label: 'x' }); + await nextPoll(); + + expect(pollRequests()).toHaveLength(1); + expect(JSON.parse(pollRequests()[0]!.init!.body as string)).toEqual({ deviceCode: 'E'.repeat(RELAY_BEARER_LENGTH) }); + expect(await hostedEnrollment()).toMatchObject({ status: 'waiting', userCode: '9999-ZZZZ' }); + }); +}); + +describe('enrollVerificationUrl', () => { + const code = '23AB-YZ9K'; + const release = { origin: HOSTED_ORIGIN, mode: 'hosted' } as const; + const dev = { origin: 'http://localhost:8787', mode: 'hosted' } as const; + + it('composes the account page in a release build, whatever the Relay names', () => { + for (const verificationUrl of [undefined, `https://hosted.example/enroll#${code}`, 'javascript:alert(1)']) { + expect(enrollVerificationUrl(release, { userCode: code, verificationUrl })).toBe( + `https://hosted.dormouse.sh/enroll#${code}`, + ); + } + }); + + it('follows the Relay’s account origin in a dev build, held to the link’s checks', () => { + expect(enrollVerificationUrl(dev, { userCode: code, verificationUrl: `http://localhost:5173/enroll#${code}` })) + .toBe(`http://localhost:5173/enroll#${code}`); + expect(enrollVerificationUrl(dev, { userCode: code, verificationUrl: `https://hosted-pr-7.example.dev/enroll#${code}` })) + .toBe(`https://hosted-pr-7.example.dev/enroll#${code}`); + for (const verificationUrl of [ + undefined, + `http://192.168.1.4:5173/enroll#${code}`, + `https://hosted.example/account#${code}`, + `https://hosted.example/enroll?x=1#${code}`, + `https://user@hosted.example/enroll#${code}`, + `https://hosted.example/enroll#9999-ZZZZ`, + `https://hosted.example/enroll#${code}x`, + `javascript:alert(1)//enroll#${code}`, + ]) { + expect(() => enrollVerificationUrl(dev, { userCode: code, verificationUrl }), String(verificationUrl)).toThrow( + /named no account page/, + ); + } + }); + + it('composes none in a self-host build', () => { + expect(() => enrollVerificationUrl({ origin: ORIGIN, mode: 'self-host' }, { userCode: code })).toThrow( + /setup password/, + ); + }); +}); diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index f87a5b54f..812941710 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -10,19 +10,26 @@ import { hostname } from 'node:os'; import { API_ROUTES, + ENROLL_PAGE_PATH, + MAX_ENROLL_POLL_INTERVAL_S, MAX_PENDING_PAIRINGS, deriveNoiseStaticPublicKey, formatPairingInvitationUrl, isSetupTokenResponse, mintNoiseStaticKeyPair, + parseLinkFragment, randomBase64Url, + type BurrowEnrollBeginResponse, type EnrollmentOffer, } from 'remote-lib-common'; import { + beginHostedEnrollment, originMismatchMessage, performEnrollment, + pollHostedEnrollment, type BurrowEnrollCredential, type BurrowEnrollment, + type EnrollmentStatic, } from '../../remote/burrow/enrollment'; import type { BurrowSurfaceProvider } from '../../remote/burrow/burrow-surface-provider'; import { burrowFetch } from '../../remote/burrow/burrow-fetch'; @@ -60,7 +67,13 @@ import { type NetworkPolicy, type NetworkPolicyResult, } from '../../remote/network-policy'; -import { hostedOrigin, isRelayOrigin, type RelayBuild } from '../relay-origin'; +import { + hostedAccountOrigin, + hostedOrigin, + isDevHostedBuild, + isRelayOrigin, + type RelayBuild, +} from '../relay-origin'; import { readEnrollmentOffer } from './enroll-offer'; import type { BurrowStateStore } from './burrow-state-store'; import { directPeeringFor, samePaths } from './direct-peering'; @@ -79,6 +92,9 @@ import { type EnrollOfferParams, type EnrollParams, type EnrollResult, + type HostedEnrollParams, + type HostedEnrollmentEndReason, + type HostedEnrollmentState, type BurrowStatusEvent, type InvitationEvent, type OneTimeEvent, @@ -158,12 +174,15 @@ function safeHostname(): string { } /** - * Whether this build enrolls at all: only a self-host build, a Hosted one's - * Relay being Hosted's, which enrolls no Burrow yet (`docs/specs/relay.md` → - * "Relay origin"). + * How this build enrolls (`docs/specs/relay.md` → "Relay origin"): a + * self-host build with its Relay's setup password or the installer's offer + * (`enroll`, `enrollOffer`), a Hosted build by device code + * (`beginHostedEnrollment`). Each command refuses in the other build. */ -export function canEnroll(relay: RelayBuild): boolean { - return relay.mode === 'self-host'; +export type EnrollmentMethod = 'password' | 'device-code'; + +export function enrollmentMethod(relay: RelayBuild): EnrollmentMethod { + return relay.mode === 'self-host' ? 'password' : 'device-code'; } /** What every command the `nothing` level refuses answers, before any request. */ @@ -225,21 +244,80 @@ function requestedNetworkPolicy(value: unknown, relay: RelayBuild): NetworkPolic return { ...policy, allowed }; } -/** What `enroll` and `enrollOffer` answer where {@link canEnroll} is false. */ +/** What `enroll` and `enrollOffer` answer in a Hosted build. */ const HOSTED_ENROLLMENT_REFUSAL = - 'This build’s Relay is Dormouse Hosted, which is not running one yet. A Relay you run takes ' + - 'a Dormouse built with its origin (DORMOUSE_RELAY_ORIGIN).'; + 'This Dormouse enrolls with Dormouse Hosted by approving a code at hosted.dormouse.sh, not with ' + + 'a setup password. A Relay you run takes a Dormouse built with its origin (DORMOUSE_RELAY_ORIGIN).'; + +/** What `beginHostedEnrollment` answers in a self-host build. */ +const SELF_HOST_DEVICE_CODE_REFUSAL = + 'This Dormouse enrolls with its own Relay’s setup password; enrollment codes are for Dormouse Hosted.'; + +/** + * How long a Hosted enrollment waits for approval, by this machine's clock: + * the Relay's `expiresAt`, held to these bounds, since that is the Relay's + * clock and this one may be minutes off it. The Relay's `expired` ends it too. + */ +const HOSTED_ENROLLMENT_MIN_WAIT_MS = 60_000; +const HOSTED_ENROLLMENT_MAX_WAIT_MS = 15 * 60_000; + +/** How much a 429 lengthens the poll interval, up to `MAX_ENROLL_POLL_INTERVAL_S`. */ +const HOSTED_ENROLLMENT_SLOW_DOWN_MS = 5_000; + +/** + * The account page that approves `begin.userCode`, composed here + * (`docs/specs/hosted.md` -> "Burrow enrollment"): **never the Relay's + * `verificationUrl` in a release build**, which opens + * `HOSTED_ACCOUNT_ORIGIN/enroll#`. A dev Hosted build + * (`isDevHostedBuild`) takes the origin of the Relay's `verificationUrl`, + * holding it to a link's checks — https or loopback http, no credentials, path + * `/enroll`, no query, fragment exactly the code — and throws without one. + */ +export function enrollVerificationUrl( + relay: RelayBuild, + begin: Pick, +): string { + const account = hostedAccountOrigin(relay); + if (account === null) throw new Error(SELF_HOST_DEVICE_CODE_REFUSAL); + const origin = isDevHostedBuild(relay) ? namedAccountOrigin(begin) : account; + if (origin === null) { + throw new Error( + `The Relay at ${relay.origin} named no account page to approve ${begin.userCode} at.`, + ); + } + return `${origin}${ENROLL_PAGE_PATH}#${begin.userCode}`; +} + +/** The origin of a `verificationUrl` that is exactly `/enroll#`, or `null`. */ +function namedAccountOrigin( + begin: Pick, +): string | null { + const named = begin.verificationUrl; + if (typeof named !== 'string') return null; + let origin: string; + try { + origin = new URL(named).origin; + } catch { + return null; + } + const fragment = parseLinkFragment(named, origin, { + maxLength: 512, + pathname: ENROLL_PAGE_PATH, + hashPrefix: '#', + }); + return fragment === begin.userCode ? origin : null; +} /** - * The installer's offer this build could spend: read only where - * {@link canEnroll}, and `null` unless it names the baked origin. One reader - * for the service and the VS Code glue, which both answer `status`. + * The installer's offer this build could spend: read only in a self-host + * build, and `null` unless it names the baked origin. One reader for the + * service and the VS Code glue, which both answer `status`. */ export async function readUsableOffer( relay: RelayBuild, read: () => Promise, ): Promise { - if (!canEnroll(relay)) return null; + if (enrollmentMethod(relay) !== 'password') return null; const offer = await read(); return offer && isRelayOrigin(offer.origin, relay.origin) ? offer : null; } @@ -283,6 +361,7 @@ export function unenrolledStatus( kind: BurrowKind, relay: RelayBuild, serving = false, + hostedEnrollment: HostedEnrollmentState | null = null, ): BurrowConsoleStatus { return { enrolled: false, @@ -294,6 +373,7 @@ export function unenrolledStatus( pairedClients: 0, suggestedLabel: suggestedBurrowLabel(kind), offer: offer !== null, + hostedEnrollment, }; } @@ -347,6 +427,25 @@ export function suggestedBurrowLabel(kind: BurrowKind): string { return machine ? `${machine} (${KIND_NAMES[kind]})` : KIND_NAMES[kind]; } +/** + * A Hosted enrollment awaiting approval. The device code and the Noise static + * stay here, in this process; `status` carries the rest + * ({@link HostedEnrollmentState}). + */ +interface HostedEnrollmentRun { + readonly deviceCode: string; + readonly noiseStatic: EnrollmentStatic; + readonly label: string; + readonly userCode: string; + readonly verificationUrl: string; + /** This machine's deadline (`HOSTED_ENROLLMENT_MAX_WAIT_MS`). */ + readonly expiresAt: number; + intervalMs: number; + /** The last poll found the approving account full. */ + accountFull: boolean; + timer: ReturnType | null; +} + export class BurrowService { readonly #store: BurrowStateStore; readonly #provider: BurrowSurfaceProvider; @@ -401,6 +500,13 @@ export class BurrowService { */ readonly #pairings = new Map(); + /** The Hosted enrollment awaiting approval, polled by its own timer. */ + #enrollRun: HostedEnrollmentRun | null = null; + /** How the last one ended short of enrolling, until the next begin or a cancel. */ + #enrollEnded: { reason: HostedEnrollmentEndReason; message?: string } | null = null; + /** Bumped by every begin and cancel, so a begin still in flight that was superseded keeps nothing. */ + #enrollSeq = 0; + /** * The one-time connection, as `oneTimeStatus` answers it and the `one-time` * event carries it. Independent of the enrollment: it needs none, and @@ -505,6 +611,7 @@ export class BurrowService { dispose(): void { if (this.#disposed) return; this.#disposed = true; + this.#stopHostedEnrollment(); this.#stopBurrow(); const oneTime = this.#oneTime; this.#oneTime = null; @@ -536,6 +643,12 @@ export class BurrowService { return this.#serialize(() => this.#enroll(params as EnrollParams)); case 'enrollOffer': return this.#serialize(() => this.#enrollOffer(params as EnrollOfferParams)); + // Off the chain: the begin is one request and writes nothing; the poll's + // enrolled answer takes the lease for its persist-then-start. + case 'beginHostedEnrollment': + return this.#beginHostedEnrollment(params as HostedEnrollParams | undefined); + case 'cancelHostedEnrollment': + return this.#cancelHostedEnrollment(); case 'status': return this.#status(); case 'reconnect': @@ -582,7 +695,7 @@ export class BurrowService { // --- Commands --- async #enroll(params: EnrollParams): Promise { - if (!canEnroll(this.#relay)) throw new Error(HOSTED_ENROLLMENT_REFUSAL); + if (enrollmentMethod(this.#relay) !== 'password') throw new Error(HOSTED_ENROLLMENT_REFUSAL); await this.#networkPolicy(); this.#refuseNothing(); this.#refuseOtherOrigin((params as { relayUrl?: unknown }).relayUrl); @@ -621,7 +734,7 @@ export class BurrowService { * (`docs/specs/relay.md` → "Remote control, in the Settings dialog"). */ async #enrollOffer(params: EnrollOfferParams): Promise { - if (!canEnroll(this.#relay)) throw new Error(HOSTED_ENROLLMENT_REFUSAL); + if (enrollmentMethod(this.#relay) !== 'password') throw new Error(HOSTED_ENROLLMENT_REFUSAL); await this.#networkPolicy(); this.#refuseNothing(); this.#refuseOtherOrigin((params as { origin?: unknown }).origin); @@ -645,14 +758,26 @@ export class BurrowService { */ async #enrollWith(credential: BurrowEnrollCredential, label: string): Promise { const enrollment = await performEnrollment(this.#relay.origin, credential, label, this.#fetch); + await this.#adoptEnrollment( + enrollment, + `The Relay has already recorded Burrow ${enrollment.burrowId}; remove it from burrows.json.`, + ); + return { burrowId: enrollment.burrowId }; + } + + /** + * Hold an enrollment either exchange just minted: the Relay's own origin + * checked against the baked one, then store-first persistence and the + * status edge the webview gate needs. **Runs on `#serialize`.** `leftBehind` + * names, for the operator, the Burrow the Relay recorded for an origin this + * build refuses. + */ + async #adoptEnrollment(enrollment: BurrowEnrollment, leftBehind: string): Promise { if (!isRelayOrigin(enrollment.origin, this.#relay.origin)) { // An older Relay, which ignores the request's `origin` and so enrolled a // Burrow built for another: nothing is persisted here, and the row it // appended is named for the operator (docs/specs/relay.md → "Relay origin"). - throw new Error( - `${originMismatchMessage(enrollment.origin, this.#relay.origin)} The Relay has already ` + - `recorded Burrow ${enrollment.burrowId}; remove it from burrows.json.`, - ); + throw new Error(`${originMismatchMessage(enrollment.origin, this.#relay.origin)} ${leftBehind}`); } // Persist before touching the running Burrow. The credential we just minted // exists nowhere else and cannot be minted again from the same exchange — a @@ -674,7 +799,186 @@ export class BurrowService { this.#emitStatus(); } await this.#startBurrow(enrollment); - return { burrowId: enrollment.burrowId }; + // A Burrow the level holds without running announced nothing; `start()` + // says the same for the same reason. + if (!this.#burrow) this.#emitStatus(); + } + + // --- Hosted enrollment (`docs/specs/hosted.md` -> "Burrow enrollment") --- + + /** + * Begin a device-code enrollment and poll it from here, answering the + * `waiting` state the panel draws. Replaces one already waiting, or ended. + * Refused in a self-host build, and under `nothing` before any request. + */ + async #beginHostedEnrollment(params: HostedEnrollParams | undefined): Promise { + if (enrollmentMethod(this.#relay) !== 'device-code') throw new Error(SELF_HOST_DEVICE_CODE_REFUSAL); + const named = typeof params?.label === 'string' ? params.label.trim() : ''; + const label = named || suggestedBurrowLabel(this.#kind); + await this.#networkPolicy(); + this.#refuseNothing(); + this.#stopHostedEnrollment(); + const seq = this.#enrollSeq; + const { begin, noiseStatic } = await beginHostedEnrollment(this.#relay.origin, this.#fetch); + if (seq !== this.#enrollSeq || this.#disposed) { + throw new Error('This enrollment was cancelled.'); + } + const verificationUrl = enrollVerificationUrl(this.#relay, begin); + const now = this.#now(); + const wait = Math.min( + Math.max(begin.expiresAt - now, HOSTED_ENROLLMENT_MIN_WAIT_MS), + HOSTED_ENROLLMENT_MAX_WAIT_MS, + ); + const run: HostedEnrollmentRun = { + deviceCode: begin.deviceCode, + noiseStatic, + label, + userCode: begin.userCode, + verificationUrl, + expiresAt: now + wait, + intervalMs: begin.interval * 1000, + accountFull: false, + timer: null, + }; + this.#enrollRun = run; + this.#schedulePoll(run); + this.#emitStatus(); + return this.#hostedEnrollmentState()!; + } + + /** Stop the enrollment waiting or ended, and say so; a poll in flight is dropped. */ + #cancelHostedEnrollment(): Record { + const had = this.#enrollRun !== null || this.#enrollEnded !== null; + this.#stopHostedEnrollment(); + if (had) this.#emitStatus(); + return {}; + } + + /** Forget any Hosted enrollment, unannounced, and void a begin in flight. */ + #stopHostedEnrollment(): void { + this.#enrollSeq++; + if (this.#enrollRun?.timer) clearTimeout(this.#enrollRun.timer); + this.#enrollRun = null; + this.#enrollEnded = null; + } + + /** The next poll after `run.intervalMs`, or at the deadline if that comes first. */ + #schedulePoll(run: HostedEnrollmentRun): void { + const delay = Math.max(0, Math.min(run.intervalMs, run.expiresAt - this.#now())); + run.timer = setTimeout(() => void this.#pollHostedEnrollment(run), delay); + } + + async #pollHostedEnrollment(run: HostedEnrollmentRun): Promise { + run.timer = null; + if (this.#enrollRun !== run) return; + if (this.#now() >= run.expiresAt) { + this.#endHostedEnrollment(run, { reason: 'expired' }); + return; + } + const answer = await pollHostedEnrollment( + this.#relay.origin, + run.deviceCode, + run.label, + run.noiseStatic, + this.#fetch, + ); + if (this.#enrollRun !== run) { + // Cancelled, replaced, or disposed while the poll was out. A redemption + // that lands now is the account's to remove: nothing here holds it. + if (answer.status === 'enrolled') { + console.warn( + `[burrow] a cancelled enrollment redeemed as Burrow ${answer.enrollment.burrowId}; ` + + 'remove it from the account.', + ); + } + return; + } + switch (answer.status) { + case 'pending': + this.#setAccountFull(run, false); + this.#schedulePoll(run); + return; + case 'retry': + if (answer.slowDown) { + run.intervalMs = Math.min( + run.intervalMs + HOSTED_ENROLLMENT_SLOW_DOWN_MS, + MAX_ENROLL_POLL_INTERVAL_S * 1000, + ); + } + this.#schedulePoll(run); + return; + case 'expired': + this.#endHostedEnrollment(run, { reason: 'expired' }); + return; + // The Relay keeps an approval it refused, so a full account polls on + // and enrolls once a computer is removed; an entitlement ends it. + case 'refused': + if (answer.reason === 'account-full') { + this.#setAccountFull(run, true); + this.#schedulePoll(run); + } else { + this.#endHostedEnrollment(run, { reason: answer.reason }); + } + return; + case 'failed': + this.#endHostedEnrollment(run, { reason: 'failed', message: answer.message }); + return; + case 'enrolled': { + // Redeemed, and single-use: the credential exists nowhere else, so it + // is held whatever lands next, and a cancel finds nothing to stop. + this.#enrollRun = null; + const { enrollment } = answer; + try { + await this.#serialize(() => + this.#adoptEnrollment( + enrollment, + `The Relay has already recorded Burrow ${enrollment.burrowId}; remove it from your account.`, + ), + ); + } catch (error) { + if (this.#enrollRun !== null || this.#disposed) return; + this.#enrollEnded = { + reason: 'failed', + message: error instanceof Error ? error.message : String(error), + }; + this.#emitStatus(); + } + return; + } + } + } + + #setAccountFull(run: HostedEnrollmentRun, full: boolean): void { + if (run.accountFull === full) return; + run.accountFull = full; + this.#emitStatus(); + } + + /** End `run` short of enrolling, if it is still the one waiting. */ + #endHostedEnrollment( + run: HostedEnrollmentRun, + ended: { reason: HostedEnrollmentEndReason; message?: string }, + ): void { + if (this.#enrollRun !== run) return; + if (run.timer) clearTimeout(run.timer); + this.#enrollRun = null; + this.#enrollEnded = ended; + this.#emitStatus(); + } + + /** What `status` reports of the Hosted enrollment: never its device code. */ + #hostedEnrollmentState(): HostedEnrollmentState | null { + const run = this.#enrollRun; + if (run) { + return { + status: 'waiting', + userCode: run.userCode, + verificationUrl: run.verificationUrl, + expiresAt: run.expiresAt, + accountFull: run.accountFull, + }; + } + return this.#enrollEnded ? { status: 'ended', ...this.#enrollEnded } : null; } /** @@ -693,7 +997,10 @@ export class BurrowService { async #status(): Promise { const offer = this.#enrollment ? null : await readUsableOffer(this.#relay, this.#readOffer); const enrollment = this.#enrollment; - if (!enrollment) return unenrolledStatus(offer, this.#kind, this.#relay, this.#serving()); + const hostedEnrollment = this.#hostedEnrollmentState(); + if (!enrollment) { + return unenrolledStatus(offer, this.#kind, this.#relay, this.#serving(), hostedEnrollment); + } return { enrolled: true, serving: this.#serving(), @@ -704,6 +1011,7 @@ export class BurrowService { pairedClients: this.#burrow?.activeRecords.length ?? 0, suggestedLabel: suggestedBurrowLabel(this.#kind), offer: false, + hostedEnrollment, }; } @@ -969,6 +1277,8 @@ export class BurrowService { const previous = await this.#networkPolicy().catch(() => nothingPolicy()); await this.#store.saveNetworkPolicy(next); this.#policy = next; + // Nothing polls nothing: an enrollment awaiting approval ends with the level. + if (next.level === 'nothing') this.#cancelHostedEnrollment(); if (!samePaths(previous, next)) { this.#oneTime?.end('user-ended'); this.#restOneTime(); diff --git a/lib/src/host/remote/test-burrow-link.ts b/lib/src/host/remote/test-burrow-link.ts index 4878b8691..1969943e0 100644 --- a/lib/src/host/remote/test-burrow-link.ts +++ b/lib/src/host/remote/test-burrow-link.ts @@ -52,6 +52,7 @@ export const UNENROLLED_STATUS: BurrowConsoleStatus = { pairedClients: 0, suggestedLabel: 'ned-mac', offer: false, + hostedEnrollment: null, }; /** diff --git a/lib/src/remote/burrow/burrow-status-store.test.ts b/lib/src/remote/burrow/burrow-status-store.test.ts index 8a86c3d56..2230cd9b1 100644 --- a/lib/src/remote/burrow/burrow-status-store.test.ts +++ b/lib/src/remote/burrow/burrow-status-store.test.ts @@ -361,3 +361,52 @@ describe('an answer from an older broker', () => { } }); }); + +describe('a Hosted enrollment in the status', () => { + it('reads one waiting without republishing it, and none from a broker without the field', async () => { + vi.useFakeTimers(); + let hostedEnrollment: unknown = { + status: 'waiting', + userCode: '23AB-YZ9K', + verificationUrl: 'https://hosted.dormouse.sh/enroll#23AB-YZ9K', + expiresAt: 1_800_000_000_000, + accountFull: false, + }; + const command = vi.fn(async () => ({ + enrolled: false, + serving: false, + relayOrigin: 'https://relay.dormouse.sh', + relayMode: 'hosted', + burrowId: null, + connection: 'stopped', + pairedClients: 0, + suggestedLabel: 'ned-mac', + offer: false, + // A fresh object every answer, as the bridge delivers it. + ...(hostedEnrollment === undefined ? {} : { hostedEnrollment: structuredClone(hostedEnrollment) }), + })); + burrowLink = { command, respond: () => {}, notify: () => {}, on: () => () => {} }; + const listener = vi.fn(); + const unsubscribe = subscribeToBurrowStatus(listener); + const shown = () => (getBurrowStatusSnapshot() as { status: { hostedEnrollment: unknown } }).status.hostedEnrollment; + try { + await vi.advanceTimersByTimeAsync(0); + expect(shown()).toEqual(hostedEnrollment); + await vi.advanceTimersByTimeAsync(3 * 2000); + expect(listener).toHaveBeenCalledTimes(1); + + // A reason this build does not know is a failure with no sentence. + hostedEnrollment = { status: 'ended', reason: 'toString' }; + await vi.advanceTimersByTimeAsync(2000); + expect(shown()).toEqual({ status: 'ended', reason: 'failed' }); + + for (const malformed of [undefined, null, { status: 'waiting', userCode: 7 }, { status: 'gone' }]) { + hostedEnrollment = malformed; + await vi.advanceTimersByTimeAsync(2000); + expect(shown(), JSON.stringify(malformed)).toBeNull(); + } + } finally { + unsubscribe(); + } + }); +}); diff --git a/lib/src/remote/burrow/burrow-status-store.ts b/lib/src/remote/burrow/burrow-status-store.ts index 87a8c1d20..97a70d320 100644 --- a/lib/src/remote/burrow/burrow-status-store.ts +++ b/lib/src/remote/burrow/burrow-status-store.ts @@ -18,6 +18,7 @@ import { servingOf, + type HostedEnrollmentState, type InvitationEvent, type PushSendSummary, type BurrowConsoleStatus, @@ -110,6 +111,9 @@ const STATUS_FIELDS: { pairedClients: Object.is, suggestedLabel: Object.is, offer: Object.is, + // A fresh object every poll, so field by field: a reference compare would + // republish every 2 s while a code waits. + hostedEnrollment: (a, b) => JSON.stringify(a) === JSON.stringify(b), }; function sameState(a: BurrowStatusState, b: BurrowStatusState): boolean { @@ -258,9 +262,33 @@ function normalizeStatus(status: BurrowConsoleStatus): BurrowConsoleStatus { relayOrigin: status.relayOrigin ?? (typeof relayUrl === 'string' ? relayUrl : ''), relayMode: status.relayMode === 'self-host' ? 'self-host' : 'hosted', offer: Boolean(status.offer), + hostedEnrollment: hostedEnrollmentOf(status.hostedEnrollment), }; } +/** + * A `hostedEnrollment` as this build can draw it, or `null` — absent from a + * broker older than the field, or of a shape this build does not know. Built + * field by field, in a fixed order, which {@link STATUS_FIELDS} compares on. + */ +function hostedEnrollmentOf(value: unknown): HostedEnrollmentState | null { + if (!value || typeof value !== 'object') return null; + const state = value as Record; + if (state.status === 'waiting') { + const { userCode, verificationUrl, expiresAt } = state; + return typeof userCode === 'string' && typeof verificationUrl === 'string' && typeof expiresAt === 'number' + ? { status: 'waiting', userCode, verificationUrl, expiresAt, accountFull: state.accountFull === true } + : null; + } + if (state.status !== 'ended') return null; + const reason = HOSTED_ENROLLMENT_END_REASONS.find((known) => known === state.reason) ?? 'failed'; + return typeof state.message === 'string' + ? { status: 'ended', reason, message: state.message } + : { status: 'ended', reason }; +} + +const HOSTED_ENROLLMENT_END_REASONS = ['expired', 'not-entitled', 'failed'] as const; + async function readBurrowStatus(): Promise { const active = burrowLink(); if (!active) { @@ -310,6 +338,28 @@ export async function enrollOfferBurrow(label: string): Promise { await refreshAfterMutation(); } +/** + * Begin a Hosted build's device-code enrollment under `label`; the service + * polls it and reports through `status` (`HostedEnrollmentState`). Rejections + * propagate verbatim — the caller renders them. Re-reads either way, since a + * begin replaces whatever enrollment was waiting or ended. + */ +export async function beginHostedEnrollment(label: string): Promise { + const active = requireBurrowLink(); + try { + await active.command('beginHostedEnrollment', { label }); + } finally { + await refreshAfterMutation(); + } +} + +/** Stop the Hosted enrollment waiting or ended, and re-read. */ +export async function cancelHostedEnrollment(): Promise { + const active = requireBurrowLink(); + await active.command('cancelHostedEnrollment'); + await refreshAfterMutation(); +} + /** * Take the relay slot back after `displaced` — which is terminal by design, so * nothing reconnects on its own. This displaces the other instance in turn diff --git a/lib/src/remote/burrow/enrollment.test.ts b/lib/src/remote/burrow/enrollment.test.ts index b53782d67..7d7a51080 100644 --- a/lib/src/remote/burrow/enrollment.test.ts +++ b/lib/src/remote/burrow/enrollment.test.ts @@ -1,14 +1,22 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { BAD_PASSWORD_ERROR, + NOT_ENTITLED_ERROR, ORIGIN_MISMATCH_ERROR, + RELAY_BEARER_LENGTH, UNAUTHORIZED_ERROR, fromBase64Url, mintNoiseStaticKeyPair, toBase64Url, } from 'remote-lib-common'; import { TEST_SETUP_PASSWORD } from '../test-setup-password'; -import { isEnrollment, performEnrollment } from './enrollment'; +import { + beginHostedEnrollment, + isEnrollment, + performEnrollment, + pollHostedEnrollment, + type EnrollmentStatic, +} from './enrollment'; // A real `burrowId`: base64url of 16 bytes, the one shape `isEnrollment` // accepts, because it is also the routing id every `e2e` envelope carries. @@ -431,3 +439,117 @@ describe('burrow enrollment', () => { }); }); + +describe('Hosted device-code enrollment', () => { + afterEach(() => vi.unstubAllGlobals()); + + const RELAY = 'https://relay.dormouse.sh'; + const BEGIN = { + deviceCode: 'D'.repeat(RELAY_BEARER_LENGTH), + userCode: '23AB-YZ9K', + verificationUrl: 'https://hosted.dormouse.sh/enroll#23AB-YZ9K', + expiresAt: 1_800_000_000_000, + interval: 5, + }; + const json = (status: number, body: unknown) => + vi.fn(async () => new Response(JSON.stringify(body), { status })); + + it('begins with the baked origin alone, minting the Noise static first', async () => { + const fetchMock = json(200, BEGIN); + const { begin, noiseStatic } = await beginHostedEnrollment(RELAY, fetchMock as unknown as typeof fetch); + + expect(begin).toEqual(BEGIN); + expect(fetchMock.mock.calls[0]![0]).toBe(`${RELAY}/api/burrow/enroll/begin`); + const init = fetchMock.mock.calls[0]![1] as RequestInit; + expect(init.redirect).toBe('error'); + expect(JSON.parse(init.body as string)).toEqual({ origin: RELAY }); + expect(JSON.stringify(init.body)).not.toContain(noiseStatic.noiseStaticPublicKey); + + // A runtime that cannot mint fails before the Relay is asked anything. + vi.mocked(mintNoiseStaticKeyPair).mockRejectedValueOnce(new Error('no X25519 here')); + const unasked = json(200, BEGIN); + await expect(beginHostedEnrollment(RELAY, unasked as unknown as typeof fetch)).rejects.toThrow( + /cannot generate the X25519 key/, + ); + expect(unasked).not.toHaveBeenCalled(); + }); + + it('refuses a begin answer that is not an enrollment code, and names a refused origin', async () => { + await expect( + beginHostedEnrollment(RELAY, json(200, { ...BEGIN, interval: 0 }) as unknown as typeof fetch), + ).rejects.toThrow(/not an enrollment code/); + await expect( + beginHostedEnrollment( + RELAY, + json(409, { error: ORIGIN_MISMATCH_ERROR, origin: 'https://relay.example.com' }) as unknown as typeof fetch, + ), + ).rejects.toThrow(/The Relay says its origin is https:\/\/relay.example.com/); + }); + + describe('a poll', () => { + let noiseStatic: EnrollmentStatic; + beforeAll(async () => { + const material = await mintNoiseStaticKeyPair(); + noiseStatic = { noiseStaticPrivateKey: material.privateKeyPkcs8, noiseStaticPublicKey: material.publicKey }; + }); + const poll = (fetchMock: unknown) => + pollHostedEnrollment(RELAY, BEGIN.deviceCode, 'My Laptop', noiseStatic, fetchMock as typeof fetch); + + it('posts the device code alone, and maps an enrolled answer through the enrollment guard', async () => { + const enrollment = { burrowId: BURROW_ID, burrowToken: 'tok', origin: RELAY, rpId: 'relay.dormouse.sh' }; + const fetchMock = json(200, { status: 'enrolled', enrollment }); + + const answer = await poll(fetchMock); + + expect(fetchMock.mock.calls[0]![0]).toBe(`${RELAY}/api/burrow/enroll/poll`); + const init = fetchMock.mock.calls[0]![1] as RequestInit; + expect(init.redirect).toBe('error'); + expect(JSON.parse(init.body as string)).toEqual({ deviceCode: BEGIN.deviceCode }); + expect(answer).toEqual({ + status: 'enrolled', + enrollment: { relayUrl: RELAY, ...enrollment, label: 'My Laptop', ...noiseStatic }, + }); + expect(isEnrollment((answer as { enrollment: unknown }).enrollment)).toBe(true); + // A redemption the guard refuses fails, naming the field. + expect(await poll(json(200, { status: 'enrolled', enrollment: { ...enrollment, burrowId: 'short' } }))) + .toEqual({ status: 'failed', message: expect.stringContaining('burrowId') }); + }); + + it('reads pending and expired as they are', async () => { + expect(await poll(json(200, { status: 'pending' }))).toEqual({ status: 'pending' }); + expect(await poll(json(200, { status: 'expired' }))).toEqual({ status: 'expired' }); + }); + + it('retries what told it nothing, slowing down on a 429', async () => { + expect(await poll(vi.fn(async () => Promise.reject(new Error('offline'))))).toEqual({ + status: 'retry', + slowDown: false, + }); + expect(await poll(json(503, { error: 'unavailable' }))).toEqual({ status: 'retry', slowDown: false }); + expect(await poll(json(429, { error: 'too many enrollment attempts' }))).toEqual({ + status: 'retry', + slowDown: true, + }); + }); + + it('refuses for the two reasons the panel words, and fails on anything else', async () => { + expect(await poll(json(403, { error: NOT_ENTITLED_ERROR }))).toEqual({ + status: 'refused', + reason: 'not-entitled', + }); + expect(await poll(json(409, { error: 'this account already has 32 computers enrolled' }))).toEqual({ + status: 'refused', + reason: 'account-full', + }); + // A 403 the Relay did not raise for the entitlement is not one. + expect(await poll(json(403, { error: 'forbidden' }))).toEqual({ + status: 'failed', + message: 'The Relay refused the enrollment (HTTP 403): {"error":"forbidden"}', + }); + expect(await poll(json(200, { status: 'approved' }))).toEqual({ + status: 'failed', + message: expect.stringContaining('not an enrollment poll'), + }); + }); + }); +}); diff --git a/lib/src/remote/burrow/enrollment.ts b/lib/src/remote/burrow/enrollment.ts index 53e892e56..734923adf 100644 --- a/lib/src/remote/burrow/enrollment.ts +++ b/lib/src/remote/burrow/enrollment.ts @@ -6,12 +6,17 @@ import { API_ROUTES, BAD_PASSWORD_ERROR, + NOT_ENTITLED_ERROR, ORIGIN_MISMATCH_ERROR, UNAUTHORIZED_ERROR, + isBurrowEnrollBeginResponse, isE2eId, isNoiseStaticMaterial, mintNoiseStaticKeyPair, normalizeOrigin, + type BurrowEnrollBeginRequest, + type BurrowEnrollBeginResponse, + type BurrowEnrollPollRequest, type BurrowEnrollRequest, type BurrowEnrollResponse, } from 'remote-lib-common'; @@ -259,6 +264,20 @@ export async function performEnrollment( } catch (error) { throw new Error(`Could not enroll: the Relay did not answer JSON (${errorMessage(error)})`); } + return enrollmentFrom(relayOrigin, body, label, noiseStatic); +} + +/** + * A Relay's {@link BurrowEnrollResponse} as this Burrow's enrollment, or a + * throw naming what it got wrong: one mapping for both exchanges, so the + * password and the device code mint the same shape through {@link isEnrollment}. + */ +function enrollmentFrom( + relayOrigin: string, + body: unknown, + label: string, + noiseStatic: EnrollmentStatic, +): BurrowEnrollment { const enrolled = body as Partial | null; const enrollment = { relayUrl: relayOrigin, @@ -277,8 +296,8 @@ export async function performEnrollment( ...(typeof enrolled?.requireUserVerification === 'boolean' ? { requireUserVerification: enrolled.requireUserVerification } : {}), - // Minted above and never sent to the Relay. Persisting it is the caller's - // job, alongside `burrowToken`. + // Minted before the first request and never sent to the Relay. Persisting + // it is the caller's job, alongside `burrowToken`. ...noiseStatic, }; if (!isEnrollment(enrollment)) { @@ -289,6 +308,116 @@ export async function performEnrollment( return enrollment; } +// --- Hosted's device code (`docs/specs/hosted.md` -> "Burrow enrollment") --- + +/** A begun device-code enrollment: the Relay's checked answer, and the static minted before it. */ +export interface HostedEnrollmentBegun { + begin: BurrowEnrollBeginResponse; + noiseStatic: EnrollmentStatic; +} + +/** + * `POST /api/burrow/enroll/begin` with the baked origin, the answer checked by + * `isBurrowEnrollBeginResponse`. Throws what the settings panel shows. The + * Noise static is minted first, as {@link performEnrollment} mints it, so a + * runtime that cannot make one fails before the Relay is asked anything; the + * caller holds it until a poll redeems, and the Relay never sees it. + */ +export async function beginHostedEnrollment( + relayOrigin: string, + // The service's guarded fetch, as for `performEnrollment`; no default. + fetch: typeof globalThis.fetch, +): Promise { + const noiseStatic = await mintNoiseStatic(); + const response = await fetch(`${relayOrigin}${API_ROUTES.burrowEnrollBegin}`, { + method: 'POST', + signal: AbortSignal.timeout(BURROW_REQUEST_TIMEOUT_MS), + redirect: 'error', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ origin: relayOrigin } satisfies BurrowEnrollBeginRequest), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(refusalMessage(response.status, detail, relayOrigin)); + } + const body: unknown = await response.json().catch(() => null); + if (!isBurrowEnrollBeginResponse(body)) { + throw new Error('Could not enroll: the Relay’s answer was not an enrollment code.'); + } + return { begin: body, noiseStatic }; +} + +/** + * What one poll says. `retry` is a poll that told nothing — the Relay + * unreachable, a 5xx, or a 429, which asks the Burrow to `slowDown` — and the + * next poll asks again; `refused` keeps the copy for its fixed reasons to the + * panel; `failed` names what went wrong. + */ +export type HostedEnrollmentPoll = + | { status: 'pending' } + | { status: 'retry'; slowDown: boolean } + | { status: 'expired' } + | { status: 'enrolled'; enrollment: BurrowEnrollment } + | { status: 'refused'; reason: 'not-entitled' | 'account-full' } + | { status: 'failed'; message: string }; + +/** + * `POST /api/burrow/enroll/poll` once. **Never throws**: the service's poll + * loop reads every outcome. An `enrolled` answer is mapped through the same + * {@link isEnrollment} guard as the password exchange's, with `label` and the + * static {@link beginHostedEnrollment} minted. + */ +export async function pollHostedEnrollment( + relayOrigin: string, + deviceCode: string, + label: string, + noiseStatic: EnrollmentStatic, + fetch: typeof globalThis.fetch, +): Promise { + let response: Response; + try { + response = await fetch(`${relayOrigin}${API_ROUTES.burrowEnrollPoll}`, { + method: 'POST', + signal: AbortSignal.timeout(BURROW_REQUEST_TIMEOUT_MS), + // The device code is a bearer until it expires. + redirect: 'error', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ deviceCode } satisfies BurrowEnrollPollRequest), + }); + } catch { + return { status: 'retry', slowDown: false }; + } + if (response.status === 429) return { status: 'retry', slowDown: true }; + if (response.status >= 500) return { status: 'retry', slowDown: false }; + if (!response.ok) { + const detail = await response.text().catch(() => ''); + if (response.status === 403 && refusedError(detail) === NOT_ENTITLED_ERROR) { + return { status: 'refused', reason: 'not-entitled' }; + } + // The poll's only 409: the account holds `MAX_ENROLLED_BURROWS` already. + if (response.status === 409) return { status: 'refused', reason: 'account-full' }; + return { status: 'failed', message: refusalMessage(response.status, detail, relayOrigin) }; + } + const body: unknown = await response.json().catch(() => null); + const status = (body as { status?: unknown } | null)?.status; + if (status === 'pending' || status === 'expired') return { status }; + if (status !== 'enrolled') { + return { status: 'failed', message: 'Could not enroll: the Relay’s answer was not an enrollment poll.' }; + } + try { + const enrolled = (body as { enrollment?: unknown }).enrollment; + return { status: 'enrolled', enrollment: enrollmentFrom(relayOrigin, enrolled, label, noiseStatic) }; + } catch (error) { + return { status: 'failed', message: errorMessage(error) }; + } +} + +/** The Noise static an enrollment carries, minted before its first request. */ +export interface EnrollmentStatic { + noiseStaticPrivateKey: string; + noiseStaticPublicKey: string; +} + /** * This Burrow's Noise static. **A runtime that cannot mint one does not enroll.** * @@ -299,10 +428,7 @@ export async function performEnrollment( * than leaving the operator with a Burrow that enrolled and then does nothing * (`docs/specs/remote-security-model.md` → Noise suite). */ -async function mintNoiseStatic(): Promise<{ - noiseStaticPrivateKey: string; - noiseStaticPublicKey: string; -}> { +async function mintNoiseStatic(): Promise { let material; try { material = await mintNoiseStaticKeyPair(); diff --git a/lib/src/stories/RemoteControlSection.stories.tsx b/lib/src/stories/RemoteControlSection.stories.tsx index 57cc46160..dd41caf39 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,21 +103,105 @@ 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/), +}; + +/** Approved by an account not entitled to the Hosted Relay: ended, in fixed copy. */ +export const HostedEnrollNotEntitled: Story = { + parameters: { + primedBurrow: { + status: { ...UNENROLLED_STATUS, hostedEnrollment: { status: 'ended', reason: 'not-entitled' } }, + }, + docs: { story: { height: '520px' } }, + }, + play: settled(/can’t use the Hosted Relay/), +}; + +/** Nobody approved the code in time. */ +export const HostedEnrollExpired: Story = { + parameters: { + primedBurrow: { status: { ...UNENROLLED_STATUS, hostedEnrollment: { status: 'ended', reason: 'expired' } } }, + docs: { story: { height: '520px' } }, + }, + play: settled(/expired before it was approved/), +}; + +/** 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' }, + }, + }, + docs: { story: { height: '540px' } }, + }, + play: settled('keychain is locked'), +}; + +/** + * 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', connection: 'stopped' }), + }, + }, + play: settled('Manage computers at hosted.dormouse.sh'), +}; + /** * A self-host build that has never enrolled, Persistent Relay unfolded: the * origin it was built for over the typed form — setup password, name. Its diff --git a/remote-lib-common/src/remote/wire.ts b/remote-lib-common/src/remote/wire.ts index ca591fbb4..392fffeb4 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'; @@ -384,6 +385,40 @@ 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; } diff --git a/remote-lib-common/test/wire.test.mjs b/remote-lib-common/test/wire.test.mjs index 4053b9fee..84f9fd7e8 100644 --- a/remote-lib-common/test/wire.test.mjs +++ b/remote-lib-common/test/wire.test.mjs @@ -22,6 +22,10 @@ import { isE2eId, isE2eRelayToBurrowFrame, isSetupTokenResponse, + isBurrowEnrollBeginResponse, + MAX_ENROLL_POLL_INTERVAL_S, + MIN_ENROLL_POLL_INTERVAL_S, + RELAY_BEARER_LENGTH, pushSubscriptionDeletePath, } from '../dist/index.js'; @@ -299,3 +303,42 @@ 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)); + } +}); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index db6679e67..292726660 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -18,7 +18,7 @@ "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/one-time.md": 3850, "docs/specs/pocket-app.md": 5100, - "docs/specs/relay.md": 10250, + "docs/specs/relay.md": 10700, "docs/specs/remote-api.md": 5250, "docs/specs/remote-network.md": 2450, "docs/specs/remote-security-model.md": 5450, @@ -26,7 +26,7 @@ "docs/specs/security-ci.md": 2950, "docs/specs/security-hosted.md": 2350, "docs/specs/security-local.md": 3850, - "docs/specs/security-remote.md": 7050, + "docs/specs/security-remote.md": 7150, "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..2667989c7 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) { @@ -567,6 +572,8 @@ async function idleAnswer(cmd: string): Promise<{ result: unknown } | null> { 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..79fba6e9c 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,7 @@ describe('burrow service glue', () => { pairedClients: 0, suggestedLabel: `${hostname()} (VS Code)`, offer: true, + hostedEnrollment: null, }, }, ]); @@ -1008,12 +1030,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 +1075,7 @@ describe('burrow service glue', () => { 'oneTimeStatus', 'networkPolicy', 'oneTimeEnd', + 'cancelHostedEnrollment', 'takeBack', ]) { await idle.handleCommand({ burrowRequestId: `rh-${cmd}`, cmd, params: { holder: 'nobody' } }); From a03f990889ed10551679d6fb4cf2bf069a27c4d5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 1 Oct 2026 01:37:06 -0700 Subject: [PATCH 02/13] Keep a Hosted enrollment through redemption, late answers, and lost answers Review fixes: the run stays redeeming until its save lands, a late redemption is adopted rather than dropped, a lost poll answer reads as redeemed from a marker the Relay keeps until expiry, a second window joins the waiting code instead of replacing it, a failed begin keeps the old code, and the account links follow one origin. Co-Authored-By: Claude Opus 5.5 --- .github/audit/hosted.md | 3 +- docs/specs/hosted.md | 5 +- docs/specs/relay.md | 42 +++- docs/specs/security-hosted.md | 2 +- .../server/dormouse-migrations/002_relay.sql | 10 +- hosted/server/relay-account.ts | 8 +- hosted/server/relay-api.ts | 28 ++- hosted/server/tests/relay.test.ts | 24 +- hosted/server/tests/workers.test.ts | 17 ++ .../components/RemoteControlSection.test.tsx | 76 +++++- lib/src/components/RemoteControlSection.tsx | 117 +++++++-- lib/src/host/relay-origin.ts | 3 + lib/src/host/remote/service-protocol.ts | 31 ++- lib/src/host/remote/service.test.ts | 234 ++++++++++++++++-- lib/src/host/remote/service.ts | 182 ++++++++++---- lib/src/host/remote/test-burrow-link.ts | 2 + lib/src/remote/burrow/activation.test.ts | 15 +- lib/src/remote/burrow/activation.ts | 6 + .../remote/burrow/burrow-status-store.test.ts | 12 + lib/src/remote/burrow/burrow-status-store.ts | 17 +- lib/src/remote/burrow/enrollment.test.ts | 4 +- lib/src/remote/burrow/enrollment.ts | 6 +- .../stories/RemoteControlSection.stories.tsx | 42 +++- remote-lib-common/src/remote/wire.ts | 5 +- scripts/spec-word-budgets.json | 2 +- vscode-ext/test/burrow.test.ts | 57 +++++ 26 files changed, 804 insertions(+), 146 deletions(-) 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/docs/specs/hosted.md b/docs/specs/hosted.md index 09858e407..65404d042 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -158,13 +158,14 @@ A Burrow joins an account by device code, in place of the self-host setup passwo 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` once an earlier poll enrolled, until the approval expires. 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). - **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 | |---|---|---| diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 43d8ab45c..ed61579a1 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -808,16 +808,24 @@ memo invalidation — live in that burrow's spec. 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 }` + `beginHostedEnrollment` `{ label, replace? }` 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**, + unless `replace` is set ("Get a new code"): another VS Code window's Enroll + reaches the same service. A begin in flight is joined. + - **Never change what is waiting or ended until a replacing begin has its + code.** - **The device code never leaves the service**, as `burrowToken` does not: `status` carries `hostedEnrollment` — `waiting` with `userCode`, - `verificationUrl`, `expiresAt`, and `accountFull`, or `ended` with a - reason — and each change is a `status` event. + `verificationUrl`, `expiresAt`, and `accountFull`; `redeeming`; or `ended` + with a reason from `HOSTED_ENROLLMENT_END_REASONS` — 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 @@ -832,10 +840,16 @@ memo invalidation — live in that burrow's spec. `failed` with the service's sentence. **A transport failure, a 5xx, or a 429 polls again**, a 429 adding 5 s to the interval, up to 60 s; **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`. - **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. One redeemed after a Cancel is - dropped with a warning naming the Burrow, for the account to remove. + 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 or a replacing 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). * **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 @@ -897,7 +911,8 @@ memo invalidation — live in that burrow's spec. Source of truth: `lib/src/host/remote/service.ts` (`BurrowService`, `#enrollWith`, `#adoptEnrollment`, `#beginHostedEnrollment`, -`#pollHostedEnrollment`, `enrollVerificationUrl`, `#status`, `#setupQr`, +`#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` (`performEnrollment`, @@ -992,14 +1007,17 @@ exists to honor: `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" and Done, which cancels. **The name field is hidden while a + "Get a new code" (`replace`) and Done, which cancels; `answer-lost` 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 - hosted.dormouse.sh", linking `HOSTED_ACCOUNT_ORIGIN/account`. + ", 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`, a missing `relayMode` as Hosted, and a missing or malformed `hostedEnrollment` as `null`, an unknown end reason as - `failed`. + `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 @@ -1024,9 +1042,9 @@ bundle); `describePushTargets` in `lib/src/components/SettingsDialog.tsx`; 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` diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 0434bfba1..da0a7b415 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -50,7 +50,7 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. - **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/hosted/server/dormouse-migrations/002_relay.sql b/hosted/server/dormouse-migrations/002_relay.sql index c9d18de7a..0448fde7e 100644 --- a/hosted/server/dormouse-migrations/002_relay.sql +++ b/hosted/server/dormouse-migrations/002_relay.sql @@ -67,11 +67,17 @@ CREATE INDEX dormouse_relay_setup_tokens_expiry ON dormouse_relay_setup_tokens ( -- Device-code enrollment approvals: the account approved this user code, and -- the first poll whose device code derives it enrolls a Burrow owned by --- "userId" and deletes the row. Begin writes nothing. +-- "userId" and marks the row redeemed, which it stays until it expires, so a +-- poll whose answer was lost learns it was spent. Begin writes nothing. +-- "redeemedBurrowId" names no foreign key: removing that Burrow must never +-- make the approval redeemable again. CREATE TABLE dormouse_relay_enrollment_approvals ( "userCode" text PRIMARY KEY CHECK ("userCode" ~ '^[2-9A-HJKMNP-TV-Z]{4}-[2-9A-HJKMNP-TV-Z]{4}$'), "userId" text NOT NULL REFERENCES "user" (id) ON DELETE CASCADE, - "expiresAt" timestamptz NOT NULL + "expiresAt" timestamptz NOT NULL, + "redeemedBurrowId" text, + "redeemedAt" timestamptz, + CHECK (("redeemedBurrowId" IS NULL) = ("redeemedAt" IS NULL)) ); CREATE INDEX dormouse_relay_enrollment_approvals_user ON dormouse_relay_enrollment_approvals ("userId"); CREATE INDEX dormouse_relay_enrollment_approvals_expiry ON dormouse_relay_enrollment_approvals ("expiresAt"); 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..6ad1b4b16 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -489,13 +489,16 @@ 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."redeemedAt" IS NOT NULL AS redeemed, ${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.redeemed) return answer({ status: "redeemed" }); 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 +513,30 @@ 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 { rowCount: redeemed } = await db.query( + `SELECT 1 FROM dormouse_relay_enrollment_approvals + WHERE "userCode" = $1 AND "expiresAt" > now() AND "redeemedAt" IS NOT NULL`, + [userCode], + ); + return redeemed ? "redeemed" : "expired"; }); if (outcome === "full") { const where = c.env.ACCOUNT_ORIGIN; @@ -535,7 +549,7 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { 409, ); } - if (outcome === "spent") return answer({ status: "expired" }); + if (outcome !== "enrolled") return answer({ status: outcome }); return answer({ status: "enrolled", // The Burrow enforces `origin`/`rpId` as its ConnectionPolicy; Hosted diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index b789f7500..113435156 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -879,10 +879,22 @@ 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 }]); + expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "redeemed" }); + 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" }); + 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 }) => { @@ -920,6 +932,9 @@ test("two polls racing one approval mint one Burrow", async ({ onTestFinished }) 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); + // Every other poll learns the approval was spent, whether it read it + // spent or lost the race at the lock. + expect(answers.filter(({ json }) => json!.status === "redeemed")).toHaveLength(3); } expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_burrows`)).toEqual([{ n: 5 }]); }); @@ -958,6 +973,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" } }); + await f.sql(`UPDATE "user" SET "emailVerified" = false WHERE id = $1`, [owner]); + expect(await f.poll(begun.deviceCode)).toMatchObject({ status: 200, json: { status: "redeemed" } }); }); 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..58f44a508 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" }); + 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/RemoteControlSection.test.tsx b/lib/src/components/RemoteControlSection.test.tsx index 90f3a55b2..8c1a61747 100644 --- a/lib/src/components/RemoteControlSection.test.tsx +++ b/lib/src/components/RemoteControlSection.test.tsx @@ -37,6 +37,7 @@ import { ONE_TIME_OUTCOME_LABEL, oneTimeEndedCopy } from './OneTimeConnection'; import { HOSTED_ENROLLMENT_CODE_LABEL, HOSTED_ENROLLMENT_ENDED_COPY, + HOSTED_ENROLLMENT_REDEEMING_COPY, PAIRING_OUTCOME_LABEL, RemoteControlSection, } from './RemoteControlSection'; @@ -396,12 +397,68 @@ describe('RemoteControlSection', () => { expect(text()).toContain(HOSTED_ENROLLMENT_ENDED_COPY.failed); expect(text()).toContain('keychain is locked'); + // A new code replaces whatever code is waiting by now; the first Enroll does not. await act(async () => buttonLabelled('Get a new code')!.click()); - expect(link.command).toHaveBeenCalledWith('beginHostedEnrollment', { label: NOT_ENROLLED.suggestedLabel }); + expect(link.command).toHaveBeenCalledWith('beginHostedEnrollment', { + label: NOT_ENROLLED.suggestedLabel, + replace: true, + }); 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 what it added', async () => { + const openExternal = vi.fn(); + platform = { + burrow: makeLink(async () => ({ ...NOT_ENROLLED, hostedEnrollment: { status: 'ended', reason: 'answer-lost' } })), + openExternal, + }; + await render(); + expect(text()).toContain(HOSTED_ENROLLMENT_ENDED_COPY['answer-lost']); + 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) => { @@ -418,7 +475,9 @@ describe('RemoteControlSection', () => { 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' })), + burrow: makeLink(async () => + enrolled({ relayOrigin: DEFAULT_RELAY_ORIGIN, relayMode: 'hosted', accountOrigin: 'https://hosted.dormouse.sh' }), + ), openExternal, }; await render(); @@ -426,6 +485,19 @@ describe('RemoteControlSection', () => { 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(); diff --git a/lib/src/components/RemoteControlSection.tsx b/lib/src/components/RemoteControlSection.tsx index 372738e75..cec20ef71 100644 --- a/lib/src/components/RemoteControlSection.tsx +++ b/lib/src/components/RemoteControlSection.tsx @@ -13,7 +13,7 @@ import { useMinutesLeft, } from './remote-control-shared'; import { ExpiringCode } from './ScannableCode'; -import { HOSTED_ACCOUNT_ORIGIN } from '../host/relay-origin'; +import { ACCOUNT_PAGE_PATH } from '../host/relay-origin'; import type { BurrowConsoleStatus, HostedEnrollmentEndReason, @@ -74,9 +74,6 @@ const TONE_CLASS = { const SELF_HOST_URL = 'https://dormouse.sh/self-host/'; -/** Where a Hosted account lists its enrolled computers, with Remove. */ -const ACCOUNT_PATH = '/account'; - /** * How far ahead of `expiresAt` the phone-setup panel mints a replacement code. * @@ -479,8 +476,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). @@ -525,7 +523,8 @@ function RelayChoices({ status }: { status: BurrowConsoleStatus }) { @@ -564,6 +563,7 @@ function UnenrolledRelay({ status }: { status: BurrowConsoleStatus }) { <>
@@ -607,18 +607,53 @@ export const HOSTED_ENROLLMENT_ENDED_COPY: Record; + accountOrigin: string | null; +}) { + const page = ended.reason === 'answer-lost' ? accountPage(accountOrigin) : null; + return ( +
+
+ {own(HOSTED_ENROLLMENT_ENDED_COPY, ended.reason) ?? HOSTED_ENROLLMENT_ENDED_COPY.failed} +
+ {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 @@ -632,9 +667,11 @@ function accountPageOf(verificationUrl: string): string { */ function HostedEnrollView({ enrollment, + accountOrigin, suggestedLabel, }: { enrollment: HostedEnrollmentState | null; + accountOrigin: string | null; suggestedLabel: string; }) { const [label, setLabel] = useState(suggestedLabel); @@ -642,12 +679,15 @@ function HostedEnrollView({ /** 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 { - await beginHostedEnrollment(label.trim()); + // "Get a new code" replaces whatever code is waiting by now; the + // first Enroll joins one another window already has. + await beginHostedEnrollment(label.trim(), { replace: ended !== null }); } finally { setBeginning(false); } @@ -656,17 +696,22 @@ function HostedEnrollView({ return (
{waiting ? ( - void run(cancelHostedEnrollment)} /> + void run(cancelHostedEnrollment)} + /> ) : null} - {ended ? ( -
-
{own(HOSTED_ENROLLMENT_ENDED_COPY, ended.reason) ?? HOSTED_ENROLLMENT_ENDED_COPY.failed}
- {ended.reason === 'failed' && ended.message ?
{ended.message}
: null} + {redeeming ? ( +
+ {HOSTED_ENROLLMENT_REDEEMING_COPY}
) : null} + {ended ? : null}