Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/audit/canaries/passkey-removal-keeps-sessions.patch
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,14 @@ diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrat
index 09806ad75..cb7005345 100644
--- a/hosted/server/tests/migrations.test.ts
+++ b/hosted/server/tests/migrations.test.ts
@@ -32,7 +32,7 @@ const PINNED: Record<string, string> = {
@@ -32,9 +32,9 @@ const PINNED: Record<string, string> = {
"009_relay_passkey_removal.sql":
"c6c220960187e33a1350dc85e93d24f99866a72a579ed9def6a3663643f3e928",
"010_relay_session_credential_required.sql":
- "f385398d16126381b6a976d74e23e10b07370c1a1da3205e71697aa62a3399f5",
+ "892b0116797b52e9f42617be5703d2e516f3ec5c6d37cbc8c4f8504e4fcce969",
"011_enrollment_confirm_digits.sql":
"54afe9ae8a2efd3c654503d479fce96c1ce8ff4f2df249aa93b5a91e925def6e",
};

const directory = new URL("../dormouse-migrations/", import.meta.url);
Expand Down
2 changes: 1 addition & 1 deletion .github/audit/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Be adversarial, and go past the `FAIL IF` list; a bare section name is `docs/spe
- **Is each Worker's origin the only one that can drive it?** Trace a request from `hosted/server/worker.ts`, `hosted/server/relay-worker.ts`, and `hosted/server/voice-worker.ts` through `workerApp`'s origin gate and `secureHeaders`: a foreign `Host`, a sibling Worker's origin, a preview hostname, a misconfigured deployment's error path, and the account's SPA fallback. The siblings are same-site, so the login cookie rides their requests to the account, its login reads included. "Origin boundary" holds each answer.
- **Does a secret reach a Worker that has no use for it?** Read each mapper in `hosted/server/bindings.ts`, each Wrangler config, and each entry's import graph against "Origin boundary" and "Relay boundary", and which tables each Worker queries against what its role is granted in `hosted/server/runtime-roles.sql` ("Deployment boundary" holds which role each Worker connects as).
- **Can one account reach another's Relay rows?** Trace every query in `hosted/server/relay-api.ts` with a session or Burrow token of account B against account A's rows, a single-use row raced against itself, a de-entitled account or a removed Burrow, and a table a caller grows ("Relay boundary"). Does every unauthenticated route that reads or writes a row, on either Worker, spend a per-address limit first? Classify a percent-encoded path (`/%63onnect/`, `/%61ssets/`) the way `secureHeaders` and the routes do.
- **Can an enrollment become someone else's Burrow, or a second one?** Trace a device code from `begin` through the account's approval (`hosted/server/relay-account.ts`) to the poll that redeems it, and a removed Burrow's token on every relay route ("Relay boundary"). Look for a user code predictable without the secret, an unlimited approval loop, and a table an unauthenticated caller can grow.
- **Can an enrollment become someone else's Burrow, or a second one?** Trace a device code from `begin` through the account's approval (`hosted/server/relay-account.ts`) to the poll that redeems it, and a removed Burrow's token on every relay route ("Relay boundary"). Look for a user code predictable without the secret, confirmation digits a link or the account Worker can learn, 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?** Trace a send through `relayPushRoutes` in `hosted/server/relay-push.ts`, a session holding another account's `deliveryId`, an upsert's endpoint rotation, its caps, the 404/410 prune, and every fetch ("Relay boundary"). Check the sender against RFC 8291 and RFC 8292 yourself: the test's expected bytes must come from the RFC, not the code, and a JWT's `aud` must be the endpoint's origin. Look for an endpoint string that parses to an allowlisted host in one place and another host in another.
- **Can a Hosted login become terminal access, or an account become someone else's?** Trace linking, a revoked login's callback, an unused or unknown provider credential, and every path from a login toward a Burrow ACL grant ("Account boundary"; the relay Worker's cookie rule is "Relay boundary"). Pocket and `/connect/` share the relay origin; check what each page's policy lets it reach of the other.
- **Can one account's relay socket reach another's?** Trace an upgrade through `relaySocketRoutes` (`hosted/server/relay-sockets.ts`) into `RelayRoom` (`hosted/server/relay-room.ts`) against "Relay boundary". Look for routing state kept in memory that a hibernated object would lose, a socket torn down twice or routed after its close began, a frame bounded in characters rather than bytes, a `ct` touched outside the shared frame layer's field copy, a Client cap one socket can evict past, a session that outlives its alarm, a ping that wakes the object, a Burrow socket accepted on a row removed after the token check, and a removed or de-entitled Burrow's socket outliving the sweep.
Expand Down
18 changes: 11 additions & 7 deletions docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,9 @@ sequenceDiagram
participant A as account Worker
actor User
Burrow->>R: POST /api/burrow/enroll/begin {origin}
R-->>Burrow: deviceCode, userCode, verificationUrl, expiresAt, interval
Note over Burrow,User: Dormouse shows userCode and links verificationUrl
User->>A: sign in, compare code, approve {userCode}
R-->>Burrow: deviceCode, userCode, confirmDigits, verificationUrl, expiresAt, interval
Note over Burrow,User: Dormouse shows confirmDigits and links verificationUrl
User->>A: sign in, type the digits, approve {userCode, confirmDigits}
Note over R,A: approval row in Postgres, keyed by userCode
loop every interval
Burrow->>R: POST /api/burrow/enroll/poll {deviceCode}
Expand All @@ -196,6 +196,8 @@ sequenceDiagram
R-->>Burrow: pending
else already redeemed
R-->>Burrow: redeemed {burrowId}
else approved with other digits
R-->>Burrow: mismatch
else approver not entitled, or account full
R-->>Burrow: 403 NOT_ENTITLED_ERROR, or 409, approval kept
else live, unredeemed
Expand All @@ -208,7 +210,8 @@ The pre-launch sign-in gate is "Identity and login".

Begin refuses an absent or mismatched origin with `ORIGIN_MISMATCH_ERROR`. The verification link points to the account's `/enroll` page, with the user code in its fragment and voice purpose in its query. `redeemed` names the Burrow whose answer was lost; 409 names the account page at the abuse ceiling `MAX_BURROWS_PER_ACCOUNT` (`docs/specs/pricing.md` -> "Checkout and entitlement").

- **Never write from begin.** The device code is 32 bytes, the bearer shape: its expiry in its leading bytes, the rest random. The user code is `enrollUserCode` under `RELAY_ENROLL_SECRET` for remote enrollment, or that secret suffixed with `:voice` for managed voice (rationale).
- **Never write from begin.** The device code is 32 bytes, the bearer shape: its expiry in its leading bytes, the rest random. The user code is `enrollUserCode` under `RELAY_ENROLL_SECRET` for remote enrollment, or that secret suffixed with `:voice` for managed voice (rationale). The confirmation digits are `enrollConfirmDigits` of the user code under `RELAY_ENROLL_SECRET`; the link carries the user code, never the digits.
- **Must redeem only an approval whose typed digits are its code's confirmation digits, compared at the poll** (rationale). Any other answers `mismatch` to every poll until it expires: one attempt per code. The first such poll stamps the live, unredeemed approval's `mismatchedAt` for its approver's waiting page.
- **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.
Expand All @@ -217,7 +220,8 @@ Begin refuses an absent or mismatched origin with `ORIGIN_MISMATCH_ERROR`. The v

| Route (account) | Credential | Success |
|---|---|---|
| `POST /api/relay/enrollments/approve` | login cookie, exact `Origin`, JSON `{ userCode, purpose }` | 204 |
| `POST /api/relay/enrollments/approve` | login cookie, exact `Origin`, JSON `{ userCode, purpose, confirmDigits }` | 204 |
| `GET /api/relay/enrollments/:userCode` | login cookie, same-origin fetch | 200 `{ mismatched, redeemed }`, each true only for the caller's own live approval |
| `GET /api/relay/burrows` | login cookie, same-origin fetch | 200 `{ burrows }` |
| `DELETE /api/relay/burrows/:burrowId` | login cookie, exact `Origin` | 204; 404 for another account's or an unknown ID |
| `GET /api/relay/passkeys` | login cookie, same-origin fetch | 200 `{ passkeys }`, each with the enrolled Burrow whose setup token registered it, or none |
Expand All @@ -227,12 +231,12 @@ Errors are the managed-voice cookie routes' ("Managed voice"), except that their

- **Must refuse approval from a login older than `LOGIN_FRESH_AGE_MS`**, 403 `RECENT_LOGIN_REQUIRED`, failing closed when the login's `createdAt` is missing or unparsable; the voice routes require no recent login.
- **Must count every approval attempt** against `RELAY_APPROVE_LIMIT` (10 a minute per account) before reading the body: 429 with `Retry-After` past it.
- **Must answer a malformed code 400**, forgiving case, spaces, and dashes, and a second approval of a live code 409 `ALREADY_APPROVED`, whichever account sends it: a live approval never moves. An expired one is replaced.
- **Must answer a malformed code or digits 400**, the code forgiving case, spaces, and dashes, and a second approval of a live code 409 `ALREADY_APPROVED`, whichever account sends it: a live approval never moves. An expired one is replaced.
- **Must remove by deleting the Burrow's row**, its setup tokens and setup challenges cascading, so its token authenticates nothing on any relay route, **then close its live socket** ("Relay sockets"). **Never remove a passkey with its Burrow**: it is left with no Burrow (rationale).
- **Must remove a passkey by deleting its row**, every session it signed in cascading, so it signs in to nothing again; every session names the passkey that signed it in, its `"credentialId"` `NOT NULL`.
- **Must take the `/enroll` fragment before render and erase it from history, and hold the code in memory only**: through email sign-in and back, never into storage. Provider sign-in leaves the page, so the user opens the link again.

**Must bind every approval to its purpose, defaulting omitted purpose to remote control.** Voice approvals use a distinct code derivation and scope. Voice clients require begin to acknowledge their purpose before displaying or polling a code. Voice redemption atomically marks the approval and mints a token, without a Burrow or its cap; later polls report it spent. A remote poll cannot redeem a voice approval, or vice versa. Both purposes share approval gates and expiry.
**Must bind every approval to its purpose, defaulting omitted purpose to remote control.** Voice approvals use a distinct code derivation and scope. Voice clients require begin to acknowledge their purpose before displaying or polling a code. Voice redemption atomically marks the approval and mints a token, without a Burrow or its cap; later polls report it spent. A remote poll cannot redeem a voice approval, or vice versa. Both purposes share the digits, approval gates, and expiry.

Source of truth: `relayApiRoutes` in `hosted/server/relay-api.ts`; `relayAccountRoutes` in `hosted/server/relay-account.ts`; `takeEnrollment` in `hosted/src/enrollment.ts`.

Expand Down
1 change: 1 addition & 0 deletions docs/specs/hosted.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,5 @@ A `RelayRoom` that opened a Postgres connection through Hyperdrive could not be
- User code derivation: the HMAC key keeps the user code unpredictable from the device code, so no one can mint a device code for a code a victim is about to approve without about 30⁸ ≈ 6.6 × 10¹¹ begins, against 10 a minute per address. Two live device codes sharing a user code is a 40-bit collision; the approval then redeems for whichever polls first, as owned by the approver.
- Approvals table bound: the approval route is its only writer, authenticated, admin-only, and limited to 10 attempts a minute per account, each approval living 10 minutes, so one account holds at most about 100 live approvals.
- A passkey outlives its Burrow (2026-10-20): Pocket signs in to every Burrow of the account with one passkey, retiring a later computer's setup code rather than registering again, and each Burrow's pairings pin that passkey, so removing the computer whose code added it would sign the phone out of the others and force it to pair with each again. The account page removes a passkey on its own instead, naming the computer that added it while that computer is enrolled.
- Confirmation digits (2026-10-20): the user code alone was passive, so a person who clicked a stranger's link and Approve without comparing handed that stranger an enrollment or a voice token. The digits make approving active, as pairing's are: only someone looking at the computer that asked can type them, and a link never carries them. They are derived rather than stored so begin still writes nothing, and checked at the poll because the account Worker never holds `RELAY_ENROLL_SECRET` (`docs/specs/security-hosted.md` -> "Origin boundary"); the account page therefore cannot say a typo was wrong, and the desktop reports it. One attempt at 1/100 per code, the approval limit bounding attempts per account. Desktops through v1.3.0 show no digits, so they cannot finish a Hosted approval: a path without digits would be a downgrade anyone could choose.
- Guessing: approving a well-formed code no Burrow holds enrolls nothing; to steal a pending enrollment the account would have to be the one approving its code, which is the flow itself.
4 changes: 2 additions & 2 deletions docs/specs/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,12 +303,12 @@ Source of truth: `resolveRelayOrigin` and `assertRelayOriginBaked` in `scripts/r
- **Burrow→Relay requests time out at `BURROW_REQUEST_TIMEOUT_MS`, under the webview's own 15 s command budget** (rationale); a route the Relay may legitimately hold open longer (push delivery) passes its own.
- **The store goes first**: the save is awaited before any running Burrow is stopped (rationale), and **`clearEnrollment` awaits the delete before anything else**. Replacing a running Burrow emits a `status` event with `enrolled: false` between the two, since the webview's gate is edge-triggered.

**Hosted enrollment** (a Hosted build; the protocol is `docs/specs/hosted.md` -> "Burrow enrollment"): `beginHostedEnrollment` posts at the baked origin and the service polls on its own. Refused on an enrolled machine.
**Hosted enrollment** (a Hosted build; the protocol is `docs/specs/hosted.md` -> "Burrow enrollment"): `beginHostedEnrollment` posts at the baked origin and the service polls on its own, showing the code's two digits for the account page to ask for. Refused on an enrolled machine. Managed voice's approval (`docs/specs/hosted.md` -> "Managed voice") is the same run under these rules, but for a full account and a lost answer's Burrow, which it never mints (`HostedApproval` in `lib/src/host/remote/hosted-approval.ts`).

- **Must answer a code already waiting, or redeeming, rather than replace it**: another VS Code window's Enroll reaches the same service. **Never change what ended until a new begin has its code.**
- **Never send the device code to the webview**, as for `burrowToken`.
- **Must compose the verification URL, never take it from the Relay, in a release build**: `HOSTED_ACCOUNT_ORIGIN/enroll#<userCode>`. A dev Hosted build (`isDevHostedBuild`) takes only the origin of the Relay's `verificationUrl`, after `parseLinkFragment`'s checks, and refuses a begin without one.
- **Must stop polling** on the Relay's `expired`, at this machine's own deadline (the clocks being separate), on Cancel, on disposal, and under `nothing`. **A full account polls on**, the Relay keeping the approval. **A poll with no answer or a 5xx retries; a 429 also widens the interval**, capped at `MAX_ENROLL_POLL_INTERVAL_S`; any other refusal ends the run.
- **Must stop polling** on the Relay's `expired` or `mismatch`, at this machine's own deadline (the clocks being separate), on Cancel, on disposal, and under `nothing`. **A full account polls on**, the Relay keeping the approval. **A poll with no answer or a 5xx retries; a 429 also widens the interval**, capped at `MAX_ENROLL_POLL_INTERVAL_S`; any other refusal ends the run.
- **Must hold a redemption that lands after Cancel or a new begin**: the Relay has spent and recorded it. When it cannot be held, the failure names the Burrow and the account page to remove it at.
- **Must retain an enrollment whose save succeeded when startup fails**, reporting restart guidance, never removal advice.
- **Never enable, disable, or clear managed voice through remote-control enrollment, startup, or Disconnect.** Managed-voice approval: `docs/specs/hosted.md` -> "Managed voice".
Expand Down
Loading
Loading