From 5de10be348ccddc6a88bda9d36e8622eb474f33d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 08:27:11 -0700 Subject: [PATCH 1/2] List and remove Hosted Relay passkeys from the account page A passkey on the Hosted Relay now records the computer whose setup code registered it, and every Relay session records the passkey that signed it in. The account page lists the account's passkeys, each with its label, when it was added, and the enrolled computer that added it, and removes one: its row goes, and every session it signed in (and their presence nonces) with it. Removing a computer keeps the passkeys it added, no longer attributed, since one passkey signs a phone in to every computer of the account and each computer's pairings pin it. A full account's 409 now names the account page. Co-Authored-By: Claude Opus 5.5 --- .../passkey-removal-keeps-sessions.patch | 69 +++++++++++++++++ docs/specs/hosted.md | 7 +- docs/specs/hosted.rationale.md | 5 +- docs/specs/security-hosted.md | 4 +- docs/specs/security.md | 4 +- .../009_relay_passkey_removal.sql | 20 +++++ hosted/server/relay-account.ts | 33 ++++++++- hosted/server/relay-api.ts | 35 ++++++--- hosted/server/tests/boundary.test.ts | 4 +- hosted/server/tests/migrations.test.ts | 2 + hosted/server/tests/relay.test.ts | 61 ++++++++++++++- hosted/server/tests/workers.test.ts | 74 ++++++++++++++++++- hosted/src/App.tsx | 63 +++++++++++++++- hosted/src/api.ts | 23 +++++- scripts/spec-word-budgets.json | 4 +- 15 files changed, 377 insertions(+), 31 deletions(-) create mode 100644 .github/audit/canaries/passkey-removal-keeps-sessions.patch create mode 100644 hosted/server/dormouse-migrations/009_relay_passkey_removal.sql diff --git a/.github/audit/canaries/passkey-removal-keeps-sessions.patch b/.github/audit/canaries/passkey-removal-keeps-sessions.patch new file mode 100644 index 000000000..046e966dc --- /dev/null +++ b/.github/audit/canaries/passkey-removal-keeps-sessions.patch @@ -0,0 +1,69 @@ +Domain: hosted +Rule: S100 — or a removed passkey's sessions survive its removal +Expect: hosted/server/relay-api.ts +Source: synthetic; a session no longer names the passkey that signed it in, so removing that passkey on the account page leaves its sessions live + +diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts +index b227f87c4..918689c6e 100644 +--- a/hosted/server/relay-api.ts ++++ b/hosted/server/relay-api.ts +@@ -754,17 +754,14 @@ export async function admit( + }); + } + +-/** +- * A fresh session for `userId`, within its account's cap, signed in by +- * `credentialId`: removing that passkey ends it. +- */ +-async function mintSession(db: Client, userId: string, credentialId: string) { ++/** A fresh session for `userId`, within its account's cap. */ ++async function mintSession(db: Client, userId: string, _credentialId: string) { + const sessionToken = randomBase64Url(RELAY_BEARER_BYTE_LENGTH); + const expiresAt = await admit( + db, + SESSIONS, + userId, +- { tokenHash: digest(sessionToken), userId, credentialId }, ++ { tokenHash: digest(sessionToken), userId }, + RELAY_SESSION_TTL_MS, + ); + return { sessionToken, expiresAt: expiresAt! }; +diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts +index f26bd4613..6f64d96af 100644 +--- a/hosted/server/tests/relay.test.ts ++++ b/hosted/server/tests/relay.test.ts +@@ -454,32 +454,20 @@ test("a passkey names the computer whose code registered it and outlives it; rem + expect(await minter(phone)).toEqual([{ burrowId: null }]); + expect((await f.call("GET", API_ROUTES.burrows, { bearer: setupSession })).status).toBe(200); + +- // Every session a passkey signed in, setup's included, names it. + const signedIn = (await f.session(phone)).sessionToken; + const other = (await f.session(tablet)).sessionToken; +- expect( +- await f.sql(`SELECT "credentialId" FROM dormouse_relay_sessions WHERE "tokenHash" = ANY($1) ORDER BY 1`, [ +- [digest(setupSession), digest(signedIn)], +- ]), +- ).toEqual([{ credentialId: phone.credentialId }, { credentialId: phone.credentialId }]); + const pending = await f.call("POST", API_ROUTES.reauthBegin, { + bearer: signedIn, + body: { binding: f.pairing(desktop.burrowId, phone) }, + }); + expect(pending.status).toBe(200); + +- // Removing the passkey, as the account page does, ends its sessions and +- // their nonces, and it signs in to nothing again; the other passkey's stand. ++ // Removing the passkey, as the account page does, and it signs in to ++ // nothing again; the other passkey's sessions stand. + await f.sql(`DELETE FROM dormouse_relay_passkeys WHERE "credentialId" = $1 AND "userId" = $2`, [ + phone.credentialId, + owner, + ]); +- for (const bearer of [setupSession, signedIn]) +- expect(await f.call("GET", API_ROUTES.burrows, { bearer })).toMatchObject({ +- status: 401, +- json: { error: UNAUTHORIZED_ERROR }, +- }); +- expect(await f.sql(`SELECT nonce FROM dormouse_relay_presence_nonces`)).toEqual([]); + expect((await f.signin(phone)).status).toBe(404); + expect((await f.call("GET", API_ROUTES.burrows, { bearer: other })).status).toBe(200); + }); diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 07e5aa575..40fde3165 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -117,7 +117,7 @@ The relay Worker serves the self-host Relay's HTTP API to many accounts: the pat | Route | On Hosted | |---|---| -| `POST /api/setup/begin`, `/finish` | The passkey joins the account owning the token's Burrow: `accountId` is its user ID, `existingCredentialIds` its passkeys; 409 at `MAX_PASSKEYS_PER_ACCOUNT`; `finish`'s session counts toward `MAX_SESSIONS_PER_ACCOUNT` as sign-in's does | +| `POST /api/setup/begin`, `/finish` | The passkey joins the account owning the token's Burrow, recorded as that Burrow's: `accountId` is its user ID, `existingCredentialIds` its passkeys; 409 at `MAX_PASSKEYS_PER_ACCOUNT`, naming the account page; `finish`'s session counts toward `MAX_SESSIONS_PER_ACCOUNT` as sign-in's does | | `POST /api/setup/retire` | Spends only a token one of the session's account's Burrows minted | | `POST /api/signin/finish` | The asserted credential's account; `accountId` is its user ID. 401 `NOT_ENTITLED_ERROR`, and no session, for an account not entitled | | `POST /api/reauth/begin`, `/finish` | Only the session's account's credentials and nonces | @@ -220,13 +220,16 @@ Begin refuses an absent or mismatched origin with `ORIGIN_MISMATCH_ERROR`. The v | `POST /api/relay/enrollments/approve` | login cookie, exact `Origin`, JSON `{ userCode, purpose }` | 204 | | `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 | +| `DELETE /api/relay/passkeys/:credentialId` | login cookie, exact `Origin` | 204; 404 for another account's or an unknown ID | Errors are the managed-voice cookie routes' ("Managed voice"), except that their 403 carries `NOT_ENTITLED_ERROR`. - **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 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"). +- **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. A session from before sessions named their passkey lives out its expiry. - **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. diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 4f2ab6e5e..201b429e4 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -37,8 +37,8 @@ Three origins (decided 2026-09-30): Caps (2026-09-30): -- Passkeys per account (`MAX_PASSKEYS_PER_ACCOUNT`, 32): registration needs a setup token the account's own Burrow minted, so only the account grows its rows. A person registers one per phone or unsynced browser profile, and every `setup/begin` returns the whole list as `excludeCredentials`. A full account is refused rather than evicted, since evicting a passkey would sign a device out without saying so. -- Sessions per account (`MAX_SESSIONS_PER_ACCOUNT`, 32): each costs an assertion by one of the account's own passkeys, so only the account can spend it, and 32 is far above the browsers a person signs in from within the 12-hour lifetime. Evicting the oldest costs at worst one re-sign-in. +- Passkeys per account (`MAX_PASSKEYS_PER_ACCOUNT`, 32): registration needs a setup token one of the account's Burrows minted, but whoever runs that computer, or redeems a code it showed, grows the rows: a computer the account enrolled by approving someone else's code can fill them. A person registers one per phone or unsynced browser profile, and every `setup/begin` returns the whole list as `excludeCredentials`. A full account is refused rather than evicted, since evicting a passkey would sign a device out without saying so; the account page's removal is what frees a slot. +- Sessions per account (`MAX_SESSIONS_PER_ACCOUNT`, 32): each costs an assertion by one of the account's passkeys, and 32 is far above the browsers a person signs in from within the 12-hour lifetime. Evicting the oldest costs at worst one re-sign-in; a passkey evicting the rest stops when the account page removes it. - Setup challenges are keyed by the Burrow whose token began them, which also makes a challenge unredeemable with another Burrow's token. The self-host Relay's single flat issuer was the accepted exception for one tenant; across accounts, a flat map would let one account evict another's live registration. - Sign-in challenges stay flat: `signin/begin` has no caller to key on. The per-address limit, the two-minute expiry, and the hourly sweep bound them, rather than a global cap whose flood would evict every account's live sign-in. - A capped write prunes only its own key's expired rows because a table-wide prune would make every unauthenticated request a full-table delete; the Cron Trigger sweeps the rest. @@ -62,4 +62,5 @@ A `RelayRoom` that opened a Postgres connection through Hyperdrive could not be - Begin stores nothing (2026-10-01): a stored request needed a cap, and `begin` is unauthenticated, so the cap could only be global, which a few /64s at the per-address limit could fill, locking every Burrow out of enrolling. With the expiry inside the device code and the user code derived from it, begin costs one HMAC and grows nothing. - 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. - 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/security-hosted.md b/docs/specs/security-hosted.md index d76586567..1d51cb50d 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -60,12 +60,12 @@ 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 on every request, closes a removed computer's socket, 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`, and `007_independent_voice.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`, and `009_relay_passkey_removal.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. - **FAIL IF** `S99` a setup token, challenge, or presence nonce is checked and spent in separate statements, so two concurrent redeemers can both win, or a retire misses a setup token a `finish` claimed, a refused `finish` gives its token a later expiry or a second use rather than clearing that claim or spending the token, or a passkey insert does not first re-read the claim `FOR UPDATE`. -- **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. +- **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, 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** `S103` enrollment begin or poll admits a request carrying `Origin`, or either reaches the database before its per-address limit. diff --git a/docs/specs/security.md b/docs/specs/security.md index 76ffbf2a7..5f180af32 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -59,8 +59,8 @@ 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)). -- *Hosted phone, Self-hosted Relay.* **Whoever first redeems a live setup QR adds a passkey to your account and is signed in.** That still pairs no phone without the two digits typed here ([Trust boundary](./security-remote.md#trust-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, 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)). - **The workflow audit trusts what it classifies as routine.** A Renovate pin bump trusts the ref Renovate picked inside that action's repository; a bot commit under a forged human author passes when an admin push that replaces nothing carries it in, such as a cherry-pick or a rebase onto a new branch, or when it was first pushed more than a quarter ago ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). diff --git a/hosted/server/dormouse-migrations/009_relay_passkey_removal.sql b/hosted/server/dormouse-migrations/009_relay_passkey_removal.sql new file mode 100644 index 000000000..15b078687 --- /dev/null +++ b/hosted/server/dormouse-migrations/009_relay_passkey_removal.sql @@ -0,0 +1,20 @@ +-- Up Migration +-- An account removes a passkey from its account page (docs/specs/hosted.md -> +-- "Burrow enrollment"), and every session it signed in goes with it. +-- "burrowId" names the computer whose setup code registered the passkey, so +-- the page can say where it came from; removing that computer leaves the +-- passkey, unattributed, since a phone signs in to every computer of the +-- account with it. A passkey registered before this has none. A session +-- minted before this names no credential and lives out its 12 hours. +ALTER TABLE dormouse_relay_passkeys + ADD COLUMN "burrowId" text REFERENCES dormouse_relay_burrows ("burrowId") ON DELETE SET NULL; +CREATE INDEX dormouse_relay_passkeys_burrow ON dormouse_relay_passkeys ("burrowId"); +ALTER TABLE dormouse_relay_sessions + ADD COLUMN "credentialId" text REFERENCES dormouse_relay_passkeys ("credentialId") ON DELETE CASCADE; +CREATE INDEX dormouse_relay_sessions_credential ON dormouse_relay_sessions ("credentialId"); + +-- Down Migration +DROP INDEX dormouse_relay_sessions_credential; +ALTER TABLE dormouse_relay_sessions DROP COLUMN "credentialId"; +DROP INDEX dormouse_relay_passkeys_burrow; +ALTER TABLE dormouse_relay_passkeys DROP COLUMN "burrowId"; diff --git a/hosted/server/relay-account.ts b/hosted/server/relay-account.ts index 39c62ea4e..cb0bde3ae 100644 --- a/hosted/server/relay-account.ts +++ b/hosted/server/relay-account.ts @@ -25,9 +25,9 @@ export const ALREADY_APPROVED = "That code is already approved."; /** * The account's half of the Hosted Relay: approving a device-code enrollment, - * and listing and removing the account's Burrows. Cookie routes under the - * voice routes' gate; the relay Worker serves none of them. Call before any - * `/api/*` catch-all. + * and listing and removing the account's Burrows and passkeys. Cookie routes + * under the voice routes' gate; the relay Worker serves none of them. Call + * before any `/api/*` catch-all. */ export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAccountHost) { const gate = cookieEntitled(host, (c) => c.json({ message: NOT_ENTITLED_ERROR }, 403)); @@ -112,4 +112,31 @@ export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAc } return c.body(null, 204); }); + + // Each passkey with the computer whose setup code registered it, while that + // computer is enrolled: the label is whatever the registering phone chose. + app.get("/api/relay/passkeys", gate, async (c) => { + const passkeys = await accountQuery( + host(c), + `SELECT "credentialId", label, "createdAt", "burrowId" FROM dormouse_relay_passkeys + WHERE "userId" = $1 ORDER BY "createdAt" DESC, "credentialId"`, + [c.get("login").userId], + ); + return c.json({ passkeys }); + }); + + // The row goes, and every session it signed in with it (their presence + // nonces cascading from them), so it signs in to nothing again. A relay + // socket one of them already opened closes at that session's expiry. + app.delete("/api/relay/passkeys/:credentialId", gate, async (c) => { + const removed = + ( + await accountQuery( + host(c), + `DELETE FROM dormouse_relay_passkeys WHERE "credentialId" = $1 AND "userId" = $2 RETURNING 1`, + [c.req.param("credentialId"), c.get("login").userId], + ) + ).length > 0; + return removed ? c.body(null, 204) : c.json({ message: "Passkey not found." }, 404); + }); } diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index 70b908ad2..b227f87c4 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -275,10 +275,12 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { [spent.userId], ); if (count >= MAX_PASSKEYS_PER_ACCOUNT) return "full"; + // Attributed to the minting Burrow, so the account page can say where + // it came from. const inserted = await db.query( - `INSERT INTO dormouse_relay_passkeys ("credentialId", "userId", "publicKey", label) - VALUES ($1, $2, $3, $4) ON CONFLICT ("credentialId") DO NOTHING`, - [credentialId, spent.userId, publicKey, label], + `INSERT INTO dormouse_relay_passkeys ("credentialId", "userId", "publicKey", label, "burrowId") + VALUES ($1, $2, $3, $4, $5) ON CONFLICT ("credentialId") DO NOTHING`, + [credentialId, spent.userId, publicKey, label, spent.burrowId], ); if (!inserted.rowCount) return "duplicate"; await db.query(`DELETE FROM dormouse_relay_setup_tokens WHERE "tokenHash" = $1`, [ @@ -288,16 +290,22 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { }); if (outcome === "retired") return invalidSetup(c); if (outcome === "duplicate") return c.json({ error: DUPLICATE_CREDENTIAL_ERROR }, 409); - if (outcome === "full") + if (outcome === "full") { + const where = c.env.ACCOUNT_ORIGIN; return c.json( - { error: `this account already has ${MAX_PASSKEYS_PER_ACCOUNT} passkeys` }, + { + error: `this account already has ${MAX_PASSKEYS_PER_ACCOUNT} passkeys; remove one ${ + where ? `at ${where}/account` : "from your account" + } first`, + }, 409, ); + } registered = true; // Registering signs in too; `claimSetupToken` refused an owner not // entitled, so no session is minted for one. const res: SetupFinishResponse = { - ...(await mintSession(db, spent.userId)), + ...(await mintSession(db, spent.userId, credentialId)), accountId: spent.userId, credentialId, }; @@ -346,17 +354,17 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { WHERE p."credentialId" = $1`, [credentialId], ); - return row && { ...ownerOf(row), publicKey: row.publicKey }; + return row && { ...ownerOf(row), publicKey: row.publicKey, credentialId }; }, consumeChallenge: (challenge) => consumeChallenge(db, challenge, null), policy: { origin: c.env.APP_ORIGIN, rpId: rpIdOf(c.env) }, }); if (!verdict.ok) return c.json({ error: verdict.error }, verdict.status); - const { userId, entitled, publicKey } = verdict.passkey; + const { userId, entitled, publicKey, credentialId } = verdict.passkey; // A session is never minted for an account that is not entitled. if (!entitled) return c.json({ error: NOT_ENTITLED_ERROR }, 401); const res: SigninFinishResponse = { - ...(await mintSession(db, userId)), + ...(await mintSession(db, userId, credentialId)), accountId: userId, passkeyPublicKey: publicKey, }; @@ -746,14 +754,17 @@ export async function admit( }); } -/** A fresh session for `userId`, within its account's cap. */ -async function mintSession(db: Client, userId: string) { +/** + * A fresh session for `userId`, within its account's cap, signed in by + * `credentialId`: removing that passkey ends it. + */ +async function mintSession(db: Client, userId: string, credentialId: string) { const sessionToken = randomBase64Url(RELAY_BEARER_BYTE_LENGTH); const expiresAt = await admit( db, SESSIONS, userId, - { tokenHash: digest(sessionToken), userId }, + { tokenHash: digest(sessionToken), userId, credentialId }, RELAY_SESSION_TTL_MS, ); return { sessionToken, expiresAt: expiresAt! }; diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 0e1c004ea..8c223f945 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -125,11 +125,13 @@ afterAll(async () => { const send = (name: Name, url: string, method = "GET") => workers[name].dispatchFetch(url, { method, redirect: "manual" }); -/** The Relay's routes the account serves: approval, and its Burrows. */ +/** 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/burrows"], ["DELETE", "/api/relay/burrows/AAAAAAAAAAAAAAAAAAAAAA"], + ["GET", "/api/relay/passkeys"], + ["DELETE", "/api/relay/passkeys/AAAA"], ]; /** A route each Worker serves, as method and path. */ diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts index 5ec9c3aa0..9ec2e01c8 100644 --- a/hosted/server/tests/migrations.test.ts +++ b/hosted/server/tests/migrations.test.ts @@ -22,6 +22,8 @@ const PINNED: Record = { "6b6d95fd3e3e5d71f3915913d483edebd6837e0481388b4f2af11a8249364264", "008_voice_token_idle_expiry.sql": "b3f265e4c99b7d78c84aa58fe3e95b9a609c450ccc0a7f3701f871c8798d4801", + "009_relay_passkey_removal.sql": + "c6c220960187e33a1350dc85e93d24f99866a72a579ed9def6a3663643f3e928", }; const directory = new URL("../dormouse-migrations/", import.meta.url); diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index f05bbcc04..f26bd4613 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -427,6 +427,63 @@ test("setup, sign-in, and a presence proof end to end, scoped to the account tha expect(await f.sql(`SELECT * FROM dormouse_relay_${table}`), table).toEqual([]); }); +test("a passkey names the computer whose code registered it and outlives it; removing the passkey ends its sessions", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const owner = await f.account(ADMIN_EMAIL); + const laptop = await f.burrow(owner); + const desktop = await f.burrow(owner); + const phone = await newAuthenticator(); + const tablet = await newAuthenticator(); + const registered = await f.register(phone, await f.setupToken(laptop.token)); + expect(registered.status).toBe(200); + const setupSession = registered.json!.sessionToken; + expect((await f.register(tablet, await f.setupToken(desktop.token))).status).toBe(200); + const minter = (authenticator: Authenticator) => + f.sql(`SELECT "burrowId" FROM dormouse_relay_passkeys WHERE "credentialId" = $1`, [ + authenticator.credentialId, + ]); + expect(await minter(phone)).toEqual([{ burrowId: laptop.burrowId }]); + expect(await minter(tablet)).toEqual([{ burrowId: desktop.burrowId }]); + + // Removing the computer leaves its passkey, unattributed: the phone still + // signs in to the account's other computers. + await f.sql(`DELETE FROM dormouse_relay_burrows WHERE "burrowId" = $1`, [laptop.burrowId]); + expect(await minter(phone)).toEqual([{ burrowId: null }]); + expect((await f.call("GET", API_ROUTES.burrows, { bearer: setupSession })).status).toBe(200); + + // Every session a passkey signed in, setup's included, names it. + const signedIn = (await f.session(phone)).sessionToken; + const other = (await f.session(tablet)).sessionToken; + expect( + await f.sql(`SELECT "credentialId" FROM dormouse_relay_sessions WHERE "tokenHash" = ANY($1) ORDER BY 1`, [ + [digest(setupSession), digest(signedIn)], + ]), + ).toEqual([{ credentialId: phone.credentialId }, { credentialId: phone.credentialId }]); + const pending = await f.call("POST", API_ROUTES.reauthBegin, { + bearer: signedIn, + body: { binding: f.pairing(desktop.burrowId, phone) }, + }); + expect(pending.status).toBe(200); + + // Removing the passkey, as the account page does, ends its sessions and + // their nonces, and it signs in to nothing again; the other passkey's stand. + await f.sql(`DELETE FROM dormouse_relay_passkeys WHERE "credentialId" = $1 AND "userId" = $2`, [ + phone.credentialId, + owner, + ]); + for (const bearer of [setupSession, signedIn]) + expect(await f.call("GET", API_ROUTES.burrows, { bearer })).toMatchObject({ + status: 401, + json: { error: UNAUTHORIZED_ERROR }, + }); + expect(await f.sql(`SELECT nonce FROM dormouse_relay_presence_nonces`)).toEqual([]); + expect((await f.signin(phone)).status).toBe(404); + expect((await f.call("GET", API_ROUTES.burrows, { bearer: other })).status).toBe(200); +}); + test("one setup token registers once, however many finishes race it", async ({ onTestFinished }) => { const f = await fixture(); onTestFinished(f.close); @@ -669,7 +726,9 @@ test("every table a caller grows is capped by whoever grows it, and expired rows const full = tokens.at(-2)!; expect(await f.register(await newAuthenticator(), full)).toMatchObject({ status: 409, - json: { error: `this account already has ${MAX_PASSKEYS_PER_ACCOUNT} passkeys` }, + json: { + error: `this account already has ${MAX_PASSKEYS_PER_ACCOUNT} passkeys; remove one at ${ORIGINS.account}/account first`, + }, }); expect((await f.call("POST", API_ROUTES.setupBegin, { body: { setupToken: full } })).status).toBe(200); diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts index 33f10cd70..538f0b643 100644 --- a/hosted/server/tests/workers.test.ts +++ b/hosted/server/tests/workers.test.ts @@ -426,13 +426,22 @@ async function fixture( }>; const remove = (burrowId: string, from = origin) => request(`/api/relay/burrows/${burrowId}`, { method: "DELETE", headers: { origin: from } }); + const passkeys = async () => + (await request("/api/relay/passkeys")).json() as Promise<{ + passkeys: { credentialId: string; label: string; createdAt: string; burrowId: string | null }[]; + }>; + const removePasskey = (credentialId: string, from = origin) => + request(`/api/relay/passkeys/${encodeURIComponent(credentialId)}`, { + method: "DELETE", + headers: { origin: from }, + }); const accept = (version: unknown, from = origin) => request("/api/terms/acceptance", { method: "POST", body: JSON.stringify({ version }), headers: { origin: from, "content-type": "application/json" }, }); - return { request, post, session, email, oauth, hostedVoice, speak, forget, mint, approve, computers, remove, accept }; + return { request, post, session, email, oauth, hostedVoice, speak, forget, mint, approve, computers, remove, passkeys, removePasskey, accept }; } return { ...context, @@ -1213,6 +1222,69 @@ test("enrollment: only a recent admin login from this origin approves, and the B ).toEqual([{ redeemedBurrowId: null, redeemedAt: null }]); }); +test("the account lists its passkeys with the computer that added each, and removing one ends its sessions", 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 adminId = (await admin.session())!.user.id; + const otherId = (await other.session())!.user.id; + const sql = (text: string, values: unknown[] = []) => queryDatabase(f.database.url, text, values); + const laptop = "LLLLLLLLLLLLLLLLLLLLLL"; + await sql(`INSERT INTO dormouse_relay_burrows ("burrowId", "userId", "tokenHash") VALUES ($1, $2, 'l')`, [ + laptop, + adminId, + ]); + // A passkey a computer's code added, a legacy one with no computer, and + // another account's, each with a session it signed in. + for (const [credentialId, userId, burrowId] of [ + ["stranger-key", adminId, laptop], + ["own-key", adminId, null], + ["foreign-key", otherId, null], + ] as const) { + await sql( + `INSERT INTO dormouse_relay_passkeys ("credentialId", "userId", "publicKey", label, "burrowId") + VALUES ($1, $2, 'x', $1, $3)`, + [credentialId, userId, burrowId], + ); + await sql( + `INSERT INTO dormouse_relay_sessions ("tokenHash", "userId", "expiresAt", "credentialId") + VALUES ($1, $2, now() + interval '1 hour', $1)`, + [credentialId, userId], + ); + } + const listed = await admin.passkeys(); + expect(listed.passkeys.map(({ credentialId, label, burrowId }) => ({ credentialId, label, burrowId })).sort((a, b) => + a.credentialId < b.credentialId ? -1 : 1, + )).toEqual([ + { credentialId: "own-key", label: "own-key", burrowId: null }, + { credentialId: "stranger-key", label: "stranger-key", burrowId: laptop }, + ]); + expect(Math.abs(Date.parse(listed.passkeys[0].createdAt) - Date.now())).toBeLessThan(60_000); + // An account the Relay does not entitle sees none. + expect((await other.request("/api/relay/passkeys")).status).toBe(403); + + // Removing the computer keeps its passkey, unattributed. + expect((await admin.remove(laptop)).status).toBe(204); + expect((await admin.passkeys()).passkeys.find((p) => p.credentialId === "stranger-key")?.burrowId).toBeNull(); + + // Remove: this origin only; another account's passkey is not found. + expect((await admin.removePasskey("stranger-key", ORIGINS.relay)).status).toBe(403); + expect((await admin.removePasskey("foreign-key")).status).toBe(404); + expect((await admin.removePasskey("stranger-key")).status).toBe(204); + expect((await admin.removePasskey("stranger-key")).status).toBe(404); + expect((await admin.passkeys()).passkeys.map(({ credentialId }) => credentialId)).toEqual(["own-key"]); + // Its session went with it; every other stands. + expect(await sql(`SELECT "tokenHash" FROM dormouse_relay_sessions ORDER BY "tokenHash"`)).toEqual([ + { tokenHash: "foreign-key" }, + { tokenHash: "own-key" }, + ]); +}); + test("enrollment approval needs a login from the recent-login window, and attempts are limited per account", async ({ onTestFinished, }) => { diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index 23f258e04..2b7816ee1 100644 --- a/hosted/src/App.tsx +++ b/hosted/src/App.tsx @@ -11,16 +11,19 @@ import { getAccounts, getComputers, getHostedVoice, + getPasskeys, getProviders, getSession, post, providerNames, removeComputer, + removePasskey, social, turnOffHostedVoice, type Account, type Computer, type HostedVoice, + type Passkey, type Provider, type Session, } from "./api"; @@ -73,6 +76,8 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { const turnOffDialog = useRef(null); // Null hides the Computers section. const [computers, setComputers] = useState(null); + // Null hides the Passkeys section. + const [passkeys, setPasskeys] = useState(null); // The enrollment awaiting approval, in memory only: through sign-in and // back, never into storage (docs/specs/hosted.md -> "Burrow enrollment"). const [enrolling, setEnrollingState] = useState(enrollment); @@ -102,13 +107,14 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { getProviders(), ]); // A failed list hides its section, never the account page. - const [linked, voice, enrolled] = current + const [linked, voice, enrolled, registered] = current ? await Promise.all([ getAccounts(), getHostedVoice().catch(() => null), getComputers().catch(() => null), + getPasskeys().catch(() => null), ]) - : [[], null, null]; + : [[], null, null, null]; if (current) await recordContinued().catch(() => {}); if (generation !== refreshGeneration.current) return; setSession(current); @@ -116,6 +122,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { setAccounts(linked); setHostedVoice(voice); setComputers(enrolled); + setPasskeys(registered); history.replaceState( null, "", @@ -224,6 +231,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { setAccounts([]); setHostedVoice(null); setComputers(null); + setPasskeys(null); setEnterCode(false); setCode(""); await refresh(); @@ -264,6 +272,22 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { enrolled?.filter((computer) => computer.burrowId !== burrowId) ?? null, ); + // Its passkeys stay, no longer attributed to it. + setPasskeys( + (registered) => + registered?.map((passkey) => + passkey.burrowId === burrowId ? { ...passkey, burrowId: null } : passkey, + ) ?? null, + ); + }); + const removeKey = (credentialId: string) => + act(`passkey-${credentialId}`, async () => { + await removePasskey(credentialId); + setPasskeys( + (registered) => + registered?.filter((passkey) => passkey.credentialId !== credentialId) ?? + null, + ); }); // A lapsed account still sees, and may turn off, a Natural Voice left on. const voiceShown = !!hostedVoice && (hostedVoice.entitled || hostedVoice.on); @@ -540,6 +564,41 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { ))} )} + {passkeys && ( +
+

Passkeys

+

+ Each passkey signs a phone in to every computer above. A + computer’s setup code adds one. Remove any you do not + recognize, and that phone can no longer sign in. +

+ {passkeys.length === 0 && ( +

No passkeys yet.

+ )} + {passkeys.map((passkey) => ( +
+ + {passkey.label || "Unnamed passkey"} + + Added{" "} + {new Date(passkey.createdAt).toLocaleDateString()} + {passkey.burrowId + ? ` from Computer ${passkey.burrowId.slice(0, 8)}` + : ""} + + + +
+ ))} +
+ )}

Signed in on this browser

diff --git a/hosted/src/api.ts b/hosted/src/api.ts index 93fac8131..ee597a49e 100644 --- a/hosted/src/api.ts +++ b/hosted/src/api.ts @@ -125,7 +125,7 @@ async function relay(path: string, init: RequestInit = {}): Promise { return request( `/api/relay${path}`, init, - "Computers are temporarily unavailable. Try again.", + "Computers and passkeys are temporarily unavailable. Try again.", ); } /** The server's message for a refused request: each is written for this page. */ @@ -148,6 +148,27 @@ export async function removeComputer(burrowId: string) { }); if (!response.ok) throw await refused(response); } +export interface Passkey { + credentialId: string; + /** Chosen by the phone that registered it. */ + label: string; + createdAt: string; + /** The enrolled computer whose setup code registered it, or null once removed. */ + burrowId: string | null; +} +/** Null when the server says this account may not use the Hosted Relay. */ +export async function getPasskeys(): Promise { + const response = await relay("/passkeys"); + if (response.status === 401 || response.status === 403) return null; + if (!response.ok) throw await refused(response); + return ((await response.json()) as { passkeys: Passkey[] }).passkeys; +} +export async function removePasskey(credentialId: string) { + const response = await relay(`/passkeys/${encodeURIComponent(credentialId)}`, { + method: "DELETE", + }); + if (!response.ok) throw await refused(response); +} export async function approveEnrollment(userCode: string, purpose: "remote" | "voice" = "remote") { const response = await relay("/enrollments/approve", { method: "POST", diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 53f806eaa..f7b360e8c 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,7 +12,7 @@ "docs/specs/dor-tools-builtin.md": 1700, "docs/specs/dor-tools-lib.md": 350, "docs/specs/glossary.md": 2950, - "docs/specs/hosted.md": 4400, + "docs/specs/hosted.md": 4450, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4150, @@ -30,7 +30,7 @@ "docs/specs/security-local.md": 5900, "docs/specs/security-remote.md": 7050, "docs/specs/security-supply-chain.md": 1650, - "docs/specs/security.md": 2600, + "docs/specs/security.md": 2650, "docs/specs/shortcuts.md": 1150, "docs/specs/standalone.md": 6450, "docs/specs/terminal-context.md": 950, From c4839faf77208ba14177f0861ff7ddf1a1840a9e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 08:55:33 -0700 Subject: [PATCH 2/2] Share the full-account hint between the passkey and computer caps Co-Authored-By: Claude Opus 5.5 --- hosted/server/relay-api.ts | 20 ++++++++------------ hosted/server/tests/workers.test.ts | 13 +++++++------ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index b227f87c4..4d4483e07 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -290,17 +290,13 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { }); if (outcome === "retired") return invalidSetup(c); if (outcome === "duplicate") return c.json({ error: DUPLICATE_CREDENTIAL_ERROR }, 409); - if (outcome === "full") { - const where = c.env.ACCOUNT_ORIGIN; + if (outcome === "full") return c.json( { - error: `this account already has ${MAX_PASSKEYS_PER_ACCOUNT} passkeys; remove one ${ - where ? `at ${where}/account` : "from your account" - } first`, + error: `this account already has ${MAX_PASSKEYS_PER_ACCOUNT} passkeys; ${removeOneFirst(c.env)}`, }, 409, ); - } registered = true; // Registering signs in too; `claimSetupToken` refused an owner not // entitled, so no session is minted for one. @@ -601,17 +597,13 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { ); return spent ? { redeemed: spent.redeemedBurrowId } : "expired"; }); - if (outcome === "full") { - const where = c.env.ACCOUNT_ORIGIN; + if (outcome === "full") return c.json( { - error: `this account already has ${MAX_BURROWS_PER_ACCOUNT} computers enrolled; remove one ${ - where ? `at ${where}/account` : "from your account" - } first`, + error: `this account already has ${MAX_BURROWS_PER_ACCOUNT} computers enrolled; ${removeOneFirst(c.env)}`, }, 409, ); - } if (outcome === "expired") return answer({ status: "expired" }); if (outcome !== "enrolled") return answer({ status: "redeemed", burrowId: outcome.redeemed }); return answer({ @@ -778,6 +770,10 @@ function setupTokenOf(body: unknown): string | null { const rpIdOf = (env: RelayEnv) => new URL(env.APP_ORIGIN).hostname; +/** What a full account's 409 tells it to do: free a slot on its account page. */ +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") { if (!env.RELAY_ENROLL_SECRET) throw new Error("RELAY_ENROLL_SECRET is not set"); diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts index 538f0b643..fbb21ab24 100644 --- a/hosted/server/tests/workers.test.ts +++ b/hosted/server/tests/workers.test.ts @@ -1258,12 +1258,13 @@ test("the account lists its passkeys with the computer that added each, and remo ); } const listed = await admin.passkeys(); - expect(listed.passkeys.map(({ credentialId, label, burrowId }) => ({ credentialId, label, burrowId })).sort((a, b) => - a.credentialId < b.credentialId ? -1 : 1, - )).toEqual([ - { credentialId: "own-key", label: "own-key", burrowId: null }, - { credentialId: "stranger-key", label: "stranger-key", burrowId: laptop }, - ]); + expect(listed.passkeys.map(({ createdAt, ...passkey }) => passkey)).toEqual( + expect.arrayContaining([ + { credentialId: "own-key", label: "own-key", burrowId: null }, + { credentialId: "stranger-key", label: "stranger-key", burrowId: laptop }, + ]), + ); + expect(listed.passkeys).toHaveLength(2); expect(Math.abs(Date.parse(listed.passkeys[0].createdAt) - Date.now())).toBeLessThan(60_000); // An account the Relay does not entitle sees none. expect((await other.request("/api/relay/passkeys")).status).toBe(403);