diff --git a/.github/audit/canaries/passkey-removal-keeps-sessions.patch b/.github/audit/canaries/passkey-removal-keeps-sessions.patch index 165220fa2..41140be08 100644 --- a/.github/audit/canaries/passkey-removal-keeps-sessions.patch +++ b/.github/audit/canaries/passkey-removal-keeps-sessions.patch @@ -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 = { +@@ -32,9 +32,9 @@ const PINNED: Record = { "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); diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 46f5286d4..688b5ff6a 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -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. diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 6f8cab974..ce85da54f 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -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} @@ -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 @@ -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. @@ -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 | @@ -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`. diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 201b429e4..0418b2003 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -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. diff --git a/docs/specs/relay.md b/docs/specs/relay.md index b4519d1cd..c18efd0b2 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -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#`. 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". diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 3bade02ac..ad9b91aac 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -60,7 +60,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended | --- | --- | --- | | **Hosted's Relay keeps each account's computers, passkeys, and push registrations to that account.** It stores tokens only hashed, rechecks the owner's entitlement each time it admits a request or socket, closes a socket already open for a removed or de-entitled computer no later than the room's sweep, and neither decodes a frame nor reads a push. | Hosted phone | `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-room.test.ts`, `hosted/server/tests/relay-push.test.ts` | -**The Hosted Relay** on the relay Worker, and its account routes on the account Worker: `docs/specs/hosted.md` -> "Relay", "Relay sockets", and "Burrow enrollment" own them; these are the checks on them. Inspect `relayApiRoutes` in `hosted/server/relay-api.ts`, `hosted/server/relay-auth.ts`, `relaySocketRoutes` in `hosted/server/relay-sockets.ts`, `RelayRoom` in `hosted/server/relay-room.ts`, the frame layer in `remote-lib-common/src/remote/relay-routing.ts`, `relayAccountRoutes` in `hosted/server/relay-account.ts`, `cookieEntitled` in `hosted/server/account-gate.ts`, `hosted/server/relay-push.ts`, and `hosted/server/dormouse-migrations/002_relay.sql`, `003_relay_push.sql`, `004_relay_enrollment_redeemed.sql`, `006_relay_setup_token_claim.sql`, `007_independent_voice.sql`, `009_relay_passkey_removal.sql`, and `010_relay_session_credential_required.sql`. +**The Hosted Relay** on the relay Worker, and its account routes on the account Worker: `docs/specs/hosted.md` -> "Relay", "Relay sockets", and "Burrow enrollment" own them; these are the checks on them. Inspect `relayApiRoutes` in `hosted/server/relay-api.ts`, `hosted/server/relay-auth.ts`, `relaySocketRoutes` in `hosted/server/relay-sockets.ts`, `RelayRoom` in `hosted/server/relay-room.ts`, the frame layer in `remote-lib-common/src/remote/relay-routing.ts`, `relayAccountRoutes` in `hosted/server/relay-account.ts`, `cookieEntitled` in `hosted/server/account-gate.ts`, `hosted/server/relay-push.ts`, and `hosted/server/dormouse-migrations/002_relay.sql`, `003_relay_push.sql`, `004_relay_enrollment_redeemed.sql`, `006_relay_setup_token_claim.sql`, `007_independent_voice.sql`, `009_relay_passkey_removal.sql`, `010_relay_session_credential_required.sql`, and `011_enrollment_confirm_digits.sql`. - **FAIL IF** `S97` a session, Burrow, or setup token is stored other than as its SHA-256, an enrollment device code is stored at all, or an account's Relay rows outlive its user row. - **FAIL IF** `S98` a query reading a Burrow, passkey, presence nonce, or setup token is not scoped to the caller's account (the session's user, the Burrow token's owner, or the setup token's Burrow's owner; `RelayRows` by its consumer's account check; sign-in excepted), a passkey registers to any account but the minting Burrow's owner, or a setup challenge redeems with another Burrow's token. @@ -68,6 +68,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended - **FAIL IF** `S100` a Burrow-authenticated request, setup redemption, enrollment redemption, sign-in, or session-gated request skips rechecking the owner's entitlement (`entitledSql` 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; or a removed passkey's sessions survive its removal, a session is stored naming no passkey, or removal reaches another account's passkey. - **FAIL IF** `S101` a route that presents no credential writes a row other than a sign-in challenge (enrollment begin writes none); setup routes write only under a live setup token and a poll only under an approval; 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 the purpose-bound derivation in `docs/specs/hosted.md` -> "Burrow enrollment". - **FAIL IF** `S102` an approval is redeemed other than in the one statement that marks it redeemed and inserts only its purpose's credential (Burrow or voice token), 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 another purpose or a user code its device code does not derive; an approval replaces a live one; or a poll or approval treats an approval past its `expiresAt` as live (an approval may only replace it). +- **FAIL IF** `S276` a poll redeems an approval whose typed digits are not `enrollConfirmDigits` of its user code under `RELAY_ENROLL_SECRET`, in its read and in its redeeming statement both; `mismatchedAt` is stamped other than by a poll under a live, unredeemed approval or lets an approval redeem; another account reads an approval's mismatch or redemption; begin answers digits derived otherwise; the approval route stores one without exactly two digits; or a `verificationUrl` carries them. - **FAIL IF** `S103` enrollment begin or poll admits a request carrying `Origin`, or either reaches the database before its per-address limit. - **FAIL IF** `S104` 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 user code is parsed). - **FAIL IF** `S105` a Relay table a caller can grow has no cap keyed by whoever grows it (approvals: the per-account attempt limit; flat sign-in challenges: the per-address limit and the sweep), or a capped write deletes another key's rows; `signin/*` or `setup/begin`/`finish` reaches the database before its per-address limit; or a production `namespace_id` reaches `PREVIEW_RATELIMIT_OFFSET`. diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index e117dbc2d..a92ab030b 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -42,7 +42,7 @@ Only one local confirmation of the phone's two digits mints an ACL record. The w - **FAIL IF** `S181` `requireUserVerification` is reachable on one side without being mirrored to the other: the Relay reads `DORMOUSE_REQUIRE_USER_VERIFICATION`, and `BurrowEnrollResponse` must carry it into the Burrow's `ConnectionPolicy`, as must the `policy` frame on every `/ws/burrow` open, which no Relay frame lowers; nor may a restart lower it: `BurrowService` in `lib/src/host/remote/service.ts` must start every later runtime of the enrollment a raise reached with that raise, held in memory whether or not saving it succeeded (rationale). - **FAIL IF** `S182` the Burrow accepts an `e2e` frame it has not shape-validated itself with `isE2eRelayToBurrowFrame`, relying instead on the relay's own guard in `relay/src/relay.ts`, or lets the Client's device label reach any consumer except through `knownPairingDeviceLabel`, so no phone-chosen text is shown (`docs/specs/remote-security-model.md` -> "Pairing"; rationale). - **FAIL IF** `S183` a ceremony outcome stops being a fixed-size padded control message, or begins carrying which ACL half failed: success and every denial encrypt to the same length, every ACL miss answers `pairing-required`, and the specific miss is logged owner-locally only. -- **FAIL IF** `S184` any **service→webview** message can carry `burrowToken`, **or any other bearer credential the receiving realm has no route that takes** — `deliveryId` most of all, which is why `PushDevicesResult` is labels only. Check every service→webview shape in `lib/src/host/remote/service-protocol.ts`, `OneTimeState` included; the test is whether the webview *calls* anything with the value, not whether exposing it is currently exploitable. The only credentials that cross outbound are the Relay's **setup token** and the invitation's **public** half, inside `SetupQrResult.url`; a one-time link's room id and one-use **public** key, inside `OneTimeState`'s `waiting.url`; and a Hosted enrollment's **user code**, in `HostedEnrollmentState`'s `waiting` and inside its `verificationUrl`, which the webview opens — each minted only on request, single-use, and short-lived. Whichever account approves a live user code first owns the Burrow it enrolls (`docs/specs/security.md` -> "What is not defended"). Inbound, `EnrollParams` carries the setup password by design (rationale). +- **FAIL IF** `S184` any **service→webview** message can carry `burrowToken`, **or any other bearer credential the receiving realm has no route that takes** — `deliveryId` most of all, which is why `PushDevicesResult` is labels only. Check every service→webview shape in `lib/src/host/remote/service-protocol.ts`, `OneTimeState` included; the test is whether the webview *calls* anything with the value, not whether exposing it is currently exploitable. The only credentials that cross outbound are the Relay's **setup token** and the invitation's **public** half, inside `SetupQrResult.url`; a one-time link's room id and one-use **public** key, inside `OneTimeState`'s `waiting.url`; and a Hosted approval's **user code** inside `HostedEnrollmentState`'s `waiting.verificationUrl`, which the webview opens, beside the two digits that confirm it — each minted only on request, single-use, and short-lived. Whichever account first approves a live user code with its digits owns the Burrow it enrolls (`docs/specs/security.md` -> "What is not defended"). Inbound, `EnrollParams` carries the setup password by design (rationale). - **FAIL IF** `S185` a private key agreement ever leaves WebCrypto. **X25519 stays WebCrypto-only** and **never a JavaScript curve** (`@noble/curves`, `tweetnacl`, `libsodium`, or any other). The one bundled primitive is ChaCha20-Poly1305 from an exactly-pinned `@noble/ciphers`, whose import sites and audit delta `remote-lib-common/src/security/noise.ts`'s header records, rewritten by any version bump in the same commit (rationale). - **FAIL IF** `S186` the Burrow's Noise static is ever sent to the Relay, persisted anywhere `burrowToken` is not, or used with halves that do not correspond (`docs/specs/remote-security-model.md` -> "Burrow identity"); `BurrowService` checks the halves before starting, and a mismatch keeps the Burrow down (rationale). - **FAIL IF** `S187` `remote-lib-common/src/security/` stops being the shared implementation: the Relay, the Burrow, and the Pocket client must verify assertions, presence challenges, handshakes, and transport framing with the same modules. Conformance is proven against an independent implementation's published vector (`remote-lib-common/test/noise.test.mjs`), never against a value the production state machine computed, and this section's properties are driven end to end by `remote-lib-common/test/security-guarantees.test.mjs`. diff --git a/docs/specs/security.md b/docs/specs/security.md index f0fd7888e..5da8b3cfe 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -64,7 +64,7 @@ Source of truth: root scripts in `package.json`; native jobs in `.github/workflo - *Hosted phone, Self-hosted Relay.* **Phone-key durability.** Clearing site data means pairing again. Nothing is compromised; a lost key authorized nothing on its own ([Client static loss](./remote-security-model.md#client-static-loss)). - *One-time link, Hosted phone, Self-hosted Relay.* **Availability.** Remote terminal access needs an online Burrow and, for new relay-backed sessions, an available Relay ([Goals](./remote-security-model.md#goals); [keeping it up](../../SELF_HOST.md#keeping-the-relay-up-while-the-laptop-sleeps)). - *Hosted phone.* **The voice provider keeps what managed voice speaks until Dormouse deletes it.** ElevenLabs stores each spoken label in its speech history; Dormouse deletes it there, usually within minutes, but no bound is guaranteed, and deleting it need not erase the provider's backups or moderation records ([Account boundary](./security-hosted.md#account-boundary)). -- *Hosted phone.* **Whichever account first approves a live Hosted enrollment code owns the computer it enrolls**; yours is then refused as already approved. That account still pairs no phone without the two digits typed here ([Trust boundary](./security-remote.md#trust-boundary)). Approving a stranger's code lets their computer add passkeys to your account, filling it and signing you out, until you remove them on the account page; a voice code spends your daily voice allowance until you turn Natural Voice off. +- *Hosted phone.* **Whichever account first approves a live Hosted enrollment code, with the two digits its computer shows, owns that computer**; yours is then refused as already approved. Approving takes those digits, so a link alone approves nothing, but a stranger can read you theirs. That account still pairs no phone without the two digits typed here ([Trust boundary](./security-remote.md#trust-boundary)). Approving a stranger's code lets their computer add passkeys to your account, filling it and signing you out, until you remove them on the account page; a voice code spends your daily voice allowance until you turn Natural Voice off. - *Hosted phone, Self-hosted Relay.* **Whoever first redeems a live setup QR adds a passkey to your account and is signed in**; on Hosted, until the account page removes it. That still pairs no phone without the two digits typed here ([Trust boundary](./security-remote.md#trust-boundary)). - **The bot's upstream is pinned by tag, not commit**, so a hostile upstream could change what the bot runs without a diff here. Accepted: the trust equals what the harness already holds ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). - **The repo-level snapshot-testing tokens are reachable by any workflow the bot can author.** Accepted with rotation; each service's dashboard shows abuse ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). diff --git a/hosted/server/dormouse-migrations/011_enrollment_confirm_digits.sql b/hosted/server/dormouse-migrations/011_enrollment_confirm_digits.sql new file mode 100644 index 000000000..d5185d330 --- /dev/null +++ b/hosted/server/dormouse-migrations/011_enrollment_confirm_digits.sql @@ -0,0 +1,13 @@ +-- Up Migration +-- The two digits the approver typed (docs/specs/hosted.md -> "Burrow +-- enrollment"): a poll redeems an approval only where they are its user +-- code's confirmation digits, and marks one where they are not, for its +-- approver's page. An approval from before them, which no desktop could have +-- confirmed, is dropped. +DELETE FROM dormouse_relay_enrollment_approvals; +ALTER TABLE dormouse_relay_enrollment_approvals + ADD COLUMN "confirmDigits" text NOT NULL CHECK ("confirmDigits" ~ '^[0-9]{2}$'), + ADD COLUMN "mismatchedAt" timestamptz; + +-- Down Migration +ALTER TABLE dormouse_relay_enrollment_approvals DROP COLUMN "mismatchedAt", DROP COLUMN "confirmDigits"; diff --git a/hosted/server/relay-account.ts b/hosted/server/relay-account.ts index cb0bde3ae..405ffcb4b 100644 --- a/hosted/server/relay-account.ts +++ b/hosted/server/relay-account.ts @@ -4,6 +4,7 @@ import { bodyLimit } from "hono/body-limit"; import { NOT_ENTITLED_ERROR, isE2eId, + isEnrollConfirmDigits, normalizeEnrollUserCode, readJson, } from "remote-lib-common"; @@ -50,12 +51,17 @@ export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAc c.header("Retry-After", "60"); return c.json({ message: "Too many attempts. Wait a minute before trying again." }, 429); } - const body = await readJson<{ userCode?: unknown; purpose?: unknown }>(c); + const body = await readJson<{ userCode?: unknown; purpose?: unknown; confirmDigits?: unknown }>(c); const purpose = body?.purpose ?? "remote"; if (purpose !== "remote" && purpose !== "voice") return c.json({ message: "Invalid purpose." }, 400); const userCode = normalizeEnrollUserCode(body?.userCode); if (userCode === null) return c.json({ message: "That is not a code from Dormouse." }, 400); + // Unchecked here: only the relay can derive them. The poll compares, and an + // approval with other digits redeems nothing until it expires. + const confirmDigits = body?.confirmDigits; + if (!isEnrollConfirmDigits(confirmDigits)) + return c.json({ message: "Type the two digits Dormouse shows." }, 400); // Whether the code was ever issued is unknowable here: begin stores // nothing. An approval expires, redeemed or not. A live approval never // moves to another account, nor is one approved again once redeemed; an @@ -64,19 +70,38 @@ export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAc ( await accountQuery( host(c), - `INSERT INTO dormouse_relay_enrollment_approvals AS a ("userCode", "userId", "expiresAt", purpose) - VALUES ($1, $2, now() + ($3::float8 * interval '1 millisecond'), $4) + `INSERT INTO dormouse_relay_enrollment_approvals AS a + ("userCode", "userId", "expiresAt", purpose, "confirmDigits") + VALUES ($1, $2, now() + ($3::float8 * interval '1 millisecond'), $4, $5) ON CONFLICT ("userCode") DO UPDATE SET purpose = EXCLUDED.purpose, "userId" = EXCLUDED."userId", "expiresAt" = EXCLUDED."expiresAt", - "redeemedBurrowId" = NULL, "redeemedAt" = NULL + "confirmDigits" = EXCLUDED."confirmDigits", "redeemedBurrowId" = NULL, "redeemedAt" = NULL, + "mismatchedAt" = NULL WHERE a."expiresAt" <= now() RETURNING 1`, - [userCode, login.userId, ENROLLMENT_TTL_MS, purpose], + [userCode, login.userId, ENROLLMENT_TTL_MS, purpose, confirmDigits], ) ).length > 0; return approved ? c.body(null, 204) : c.json({ message: ALREADY_APPROVED }, 409); }); + // What a poll made of this account's live approval of the code: typed with + // other digits, or redeemed. Another account's approval, or none, reads as + // neither. + app.get("/api/relay/enrollments/:userCode", gate, async (c) => { + const userCode = normalizeEnrollUserCode(c.req.param("userCode")); + if (userCode === null) + return c.json({ message: "That is not a code from Dormouse." }, 400); + const [approval] = await accountQuery<{ mismatched: boolean; redeemed: boolean }>( + host(c), + `SELECT "mismatchedAt" IS NOT NULL AS mismatched, "redeemedAt" IS NOT NULL AS redeemed + FROM dormouse_relay_enrollment_approvals + WHERE "userCode" = $1 AND "userId" = $2 AND "expiresAt" > now()`, + [userCode, c.get("login").userId], + ); + return c.json(approval ?? { mismatched: false, redeemed: false }); + }); + app.get("/api/relay/burrows", gate, async (c) => { const burrows = await accountQuery( host(c), diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index 59ad158df..e5320a51f 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -27,6 +27,7 @@ import { WRONG_CREDENTIAL_ERROR, assertionRejectedError, checkRegistration, + enrollConfirmDigits, enrollUserCode, fromBase64Url, isPresenceBinding, @@ -474,8 +475,9 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { // --- Enrollment: device code, approved on the account origin -------------- - // Begin writes nothing: the device code carries its own expiry, and the - // user code is derived from it, so an unauthenticated caller grows no table. + // Begin writes nothing: the device code carries its own expiry, the user + // code is derived from it and the digits from that, so an unauthenticated + // caller grows no table. app.post(API_ROUTES.burrowEnrollBegin, async (c) => { const body = await readJson<{ origin?: unknown; purpose?: unknown }>(c); const purpose = body?.purpose ?? "remote"; @@ -496,6 +498,7 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { purpose, deviceCode: toBase64Url(bytes), userCode, + confirmDigits: await enrollConfirmDigits(enrollSecret(c.env), userCode), ...(c.env.ACCOUNT_ORIGIN && { verificationUrl: `${c.env.ACCOUNT_ORIGIN}${ENROLL_PAGE_PATH}${purpose === "voice" ? "?purpose=voice" : ""}#${userCode}`, }), @@ -518,14 +521,25 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { const expiresAtS = new DataView(bytes.buffer, bytes.byteOffset, DEVICE_CODE_EXPIRY_BYTES).getUint32(0); if (expiresAtS * 1000 <= Date.now()) return answer({ status: "expired" }); const userCode = await userCodeOf(c.env, bytes, purpose); + const confirmDigits = await enrollConfirmDigits(enrollSecret(c.env), userCode); return database(c, async (db) => { + // The read stamps a live, unredeemed approval typed with other digits, + // first time only, for its approver's page to tell. const { rows: [approval], - } = await db.query( - `SELECT a."userId", a."redeemedBurrowId", a."redeemedAt" IS NOT NULL AS redeemed, ${OWNER_COLUMNS} + } = await db.query< + OwnerRow & { redeemedBurrowId: string | null; redeemed: boolean; confirmDigits: string } + >( + `WITH marked AS ( + UPDATE dormouse_relay_enrollment_approvals SET "mismatchedAt" = now() + WHERE "userCode" = $1 AND purpose = $2 AND "expiresAt" > now() + AND "redeemedAt" IS NULL AND "confirmDigits" <> $3 AND "mismatchedAt" IS NULL + ) + SELECT a."userId", a."redeemedBurrowId", a."redeemedAt" IS NOT NULL AS redeemed, + a."confirmDigits", ${OWNER_COLUMNS} FROM dormouse_relay_enrollment_approvals a JOIN "user" u ON u.id = a."userId" WHERE a."userCode" = $1 AND a.purpose = $2 AND a."expiresAt" > now()`, - [userCode, purpose], + [userCode, purpose, confirmDigits], ); if (!approval) return answer({ status: "pending" }); // Spent by an earlier poll whose answer this Burrow never read: the @@ -535,6 +549,9 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { ? voiceAnswer({ status: "redeemed" }) : answer({ status: "redeemed", burrowId: approval.redeemedBurrowId! }); } + // Approved with digits other than the ones this code showed: one attempt, + // spent, since a live approval is never replaced. + if (approval.confirmDigits !== confirmDigits) return answer({ status: "mismatch" }); 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); @@ -544,12 +561,12 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { const { rowCount } = await db.query( `WITH spent AS ( UPDATE dormouse_relay_enrollment_approvals SET "redeemedAt" = now() - WHERE "userCode" = $1 AND "userId" = $2 AND purpose = 'voice' + WHERE "userCode" = $1 AND "userId" = $2 AND purpose = 'voice' AND "confirmDigits" = $4 AND "expiresAt" > now() AND "redeemedAt" IS NULL RETURNING "userId" ) INSERT INTO dormouse_voice_tokens ("userId", hash) SELECT "userId", $3 FROM spent`, - [userCode, owner.userId, digest(voiceToken)], + [userCode, owner.userId, digest(voiceToken), confirmDigits], ); return voiceAnswer(rowCount ? { status: "enabled", voiceToken } : { status: "redeemed" }); } @@ -573,11 +590,11 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { UPDATE dormouse_relay_enrollment_approvals SET "redeemedBurrowId" = $3, "redeemedAt" = now() WHERE "userCode" = $1 AND "userId" = $2 AND "expiresAt" > ${LOCKED_NOW} - AND "redeemedAt" IS NULL AND purpose = 'remote' + AND "redeemedAt" IS NULL AND purpose = 'remote' AND "confirmDigits" = $5 RETURNING "userId" ) INSERT INTO dormouse_relay_burrows ("burrowId", "userId", "tokenHash") SELECT $3, "userId", $4 FROM spent`, - [userCode, owner.userId, burrowId, digest(burrowToken)], + [userCode, owner.userId, burrowId, digest(burrowToken), confirmDigits], ); if (rowCount) return "enrolled"; // Lost to a poll that redeemed it while this one waited on the lock, @@ -768,9 +785,15 @@ const rpIdOf = (env: RelayEnv) => new URL(env.APP_ORIGIN).hostname; const removeOneFirst = ({ ACCOUNT_ORIGIN: where }: RelayEnv) => `remove one ${where ? `at ${where}/account` : "from your account"} first`; -/** A device code's user code; a deployment without its secret answers 503. */ -function userCodeOf(env: RelayEnv, deviceCode: Uint8Array, purpose: "remote" | "voice") { +/** The enrollment secret; a deployment without it answers 503. */ +function enrollSecret(env: RelayEnv): string { if (!env.RELAY_ENROLL_SECRET) throw new Error("RELAY_ENROLL_SECRET is not set"); - return enrollUserCode(purpose === "voice" ? `${env.RELAY_ENROLL_SECRET}:voice` : env.RELAY_ENROLL_SECRET, deviceCode); + return env.RELAY_ENROLL_SECRET; +} + +/** A device code's user code, under its purpose's key. */ +function userCodeOf(env: RelayEnv, deviceCode: Uint8Array, purpose: "remote" | "voice") { + const secret = enrollSecret(env); + return enrollUserCode(purpose === "voice" ? `${secret}:voice` : secret, deviceCode); } const invalidSetup = (c: Context) => c.json({ error: SETUP_TOKEN_INVALID_ERROR }, 401); diff --git a/hosted/server/runtime-roles.sql b/hosted/server/runtime-roles.sql index bd2ca6715..9d3312f89 100644 --- a/hosted/server/runtime-roles.sql +++ b/hosted/server/runtime-roles.sql @@ -77,9 +77,10 @@ GRANT SELECT, INSERT, DELETE ON TO dormouse_relay; -- A finish claims its setup token in place, so a retire can still reach it. GRANT UPDATE (claimed) ON dormouse_relay_setup_tokens TO dormouse_relay; --- The account Worker inserts approvals; the poll redeems one, the sweep deletes. +-- The account Worker inserts approvals; the poll redeems one or marks its +-- digits mismatched, the sweep deletes. GRANT SELECT, DELETE ON dormouse_relay_enrollment_approvals TO dormouse_relay; -GRANT UPDATE ("redeemedBurrowId", "redeemedAt") ON dormouse_relay_enrollment_approvals TO dormouse_relay; +GRANT UPDATE ("redeemedBurrowId", "redeemedAt", "mismatchedAt") ON dormouse_relay_enrollment_approvals TO dormouse_relay; GRANT SELECT, INSERT, DELETE ON dormouse_relay_push_subscriptions TO dormouse_relay; GRANT UPDATE (endpoint, p256dh, auth, "vapidPublicKey", "subscribedAt") ON dormouse_relay_push_subscriptions TO dormouse_relay; diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 8c223f945..689b85ac7 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -128,6 +128,7 @@ const send = (name: Name, url: string, method = "GET") => /** The Relay's routes the account serves: approval, and its Burrows and passkeys. */ const ACCOUNT_RELAY: [string, string][] = [ ["POST", "/api/relay/enrollments/approve"], + ["GET", "/api/relay/enrollments/AAAA-AAAA"], ["GET", "/api/relay/burrows"], ["DELETE", "/api/relay/burrows/AAAAAAAAAAAAAAAAAAAAAA"], ["GET", "/api/relay/passkeys"], @@ -808,7 +809,7 @@ test("the cookie gate needs no login creation time; approval reads it and fails const response = await app.request(`${origin}/api/relay/enrollments/approve`, { method: "POST", headers: { origin, "content-type": "application/json" }, - body: JSON.stringify({ userCode: "ZZZZ-ZZZZ" }), + body: JSON.stringify({ userCode: "ZZZZ-ZZZZ", confirmDigits: "00" }), }); expect(response.status, String(createdAt)).toBe(403); expect(await response.json()).toEqual({ message: RECENT_LOGIN_REQUIRED }); diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts index 09806ad75..5fdc6f215 100644 --- a/hosted/server/tests/migrations.test.ts +++ b/hosted/server/tests/migrations.test.ts @@ -33,6 +33,8 @@ const PINNED: Record = { "c6c220960187e33a1350dc85e93d24f99866a72a579ed9def6a3663643f3e928", "010_relay_session_credential_required.sql": "f385398d16126381b6a976d74e23e10b07370c1a1da3205e71697aa62a3399f5", + "011_enrollment_confirm_digits.sql": + "54afe9ae8a2efd3c654503d479fce96c1ce8ff4f2df249aa93b5a91e925def6e", }; const directory = new URL("../dormouse-migrations/", import.meta.url); diff --git a/hosted/server/tests/relay-room.test.ts b/hosted/server/tests/relay-room.test.ts index 813123216..5cf9bb690 100644 --- a/hosted/server/tests/relay-room.test.ts +++ b/hosted/server/tests/relay-room.test.ts @@ -766,7 +766,7 @@ test("a Burrow enrolled by device code pairs and connects a phone, and under Loc const approved = await account.request(`${ORIGINS.account}/api/relay/enrollments/approve`, { method: "POST", headers: { origin: ORIGINS.account, "content-type": "application/json" }, - body: JSON.stringify({ userCode: begun.json.userCode }), + body: JSON.stringify({ userCode: begun.json.userCode, confirmDigits: begun.json.confirmDigits }), }); expect(approved.status).toBe(204); // … and its poll answers the enrollment, once. diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index 7feed29bc..9aab4f4ed 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -15,6 +15,7 @@ import { SETUP_TOKEN_INVALID_ERROR, UNAUTHORIZED_ERROR, UNMINTED_DELIVERY_ID_ERROR, + enrollConfirmDigits, enrollUserCode, fromBase64Url, generateNoiseKeyPair, @@ -282,6 +283,7 @@ async function fixture({ bindings = {} }: { bindings?: Record call("POST", API_ROUTES.burrowEnrollPoll, { body: { deviceCode, purpose } }); - /** What the account Worker's approval writes (`hosted/server/relay-account.ts`). */ - const approve = (userCode: string, userId: string, purpose: "remote" | "voice" = "remote") => + /** + * What the account Worker's approval writes (`hosted/server/relay-account.ts`), + * with the digits `userCode` shows unless `confirmDigits` types others. + */ + const approve = async ( + userCode: string, + userId: string, + purpose: "remote" | "voice" = "remote", + confirmDigits?: string, + ) => sql( - `INSERT INTO dormouse_relay_enrollment_approvals ("userCode", "userId", "expiresAt", purpose) - VALUES ($1, $2, now() + interval '10 minutes', $3)`, - [userCode, userId, purpose], + `INSERT INTO dormouse_relay_enrollment_approvals ("userCode", "userId", "expiresAt", purpose, "confirmDigits") + VALUES ($1, $2, now() + interval '10 minutes', $3, $4)`, + [userCode, userId, purpose, confirmDigits ?? (await enrollConfirmDigits(TEST_ENROLL_SECRET, userCode))], ); /** The relay's Cron Trigger, as Cloudflare fires it. */ @@ -995,6 +1005,8 @@ test("a device-code enrollment: begin stores nothing, pending until approved, th expect(Math.abs(begun.expiresAt - (Date.now() + ENROLLMENT_TTL_MS))).toBeLessThan(60_000); expect(isEnrollUserCode(begun.userCode)).toBe(true); expect(begun.userCode).toBe(await enrollUserCode(TEST_ENROLL_SECRET, bytes)); + // The digits Dormouse shows, the same secret's HMAC of the user code. + expect(begun.confirmDigits).toBe(await enrollConfirmDigits(TEST_ENROLL_SECRET, begun.userCode)); expect(begun.verificationUrl).toBe(`${ORIGINS.account}/enroll#${begun.userCode}`); expect(begun.interval).toBe(ENROLLMENT_POLL_INTERVAL_S); // Begin wrote nothing (`pocket.test.ts` begins with no database at all). @@ -1065,6 +1077,94 @@ test("an approval redeems only the device code its user code is derived from", a expect((await f.poll(a.deviceCode)).json).toMatchObject({ status: "enrolled" }); }); +test("an approval with other digits redeems nothing, for either purpose, until it expires", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const owner = await f.account(ADMIN_EMAIL); + for (const purpose of ["remote", "voice"] as const) { + const begun = await f.begin(purpose); + const other = String((Number(begun.confirmDigits) + 1) % 100).padStart(2, "0"); + await f.approve(begun.userCode, owner, purpose, other); + // Every poll says so, so a poll whose answer was lost learns it too. + for (let i = 0; i < 2; i++) + expect(await f.poll(begun.deviceCode, purpose)).toMatchObject({ status: 200, json: { status: "mismatch" } }); + // Even an approver no longer entitled: the digits are told first. + await f.sql(`UPDATE "user" SET "emailVerified" = false WHERE id = $1`, [owner]); + expect((await f.poll(begun.deviceCode, purpose)).json).toEqual({ status: "mismatch" }); + await f.sql(`UPDATE "user" SET "emailVerified" = true WHERE id = $1`, [owner]); + } + expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_burrows`)).toEqual([{ n: 0 }]); + expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_voice_tokens`)).toEqual([{ n: 0 }]); + expect( + await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_enrollment_approvals WHERE "redeemedAt" IS NOT NULL`), + ).toEqual([{ n: 0 }]); +}); + +test("a poll marks a mismatched approval for its approver's page, keeping the first mark, and it still redeems nothing", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const owner = await f.account(ADMIN_EMAIL); + const marked = (userCode: string) => + f.sql(`SELECT "mismatchedAt" FROM dormouse_relay_enrollment_approvals WHERE "userCode" = $1`, [userCode]); + for (const purpose of ["remote", "voice"] as const) { + const begun = await f.begin(purpose); + await f.approve(begun.userCode, owner, purpose, String((Number(begun.confirmDigits) + 1) % 100).padStart(2, "0")); + expect(await marked(begun.userCode)).toEqual([{ mismatchedAt: null }]); + expect((await f.poll(begun.deviceCode, purpose)).json).toEqual({ status: "mismatch" }); + const [{ mismatchedAt }] = await marked(begun.userCode); + expect(mismatchedAt).toBeInstanceOf(Date); + // Marked, it still answers mismatch and redeems nothing; a later poll + // leaves the first mark. + expect((await f.poll(begun.deviceCode, purpose)).json).toEqual({ status: "mismatch" }); + expect(await marked(begun.userCode)).toEqual([{ mismatchedAt }]); + } + expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_burrows`)).toEqual([{ n: 0 }]); + expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_voice_tokens`)).toEqual([{ n: 0 }]); + expect( + await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_enrollment_approvals WHERE "redeemedAt" IS NOT NULL`), + ).toEqual([{ n: 0 }]); + // The code's own digits redeem, unmarked. + const matched = await f.begin(); + await f.approve(matched.userCode, owner); + expect((await f.poll(matched.deviceCode)).json).toMatchObject({ status: "enrolled" }); + expect(await marked(matched.userCode)).toEqual([{ mismatchedAt: null }]); + // Other digits, but not under a live, unredeemed approval of this purpose: + // an expired one, a redeemed one, or one for the other purpose. None is + // marked, and none redeems. + const unmarked = async ( + purpose: "remote" | "voice", + approve: (userCode: string, wrong: string) => Promise, + answer: unknown, + ) => { + const begun = await f.begin(purpose); + await approve(begun.userCode, String((Number(begun.confirmDigits) + 1) % 100).padStart(2, "0")); + expect((await f.poll(begun.deviceCode, purpose)).json).toEqual(answer); + expect(await marked(begun.userCode)).toEqual([{ mismatchedAt: null }]); + }; + await unmarked( + "remote", + async (userCode, wrong) => { + await f.approve(userCode, owner, "remote", wrong); + await f.sql(`UPDATE dormouse_relay_enrollment_approvals SET "expiresAt" = now() WHERE "userCode" = $1`, [userCode]); + }, + { status: "pending" }, + ); + await unmarked( + "voice", + async (userCode, wrong) => { + await f.approve(userCode, owner, "voice", wrong); + await f.sql(`UPDATE dormouse_relay_enrollment_approvals SET "redeemedAt" = now() WHERE "userCode" = $1`, [userCode]); + }, + { status: "redeemed" }, + ); + await unmarked("remote", (userCode, wrong) => f.approve(userCode, owner, "voice", wrong), { status: "pending" }); + expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_burrows`)).toEqual([{ n: 1 }]); +}); + test("two polls racing one approval mint one Burrow", async ({ onTestFinished }) => { const f = await fixture(); onTestFinished(f.close); diff --git a/hosted/server/tests/runtime-roles.test.ts b/hosted/server/tests/runtime-roles.test.ts index ce962e12d..0ab444a70 100644 --- a/hosted/server/tests/runtime-roles.test.ts +++ b/hosted/server/tests/runtime-roles.test.ts @@ -74,6 +74,7 @@ const EXPECTED: Record = { "dormouse_relay_burrows.enrolledAt: UPDATE", "dormouse_relay_burrows: INSERT, SELECT", "dormouse_relay_challenges: DELETE, INSERT, SELECT", + "dormouse_relay_enrollment_approvals.mismatchedAt: UPDATE", "dormouse_relay_enrollment_approvals.redeemedAt: UPDATE", "dormouse_relay_enrollment_approvals.redeemedBurrowId: UPDATE", "dormouse_relay_enrollment_approvals: DELETE, SELECT", diff --git a/hosted/server/tests/settle.test.ts b/hosted/server/tests/settle.test.ts index 269a7698b..64c987492 100644 --- a/hosted/server/tests/settle.test.ts +++ b/hosted/server/tests/settle.test.ts @@ -6,6 +6,7 @@ import { asRead, keepRead, pageError, + pendingEnd, preLaunchNotice, reread, settle, @@ -18,28 +19,68 @@ const state = (on: boolean | null, ids: string[] | null): AccountState => ({ computers: ids && ids.map(computer), }); +/** `after` with the approval's own read. */ +const approved = ( + after: AccountState, + approval: { mismatched?: boolean; redeemed?: boolean }, +): AccountState => ({ ...after, approval: { mismatched: false, redeemed: false, ...approval } }); + describe("approvalShows", () => { it("waits on a voice approval until Natural Voice is on", () => { - const shows = approvalShows("voice", state(false, []))!; + const shows = approvalShows("voice", state(false, [])); expect(shows(state(false, []))).toBe(false); expect(shows(state(null, []))).toBe(false); expect(shows(state(true, []))).toBe(true); }); - it("has nothing to wait on when Natural Voice is already on", () => { - expect(approvalShows("voice", state(true, []))).toBeNull(); + it("waits on the approval's own redemption when Natural Voice is already on", () => { + const shows = approvalShows("voice", state(true, [])); + expect(shows(state(true, []))).toBe(false); + expect(shows(approved(state(true, []), { redeemed: false }))).toBe(false); + expect(shows(approved(state(true, []), { redeemed: true }))).toBe(true); }); it("waits on a remote approval until a computer not listed before appears", () => { - const shows = approvalShows("remote", state(false, ["a", "b"]))!; + const shows = approvalShows("remote", state(false, ["a", "b"])); expect(shows(state(false, ["a", "b"]))).toBe(false); expect(shows(state(false, ["a"]))).toBe(false); expect(shows(state(false, null))).toBe(false); expect(shows(state(false, ["c", "a"]))).toBe(true); }); - it("has nothing to compare a remote approval with when no list was read", () => { - expect(approvalShows("remote", state(false, null))).toBeNull(); + it("waits on the approval's own redemption when no computers list was read", () => { + const shows = approvalShows("remote", state(false, null)); + expect(shows(state(false, ["c"]))).toBe(false); + expect(shows(approved(state(false, null), { redeemed: false }))).toBe(false); + expect(shows(approved(state(false, null), { redeemed: true }))).toBe(true); + }); + + it("waits on a change the page shows where one can, not the redemption read", () => { + expect(approvalShows("voice", state(false, []))(approved(state(false, []), { redeemed: true }))).toBe(false); + expect(approvalShows("remote", state(false, []))(approved(state(false, []), { redeemed: true }))).toBe(false); + }); +}); + +describe("pendingEnd", () => { + const pending = { + userCode: "7KQM-X4TD", + shows: (after: AccountState) => after.voice?.on === true, + waiting: "waiting", + done: "done", + late: "late", + mismatch: "mismatch", + }; + + it("waits until a read shows the change or the approval's mismatch", () => { + expect(pendingEnd(pending, state(false, []))).toBeNull(); + expect(pendingEnd(pending, approved(state(false, []), { mismatched: false }))).toBeNull(); + expect(pendingEnd(pending, state(true, []))).toEqual({ text: "done" }); + }); + + it("ends a mismatched approval with its error, even beside a change another made", () => { + const error = { text: "mismatch", error: true }; + expect(pendingEnd(pending, approved(state(false, []), { mismatched: true }))).toEqual(error); + expect(pendingEnd(pending, approved(state(true, []), { mismatched: true }))).toEqual(error); }); }); @@ -125,8 +166,8 @@ describe("settle", () => { .mockResolvedValueOnce(2); const opts = options(); // Never handed a missed read: `toFixed` throws on one. - const done = (value: number) => value.toFixed() === "2"; - await expect(settle(read, done, opts)).resolves.toBe("done"); + const end = (value: number) => (value.toFixed() === "2" ? "two" : null); + await expect(settle(read, end, opts)).resolves.toEqual({ ended: "two" }); expect(read).toHaveBeenCalledTimes(4); expect(opts.wait).toHaveBeenCalledTimes(4); expect(opts.wait).toHaveBeenCalledWith(5000); @@ -134,13 +175,13 @@ describe("settle", () => { it("gives up once the timeout's reads all missed", async () => { const read = vi.fn(async () => 0); - await expect(settle(read, () => false, options())).resolves.toBe("timeout"); + await expect(settle(read, () => null, options())).resolves.toBe("timeout"); expect(read).toHaveBeenCalledTimes(4); }); it("stops without reading again once cancelled", async () => { const unread = vi.fn(async () => 1); - await expect(settle(unread, () => true, options(() => true))).resolves.toBe( + await expect(settle(unread, () => 1, options(() => true))).resolves.toBe( "cancelled", ); expect(unread).not.toHaveBeenCalled(); @@ -150,7 +191,7 @@ describe("settle", () => { return 1; }); await expect( - settle(read, () => true, options(() => cancelled)), + settle(read, () => 1, options(() => cancelled)), ).resolves.toBe("cancelled"); expect(read).toHaveBeenCalledTimes(1); }); diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts index fbb21ab24..13962650b 100644 --- a/hosted/server/tests/workers.test.ts +++ b/hosted/server/tests/workers.test.ts @@ -414,12 +414,17 @@ async function fixture( return token; }; /** The account's Relay routes, from this origin unless `from` names another. */ - const approve = (userCode: unknown, from = origin) => + const approve = (userCode: unknown, confirmDigits: unknown, from = origin) => request("/api/relay/enrollments/approve", { method: "POST", - body: JSON.stringify({ userCode }), + body: JSON.stringify({ userCode, confirmDigits }), headers: { origin: from, "content-type": "application/json" }, }); + /** What a poll made of this account's live approval of `userCode`, as the page reads it. */ + const mismatched = async (userCode: string, headers?: Record) => { + const response = await request(`/api/relay/enrollments/${encodeURIComponent(userCode)}`, { headers }); + return { status: response.status, json: await response.json() }; + }; const computers = async () => (await request("/api/relay/burrows")).json() as Promise<{ burrows: { burrowId: string; enrolledAt: string }[]; @@ -441,7 +446,7 @@ async function fixture( body: JSON.stringify({ version }), headers: { origin: from, "content-type": "application/json" }, }); - return { request, post, session, email, oauth, hostedVoice, speak, forget, mint, approve, computers, remove, passkeys, removePasskey, accept }; + return { request, post, session, email, oauth, hostedVoice, speak, forget, mint, approve, mismatched, computers, remove, passkeys, removePasskey, accept }; } return { ...context, @@ -458,6 +463,7 @@ async function fixture( (await burrowCall(API_ROUTES.burrowEnrollBegin, { origin: ORIGINS.relay })).json as { deviceCode: string; userCode: string; + confirmDigits: string; verificationUrl: string; }, poll: (deviceCode: string) => burrowCall(API_ROUTES.burrowEnrollPoll, { deviceCode }), @@ -1142,28 +1148,34 @@ test("enrollment: only a recent admin login from this origin approves, and the B const begun = await f.begin(); expect(begun.verificationUrl).toBe(`${origin}/enroll#${begun.userCode}`); - expect((await admin.approve(begun.userCode)).status).toBe(401); + const digits = begun.confirmDigits; + expect((await admin.approve(begun.userCode, digits)).status).toBe(401); await other.email("other@example.test"); - const refused = await other.approve(begun.userCode); + const refused = await other.approve(begun.userCode, digits); expect(refused.status).toBe(403); expect(await refused.json()).toEqual({ message: NOT_ENTITLED_ERROR }); expect((await other.request("/api/relay/burrows")).status).toBe(403); await admin.email(ADMIN_EMAIL); // A same-site page — a sibling Worker's included — carries the cookie but not this origin. for (const sameSite of SAME_SITE) - expect((await admin.approve(begun.userCode, sameSite)).status, sameSite).toBe(403); + expect((await admin.approve(begun.userCode, digits, sameSite)).status, sameSite).toBe(403); expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "pending" }); - // A malformed code is a 400; the code forgives case and spacing. + // A malformed code or digits is a 400; the code forgives case and spacing. for (const code of ["not a code", "ZZZZ-ZZZ0", 7]) - expect((await admin.approve(code)).status, String(code)).toBe(400); - expect((await admin.approve(` ${begun.userCode.toLowerCase().replace("-", "")} `)).status).toBe(204); + expect((await admin.approve(code, digits)).status, String(code)).toBe(400); + for (const typed of [undefined, "007"]) { + const response = await admin.approve(begun.userCode, typed); + expect(response.status, String(typed)).toBe(400); + expect(await response.json()).toEqual({ message: "Type the two digits Dormouse shows." }); + } + expect((await admin.approve(` ${begun.userCode.toLowerCase().replace("-", "")} `, digits)).status).toBe(204); // A live approval answers the same 409 to a second approval, and never moves. - const again = await admin.approve(begun.userCode); + const again = await admin.approve(begun.userCode, digits); expect(again.status).toBe(409); expect(await again.json()).toEqual({ message: ALREADY_APPROVED }); // A code no Burrow holds is approved all the same, and expires unredeemed. - expect((await admin.approve("ZZZZ-ZZZZ")).status).toBe(204); + expect((await admin.approve("ZZZZ-ZZZZ", "00")).status).toBe(204); const enrolled = await f.poll(begun.deviceCode); expect(enrolled.json.status).toBe("enrolled"); @@ -1206,13 +1218,13 @@ test("enrollment: only a recent admin login from this origin approves, and the B // A redeemed approval is approved again only once it has expired, and then unredeemed. expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "redeemed", burrowId }); - expect((await admin.approve(begun.userCode)).status).toBe(409); + expect((await admin.approve(begun.userCode, digits)).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 admin.approve(begun.userCode, digits)).status).toBe(204); expect( await queryDatabase( f.database.url, @@ -1222,6 +1234,74 @@ test("enrollment: only a recent admin login from this origin approves, and the B ).toEqual([{ redeemedBurrowId: null, redeemedAt: null }]); }); +test("the approver's page reads its approval's mismatch or redemption once a poll found it, and only its own approval's", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const admin = f.browser(), + other = f.browser("203.0.113.11"); + await admin.email(ADMIN_EMAIL); + await other.email("other@example.test"); + const begun = await f.begin(); + const wrong = String((Number(begun.confirmDigits) + 1) % 100).padStart(2, "0"); + const unmatched = { status: 200, json: { mismatched: false, redeemed: false } }; + expect(await admin.mismatched(begun.userCode)).toEqual(unmatched); + expect((await admin.approve(begun.userCode, wrong)).status).toBe(204); + // Approved, but no poll has compared the digits yet. + expect(await admin.mismatched(begun.userCode)).toEqual(unmatched); + expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "mismatch" }); + // The code forgives case and spacing, as the approval does. + expect(await admin.mismatched(` ${begun.userCode.toLowerCase().replace("-", "")} `)).toEqual({ + status: 200, + json: { mismatched: true, redeemed: false }, + }); + expect(await admin.mismatched("not a code")).toEqual({ + status: 400, + json: { message: "That is not a code from Dormouse." }, + }); + // Not the account's own page, or an account the Relay does not entitle: refused. + expect((await admin.mismatched(begun.userCode, { "sec-fetch-site": "same-site" })).status).toBe(403); + expect(await other.mismatched(begun.userCode)).toEqual({ status: 403, json: { message: NOT_ENTITLED_ERROR } }); + + // Another account's mismatched approval reads as none at all. + const foreign = await f.begin(); + await queryDatabase( + f.database.url, + `INSERT INTO dormouse_relay_enrollment_approvals + ("userCode", "userId", "expiresAt", purpose, "confirmDigits", "mismatchedAt") + VALUES ($1, $2, now() + interval '10 minutes', 'remote', $3, now())`, + [foreign.userCode, (await other.session())!.user.id, wrong], + ); + expect(await admin.mismatched(foreign.userCode)).toEqual(unmatched); + // Expired, its own reads as none, and its replacement starts unmarked. + await queryDatabase( + f.database.url, + `UPDATE dormouse_relay_enrollment_approvals SET "expiresAt" = now() - interval '1 second' WHERE "userCode" = $1`, + [begun.userCode], + ); + expect(await admin.mismatched(begun.userCode)).toEqual(unmatched); + expect((await admin.approve(begun.userCode, wrong)).status).toBe(204); + expect( + await queryDatabase( + f.database.url, + `SELECT "mismatchedAt" FROM dormouse_relay_enrollment_approvals WHERE "userCode" = $1`, + [begun.userCode], + ), + ).toEqual([{ mismatchedAt: null }]); + expect(await admin.mismatched(begun.userCode)).toEqual(unmatched); + + // The code's own digits: redeemed once a poll redeems it, never mismatched. + const matched = await f.begin(); + expect((await admin.approve(matched.userCode, matched.confirmDigits)).status).toBe(204); + expect(await admin.mismatched(matched.userCode)).toEqual(unmatched); + expect((await f.poll(matched.deviceCode)).json.status).toBe("enrolled"); + expect(await admin.mismatched(matched.userCode)).toEqual({ + status: 200, + json: { mismatched: false, redeemed: true }, + }); +}); + test("the account lists its passkeys with the computer that added each, and removing one ends its sessions", async ({ onTestFinished, }) => { @@ -1296,7 +1376,7 @@ test("enrollment approval needs a login from the recent-login window, and attemp const begun = await f.begin(); const signedIn = Date.parse((await admin.session())!.session.createdAt); await f.advance(new Date(signedIn + 10 * 60_000).toISOString()); - const stale = await admin.approve(begun.userCode); + const stale = await admin.approve(begun.userCode, begun.confirmDigits); expect(stale.status).toBe(403); expect(await stale.json()).toEqual({ message: RECENT_LOGIN_REQUIRED }); expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "pending" }); @@ -1306,7 +1386,7 @@ test("enrollment approval needs a login from the recent-login window, and attemp const approveLimit = limitOf("RELAY_APPROVE_LIMIT"); const limited = await untilLimited(approveLimit, async (i) => { const valid = i === approveLimit - 1; - const response = await admin.approve(valid ? begun.userCode : "not a code"); + const response = await admin.approve(valid ? begun.userCode : "not a code", begun.confirmDigits); if (response.status !== 429) expect(response.status).toBe(valid ? 204 : 400); return response; }); diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index 7839ec7c1..0b83daf13 100644 --- a/hosted/src/App.tsx +++ b/hosted/src/App.tsx @@ -9,6 +9,7 @@ import { acceptTerms, approveEnrollment, getAccounts, + getApproval, getComputers, getHostedVoice, getPasskeys, @@ -33,6 +34,7 @@ import { RECENT_LOGIN_WINDOW, TERMS_VERSION, } from "../server/policy-constants"; +import { ConfirmDigits } from "./ConfirmDigits"; import { takeEnrollment, type Enrollment } from "./enrollment"; import { afterRead, @@ -40,12 +42,15 @@ import { asRead, keepRead, pageError, + pendingEnd, preLaunchNotice, reread, settle, type AccountState, type Lookup, + type Notice, type PageError, + type Pending, } from "./settle"; // The terms version the sign-in notice showed when this tab continued past it, @@ -81,14 +86,6 @@ const SUBSCRIBE_REDIRECT_MS = 4000; const SIGN_IN_PARAMS = new URLSearchParams(location.search); const SIGN_IN_OPEN = SIGN_IN_PARAMS.has("signin") || SIGN_IN_PARAMS.has("error"); -/** A change Dormouse on the computer finishes after the page's mutation. */ -interface Pending { - /** Whether the account, as last read, shows the change. */ - shows: (after: AccountState) => boolean; - waiting: string; - done: string; - late: string; -} // How long the page re-reads for a Pending before saying it has not shown; // after that the focus refresh picks it up. const PENDING_TIMEOUT_MS = 60_000; @@ -116,7 +113,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { const [lookup, setLookup] = useState("pending"); const [busy, setBusy] = useState(""); const [error, setError] = useState(null); - const [notice, setNotice] = useState(""); + const [notice, setNotice] = useState(null); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); const [enterCode, setEnterCode] = useState(false); @@ -125,8 +122,10 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { const refreshGeneration = useRef(0); // Bumped by every action and on unmount: stops re-reading for a Pending. const actionGeneration = useRef(0); - // A Pending whose late notice is up: a later read that shows it replaces it. - const latePending = useRef(null); + // The Pending the page waits on, from its first re-read until a read ends + // it or an action: each read asks what a poll made of it. Once its + // late notice is up (`late`), a later read that ends it replaces that. + const awaited = useRef<{ pending: Pending; late: boolean } | null>(null); const callbackError = useRef( new URLSearchParams(location.search).has("error"), ); @@ -142,14 +141,18 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { Promise.all([getSession(), getProviders()]), ).catch(failed); // A failed list hides its section, never the account page. - const [linked, voice, enrolled, registered] = current + const watched = awaited.current?.pending.userCode; + const [linked, voice, enrolled, registered, approval] = current ? await Promise.all([ asRead(getAccounts()).catch(failed), getHostedVoice().catch(() => null), getComputers().catch(() => null), getPasskeys().catch(() => null), + watched === undefined + ? undefined + : getApproval(watched).catch(() => undefined), ]) - : [[], null, null, null]; + : [[], null, null, null, undefined]; if (current) await recordContinued().catch(() => {}); if (generation !== refreshGeneration.current) return; setError(afterRead); @@ -165,11 +168,11 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { "", enrollingRef.current ? "/enroll" : current ? "/account" : "/login", ); - const state = { voice, computers: enrolled }; - const late = latePending.current; - if (late?.shows(state)) { - latePending.current = null; - setNotice(late.done); + const state: AccountState = { voice, computers: enrolled, approval }; + const late = awaited.current?.late && pendingEnd(awaited.current.pending, state); + if (late) { + awaited.current = null; + setNotice(late); } return state; }, []); @@ -229,7 +232,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { if (!next) return; setEnrolling(next); setError(keepRead); - setNotice(""); + setNotice(null); }; window.addEventListener("hashchange", takeLink); return () => window.removeEventListener("hashchange", takeLink); @@ -239,11 +242,11 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { if (actionPending.current) return; ++refreshGeneration.current; ++actionGeneration.current; - latePending.current = null; + awaited.current = null; actionPending.current = true; setBusy(label); setError(null); - setNotice(""); + setNotice(null); try { await action(); } catch (error) { @@ -272,17 +275,23 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { if (pending) void follow(pending, after); }); async function follow(pending: Pending, after: AccountState | undefined) { - if (after && pending.shows(after)) return setNotice(pending.done); + const ended = after && pendingEnd(pending, after); + if (ended) return setNotice(ended); const generation = actionGeneration.current; - setNotice(pending.waiting); - const outcome = await settle(refresh, pending.shows, { + awaited.current = { pending, late: false }; + setNotice({ text: pending.waiting }); + const outcome = await settle(refresh, (state) => pendingEnd(pending, state), { intervalMs: ENROLLMENT_POLL_INTERVAL_S * 1000, timeoutMs: PENDING_TIMEOUT_MS, cancelled: () => generation !== actionGeneration.current, }); if (outcome === "cancelled") return; - if (outcome === "timeout") latePending.current = pending; - setNotice(outcome === "done" ? pending.done : pending.late); + if (outcome === "timeout") { + awaited.current = { pending, late: true }; + return setNotice({ text: pending.late }); + } + awaited.current = null; + setNotice(outcome.ended); } const sendCode = () => act("email", async () => { @@ -292,7 +301,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { }); setEnterCode(true); setCode(""); - setNotice("Code sent. Check your inbox. It expires in 10 minutes."); + setNotice({ text: "Code sent. Check your inbox. It expires in 10 minutes." }); }); function submit(event: FormEvent) { event.preventDefault(); @@ -317,9 +326,9 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { setPasskeys(null); setEnterCode(false); setCode(""); - setNotice( - "Signed out of this browser. Your other devices stay signed in.", - ); + setNotice({ + text: "Signed out of this browser. Your other devices stay signed in.", + }); }); const turnOffVoice = () => mutate("voice-off", async () => { @@ -329,37 +338,41 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { // Closed either way, so a failure's error shows on the page. turnOffDialog.current?.close(); } - setNotice("Natural Voice is off. Your computers use the system voice."); + setNotice({ + text: "Natural Voice is off. Your computers use the system voice.", + }); }); - const approve = (code: string) => + const approve = (code: string, confirmDigits: string) => mutate("approve", async () => { const voice = enrolling?.purpose === "voice"; // The computer's next poll redeems the approval: until then neither - // Natural Voice nor the computer shows on the account. + // Natural Voice nor the computer shows on the account, nor the + // approval's own read says so. const shows = approvalShows(voice ? "voice" : "remote", { voice: hostedVoice, computers, }); - await approveEnrollment(code, enrolling?.purpose); + await approveEnrollment(code, enrolling?.purpose, confirmDigits); clearEnrollment(); const later = "Keep Dormouse open on your computer; this page updates when you come back to it."; - if (!shows) - return setNotice( - `Approved ${code}. Dormouse on your computer finishes the requested setup in a few seconds.`, - ); + const mismatch = `The digits you typed for ${code} were not the ones Dormouse showed on your computer, so nothing was approved. Start again from Dormouse on your computer.`; return voice ? { + userCode: code, shows, waiting: `Approved ${code}. Waiting for Dormouse on your computer to turn on Natural Voice…`, done: "Natural Voice is on.", late: `Approved ${code}, but Dormouse on your computer has not turned on Natural Voice yet. ${later}`, + mismatch, } : { + userCode: code, shows, waiting: `Approved ${code}. Waiting for Dormouse on your computer to finish enrolling…`, done: "Your computer is enrolled.", late: `Approved ${code}, but Dormouse on your computer has not finished enrolling yet. ${later}`, + mismatch, }; }); const clearEnrollment = () => { @@ -436,27 +449,22 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { )} {notice && ( -

- {notice} +

+ {notice.text}

)} {enrolling && (session || !enrolling.code) ? (
{enrolling.code ? ( <> -

{enrolling.code}

-

- Approve only if Dormouse on your computer is showing this - code right now. -

{fresh ? ( - + void approve(enrolling.code!, digits)} + /> ) : ( <>

@@ -758,7 +766,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { onClick={() => { setEnterCode(false); setCode(""); - setNotice(""); + setNotice(null); setError(keepRead); }} > diff --git a/hosted/src/ConfirmDigits.tsx b/hosted/src/ConfirmDigits.tsx new file mode 100644 index 000000000..33ec2ed15 --- /dev/null +++ b/hosted/src/ConfirmDigits.tsx @@ -0,0 +1,48 @@ +import { useState, type FormEvent } from "react"; + +/** + * Approve with the two digits Dormouse shows (docs/specs/hosted.md -> "Burrow + * enrollment"). This page cannot check them: the relay compares them when the + * computer polls, and an approval with other digits redeems nothing. + */ +export function ConfirmDigits({ + busy, + onApprove, +}: { + busy: string; + onApprove: (confirmDigits: string) => void; +}) { + const [digits, setDigits] = useState(""); + function submit(event: FormEvent) { + event.preventDefault(); + if (digits.length === 2 && !busy) onApprove(digits); + } + return ( +

+ + setDigits(event.target.value.replace(/\D/g, ""))} + /> +

+ Only the computer that sent you here shows them. If they are not its + digits, nothing is approved, and that code cannot be approved again. +

+ +
+ ); +} diff --git a/hosted/src/api.ts b/hosted/src/api.ts index ee597a49e..4244746e2 100644 --- a/hosted/src/api.ts +++ b/hosted/src/api.ts @@ -169,11 +169,26 @@ export async function removePasskey(credentialId: string) { }); if (!response.ok) throw await refused(response); } -export async function approveEnrollment(userCode: string, purpose: "remote" | "voice" = "remote") { +export async function approveEnrollment( + userCode: string, + purpose: "remote" | "voice" | undefined, + confirmDigits: string, +) { const response = await relay("/enrollments/approve", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ userCode, purpose }), + body: JSON.stringify({ userCode, purpose: purpose ?? "remote", confirmDigits }), }); if (!response.ok) throw await refused(response); } +/** What a poll made of this account's live approval of a code; neither when there is none. */ +export interface ApprovalState { + /** Typed with other digits than its computer showed: it never redeems. */ + mismatched: boolean; + redeemed: boolean; +} +export async function getApproval(userCode: string): Promise { + const response = await relay(`/enrollments/${encodeURIComponent(userCode)}`); + if (!response.ok) throw await refused(response); + return (await response.json()) as ApprovalState; +} diff --git a/hosted/src/settle.ts b/hosted/src/settle.ts index 2421f567b..8ed708de6 100644 --- a/hosted/src/settle.ts +++ b/hosted/src/settle.ts @@ -1,24 +1,59 @@ -import type { Computer, HostedVoice } from "./api"; +import type { ApprovalState, Computer, HostedVoice } from "./api"; /** The part of the account page's state a change finished elsewhere shows in. */ export interface AccountState { voice: HostedVoice | null; computers: Computer[] | null; + /** The approval the page waits on; unset when none is, or its read failed. */ + approval?: ApprovalState; +} + +/** A notice the page shows; `error` when it reports a failure. */ +export interface Notice { + text: string; + error?: boolean; +} + +/** An approval Dormouse on the computer finishes after the page's mutation. */ +export interface Pending { + /** The approved user code, read while the page waits. */ + userCode: string; + /** Whether the account, as last read, shows the change. */ + shows: (after: AccountState) => boolean; + waiting: string; + done: string; + late: string; + /** Once a read finds the digits typed were not the computer's. */ + mismatch: string; +} + +/** + * The notice a read of the account ends `pending` with, or null while it + * shows neither end. A mismatch first: that approval never redeems, so a + * change shown beside it is another's. + */ +export function pendingEnd( + pending: Pending, + after: AccountState, +): Notice | null { + if (after.approval?.mismatched) return { text: pending.mismatch, error: true }; + return pending.shows(after) ? { text: pending.done } : null; } /** - * What an approval's redemption changes on the page, read against the state - * before it: Natural Voice turning on, or a computer not listed before. Null - * when no change could show it: voice already on, or no computers list to - * compare with. + * What shows an approval redeemed, read against the state before it: Natural + * Voice turning on, or a computer not listed before. Where no change could + * show it (voice already on, or no computers list to compare with), the + * approval's own read saying it was redeemed. */ export function approvalShows( purpose: "remote" | "voice", before: AccountState, -): ((after: AccountState) => boolean) | null { +): (after: AccountState) => boolean { + const redeemed = (after: AccountState) => after.approval?.redeemed === true; if (purpose === "voice") - return before.voice?.on ? null : (after) => after.voice?.on === true; - if (!before.computers) return null; + return before.voice?.on ? redeemed : (after) => after.voice?.on === true; + if (!before.computers) return redeemed; const known = new Set(before.computers.map((computer) => computer.burrowId)); return (after) => !!after.computers?.some((computer) => !known.has(computer.burrowId)); @@ -92,33 +127,35 @@ export async function reread( } } -export type Settled = "done" | "timeout" | "cancelled"; +/** How {@link settle} stopped: a read's end, or no read ended it. */ +export type Settled = { ended: R } | "timeout" | "cancelled"; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** - * Reads every `intervalMs` until a read satisfies `done`, `timeoutMs` passes, - * or `cancelled()` holds. A read that fails, or resolves `undefined` (one a - * newer read superseded), only waits for the next. + * Reads every `intervalMs` until `end` maps a read to something other than + * null, `timeoutMs` passes, or `cancelled()` holds. A read that fails, or + * resolves `undefined` (one a newer read superseded), only waits for the next. */ -export async function settle( +export async function settle( read: () => Promise, - done: (value: T) => boolean, + end: (value: T) => R | null, options: { intervalMs: number; timeoutMs: number; cancelled: () => boolean; wait?: (ms: number) => Promise; }, -): Promise { +): Promise> { const { intervalMs, timeoutMs, cancelled, wait = delay } = options; for (let waited = 0; waited < timeoutMs; waited += intervalMs) { await wait(intervalMs); if (cancelled()) return "cancelled"; const value = await read().catch(() => undefined); if (cancelled()) return "cancelled"; - if (value !== undefined && done(value)) return "done"; + const ended = value === undefined ? null : end(value); + if (ended !== null) return { ended }; } return "timeout"; } diff --git a/hosted/src/style.css b/hosted/src/style.css index 9c966655d..54d8ead6a 100644 --- a/hosted/src/style.css +++ b/hosted/src/style.css @@ -192,6 +192,9 @@ section { background: var(--vscode-list-inactiveSelectionBackground); color: var(--vscode-list-inactiveSelectionForeground); } +.notice.failed { + color: var(--vscode-errorForeground); +} .identity { margin: 0; } @@ -245,11 +248,12 @@ dialog::backdrop { .enroll { margin-top: 0; } -.user-code { +.confirm-digits { + width: 5ch; font-size: 32px; font-weight: 600; - line-height: 1.2; letter-spacing: 0.08em; + text-align: center; margin-bottom: 16px; } .enroll .text-button { diff --git a/lib/src/components/HostedEnrollment.tsx b/lib/src/components/HostedEnrollment.tsx index f5b6bc162..3637af7d6 100644 --- a/lib/src/components/HostedEnrollment.tsx +++ b/lib/src/components/HostedEnrollment.tsx @@ -40,27 +40,45 @@ export function BurrowNameField({ ); } +/** What a Hosted approval turns on: remote control, or Natural Voice. */ +export type HostedApprovalPurpose = 'remote' | 'voice'; + /** - * The accessible name of the code a Hosted enrollment shows, which the account - * page shows beside Approve for the person to compare. + * The accessible name of the two digits a Hosted approval shows, which the + * person types beside Approve on the account page. */ -export const HOSTED_ENROLLMENT_CODE_LABEL = 'Enrollment code'; +export const HOSTED_ENROLLMENT_CODE_LABEL = 'Digits to type at your account'; + +/** What an approval that ended short did not do. */ +const NOT_DONE: Record = { + remote: 'this computer was not enrolled', + voice: 'Natural Voice was not turned on', +}; /** - * What an enrollment that ended short of enrolling says. **Fixed copy chosen - * by code**, as `PAIRING_OUTCOME_COPY` is: `failed` alone adds the service's - * own sentence. + * What an approval that ended short says, by purpose. **Fixed copy chosen by + * code**, as `PAIRING_OUTCOME_COPY` is: `failed` alone adds the host'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 has no Hosted plan, so this computer was not enrolled.', - 'answer-lost': - 'That code was approved and used, but the answer never reached this computer, so it was not enrolled.', - failed: 'This computer could not finish enrolling.', -}; +export const HOSTED_APPROVAL_ENDED_COPY = Object.fromEntries( + (['remote', 'voice'] as const).map((purpose) => { + const result = NOT_DONE[purpose]; + const copy: Record = { + expired: `That code expired before it was approved, so ${result}.`, + 'not-entitled': `The account that approved that code has no Hosted plan, so ${result}.`, + mismatch: `The digits typed at your account were not the ones shown here, so ${result}.`, + 'answer-lost': `That code was approved and used, but the answer never reached this computer, so ${result}.`, + failed: purpose === 'remote' ? 'This computer could not finish enrolling.' : 'Natural Voice could not finish turning on.', + }; + return [purpose, copy]; + }), +) as Record>; -/** The copy a status's `redeeming` shows: approved, and the enrollment being saved. */ -export const HOSTED_ENROLLMENT_REDEEMING_COPY = 'Approved. Enrolling this computer…'; +/** The copy a status's `redeeming` shows: approved, and the credential being saved. */ +export const HOSTED_APPROVAL_REDEEMING_COPY: Record = { + remote: 'Approved. Enrolling this computer…', + voice: 'Approved. Turning on Natural Voice…', +}; /** The account page the service names in `status.accountOrigin`, where computers are removed. */ export function accountPage(accountOrigin: string | null): string | null { @@ -74,19 +92,24 @@ export function answerLostRemoval(burrowId: string | undefined): string { : 'Remove the computer it added from your account, then enroll again.'; } -/** Why the last Hosted enrollment ended, with the account page where it says to go there. */ +/** + * Why the last Hosted approval ended, with the account page where it says to + * go there: only a lost remote-control answer left something to remove. + */ export function HostedEnrollmentEnded({ ended, accountOrigin, + purpose = 'remote', }: { ended: Extract; accountOrigin: string | null; + purpose?: HostedApprovalPurpose; }) { - const lost = ended.reason === 'answer-lost'; + const lost = purpose === 'remote' && ended.reason === 'answer-lost'; const page = lost ? accountPage(accountOrigin) : null; return (
-
{HOSTED_ENROLLMENT_ENDED_COPY[ended.reason]}
+
{HOSTED_APPROVAL_ENDED_COPY[purpose][ended.reason]}
{lost ?
{answerLostRemoval(ended.burrowId)}
: null} {ended.reason === 'failed' && ended.message ?
{ended.message}
: null} {ended.reason === 'not-entitled' ? ( @@ -103,13 +126,44 @@ export function HostedEnrollmentEnded({ ); } +/** + * A Hosted approval under way, for either purpose: the code waiting, the + * credential being saved, or why it ended. The host polls; this renders its + * state, never one of its own. + */ +export function HostedApprovalProgress({ + enrollment, + purpose, + accountOrigin, + busy, + onCancel, +}: { + enrollment: HostedEnrollmentState | null | undefined; + purpose: HostedApprovalPurpose; + accountOrigin: string | null; + busy: boolean; + onCancel: () => void; +}) { + if (enrollment?.status === 'waiting') { + return ; + } + if (enrollment?.status === 'redeeming') { + return ( +
+ {HOSTED_APPROVAL_REDEEMING_COPY[purpose]} +
+ ); + } + if (enrollment?.status === 'ended') { + return ; + } + return null; +} + /** * Enrolling (`docs/specs/hosted.md` -> "Burrow enrollment"): a button that * begins it, under the name to keep for this machine where `askName`; 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. + * {@link HostedApprovalProgress}; ended, a new code a click away. * * **The name field is hidden while a code waits, never unmounted**, so what * was typed survives a Cancel. Without `askName` the service's suggested name @@ -147,20 +201,13 @@ export function HostedEnrollView({ return (
- {waiting ? ( - void run(cancelHostedEnrollment)} - /> - ) : null} - {redeeming ? ( -
- {HOSTED_ENROLLMENT_REDEEMING_COPY} -
- ) : null} - {ended ? : null} + void run(cancelHostedEnrollment)} + />