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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/audit/canaries/passkey-removal-keeps-sessions.patch
Original file line number Diff line number Diff line change
@@ -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);
});
7 changes: 5 additions & 2 deletions docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions docs/specs/hosted.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
4 changes: 2 additions & 2 deletions docs/specs/security-hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/specs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
Loading
Loading