From f8d63a06d8ec0c96d8057399a2fbbe7a86efe9ff Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 30 Oct 2026 20:05:43 -0400 Subject: [PATCH] feat: record a buddies inbox's successor after a key change --- .agents/skills/verify-ww-api/SKILL.md | 2 +- AGENTS.md | 52 +- docs/buddies-protocol.md | 22 +- migrations/accounts/0008_inbox-successor.sql | 23 + migrations/accounts/meta/0008_snapshot.json | 1178 ++++++++++++++++++ migrations/accounts/meta/_journal.json | 7 + src/accounts/inboxSuccessors.ts | 189 +++ src/accounts/schema.ts | 29 + src/accounts/store.ts | 31 +- src/buddies/contracts.ts | 46 +- src/buddies/inboxDO.ts | 9 + src/buddies/limits.ts | 3 +- src/buddies/openapi.ts | 57 +- src/buddies/route.ts | 174 ++- src/buddies/successor.test.ts | 772 ++++++++++++ src/e2e/inboxSuccessor.e2e.test.ts | 210 ++++ src/index.ts | 12 +- 17 files changed, 2785 insertions(+), 31 deletions(-) create mode 100644 migrations/accounts/0008_inbox-successor.sql create mode 100644 migrations/accounts/meta/0008_snapshot.json create mode 100644 src/accounts/inboxSuccessors.ts create mode 100644 src/buddies/successor.test.ts create mode 100644 src/e2e/inboxSuccessor.e2e.test.ts diff --git a/.agents/skills/verify-ww-api/SKILL.md b/.agents/skills/verify-ww-api/SKILL.md index 8861f48..9f9429e 100644 --- a/.agents/skills/verify-ww-api/SKILL.md +++ b/.agents/skills/verify-ww-api/SKILL.md @@ -57,7 +57,7 @@ node scripts/verify/dev.mjs kv get app-store-ratings:summary node scripts/verify/dev.mjs kv delete buddies:enabled ``` -- For the isolated local worker, send a unique `cf-connecting-ip` per request (`198.18.x.y` or similar). Never send synthetic caller IPs to a deployed Worker: Cloudflare rejects them before the request reaches the API. Without one, every request shares the same per-IP key, and you hit the local rate limiters: 60/min per family (`/geocode` + `/autocomplete`, `/notes-import*`, `/attestation/*`, `/route-planning/*`, `/maps/*`, and `/admin/*` each count separately; `/places/*` counts with `/geocode`), and the Buddies per-caller tiers (IPv6 grouped by /64): 120/min for `invite/fetch` + `invite/claim`, 60/min for `inbox/register`, 600/min for `inbox/sync` + `inbox/live`, 60/min for `blob/put`, 600/min for `blob/get`, 600/min for other signed ops. The e2e helpers and the fuzzer already do this. +- For the isolated local worker, send a unique `cf-connecting-ip` per request (`198.18.x.y` or similar). Never send synthetic caller IPs to a deployed Worker: Cloudflare rejects them before the request reaches the API. Without one, every request shares the same per-IP key, and you hit the local rate limiters: 60/min per family (`/geocode` + `/autocomplete`, `/notes-import*`, `/attestation/*`, `/route-planning/*`, `/maps/*`, and `/admin/*` each count separately; `/places/*` counts with `/geocode`), and the Buddies per-caller tiers (IPv6 grouped by /64): 120/min for `invite/fetch` + `invite/claim`, 60/min for `inbox/register`, 600/min for `inbox/sync`, `inbox/live`, and `inbox/successor`, 60/min for `blob/put`, 600/min for `blob/get`, 600/min for other signed ops. The e2e helpers and the fuzzer already do this. - Signed Buddies ops need Ed25519 envelopes. Use `RelayOwner` and `RelayWriter` in `src/test/e2e.ts` (built on `src/test/buddiesClient.ts`) rather than hand-rolling curl. Photo blobs: `photoBlob()` makes sealed-looking bytes with their id and token, `RelayOwner.putBlob` and `getBlob` send them (raw bytes, envelope in headers). - KV edits take effect on the next request (no 60 s edge cache locally). diff --git a/AGENTS.md b/AGENTS.md index 386d81c..09ef2ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -660,6 +660,50 @@ Links. Clients are iOS and Android through: one more reason to turn `auth:legacy` off before relying on suspension. `owner_account` needs no migration (created in place at registration, `confirmed` added in place). +- **Inbox successor** (witness-work ADR 0039 §5; the protocol's "Inbox + successor"): after a key change the app deletes its old inbox with + `inbox/delete` `{ inboxId, successor }`, and buddies check a + `pair.reconnect` with `POST /buddies/v1/inbox/successor` `{ inboxId }` → + `{ ok, successor }` (any account's bearer token, read tier, kill switch + applies, no legacy form). Code in `src/accounts/inboxSuccessors.ts` and + `deleteWithSuccessor`/`handleSuccessor` in `route.ts`; D1 table + `inbox_successors` and `account_inboxes.key_epoch` (migration + `0008_inbox-successor.sql`, which backfills each link with the epoch its + account had when it was linked, from `retired_account_keys`: 0 for every + account that never changed its key; a link made within a second after a + change counts as the old key's). A link records the epoch of the key + that verified its proof (read with that key, so a key change landing in + between leaves it at the old epoch), and a later proof by a newer key + moves an existing link up (`confirmInboxLink`). Recording is one + insert-select (`recordInboxSuccessor`) gated on the deleted inbox being + the token's account's (linked, or already replaced by it), the successor + being another inbox linked to it at its current epoch with no successor + of its own, and the caller's session being live at that epoch, after the + successor's DO said it's registered (`isRegistered`, read-only); then the + inbox DO deletes, and the deleted inbox's link is dropped + (`unlinkReplacedInbox`). An inbox with any successor recorded is never + registered again (409 `conflict` in `checkInboxAccount`, bearer or legacy, + proof or not; a legacy registration while D1 is down still falls back to + registering unlinked, as before). A legacy request naming `successor` is 403 + `forbidden`, another account's inbox 403, a suspended account 403 + `suspended` (deleting without a successor still works), an ended session + 401, a bad successor 409 `conflict` with nothing deleted. The first record wins + while it still qualifies (another successor is 409; the same one + refreshes it), and one that no longer does (from before a key change) is + replaced, in the upsert's own `WHERE`. A repeat answers `ok` once the DO + says `not_found` (`buddies.deleted` is logged only for the real + deletion), and a DO refusal or a throw drops the record again (a retry + records it once more). The read checks every time that the successor is + still linked to the same account at its current epoch and that the + account isn't suspended, and answers `null` otherwise (so a K1 holder's + record from before the change is never vouched for after it), or after + 90 days; the hourly cron prunes rows 90 days old. It tells anyone with a + token who knows the old inbox id that it was replaced, and by which inbox. + The guarantee holds only after the key change: buddies who accept a K1 + holder's reconnect before it aren't recovered, and the old inbox must be + linked to the account before the change (the app links it first and + skips the invites if it can't), since another account holding the old + key could link an unlinked old inbox and name its own successor. - **Bindings** (repeated under `[env.dev]`): `BUDDY_INBOX` (`BuddyInbox`, one SQLite DO per `inboxId`), `BUDDY_INVITE` (`BuddyInvite`, one per `inviteId`), migration `v3`, `BUDDY_REGISTRATION_QUOTA` (`BuddyRegistrationQuota`, one per @@ -677,8 +721,9 @@ Links. Clients are iOS and Android client IP (IPv6 by /64); never the target inbox. The budgets were sized per IP, so one account gets the same. `BUDDIES_RATE_LIMITER` 120/min for `invite/fetch` + `invite/claim`, `BUDDIES_REGISTER_LIMITER` 60/min for - `inbox/register`, `BUDDIES_READ_LIMITER` 600/min for `inbox/sync` + - `inbox/live`, `BUDDIES_BLOB_PUT_LIMITER` 60/min for `blob/put`, + `inbox/register`, `BUDDIES_READ_LIMITER` 600/min for `inbox/sync`, + `inbox/live`, and `inbox/successor`, `BUDDIES_BLOB_PUT_LIMITER` 60/min + for `blob/put`, `BUDDIES_BLOB_GET_LIMITER` 600/min for `blob/get`, `BUDDIES_WRITE_LIMITER` 600/min for every other op (namespaces 1002-1007 prod, 2002-2007 dev; `limits.test.ts` keeps `wrangler.toml` equal to the @@ -1546,6 +1591,9 @@ deviation in the PR). Code in `src/accounts/rekey.ts`; retired keys in counted from `account.key_changed` entries (checked first for `Retry-After`, and again in the swap's own statement), plus the register edge tier. +- **Buddies**: inbox links record the key epoch whose key signed them, and + `inbox/delete` with `successor` records the new inbox for buddies' reconnect + checks, only for a link at the current epoch (Buddies → Inbox successor). - **Privacy**: never log or report keys, the history blob, wraps, credential ids, account ids, or session ids. The history is sealed by the devices; the server stores it opaque. diff --git a/docs/buddies-protocol.md b/docs/buddies-protocol.md index adc5686..83c1e70 100644 --- a/docs/buddies-protocol.md +++ b/docs/buddies-protocol.md @@ -57,6 +57,7 @@ Every op also takes `Authorization: Bearer ` from an attested acco | `blob/put`, `inbox/live` | The token, and the same fields as JSON, `b64u`-encoded, in `x-buddies-payload`, with no `x-buddies-signature` (either signature header, or only the short `x-buddies-p`, is `bad_request`). Same account check as owner ops. | | Writer ops | The token, plus the writer envelope above. The writer signature stays the slot's capability; the relay checks the token's session (and, except for `slot/leave`, that its account isn't suspended) but doesn't compare its account with the slot or store it. | | `invite/fetch`, `invite/claim`, `blob/get` | The token, plus today's body; the capability is unchanged. | +| `inbox/successor` | Bearer only: the token of any account, and `{ "inboxId" }` as plain JSON. No legacy form (`unauthorized` without a token). See [Inbox successor](#inbox-successor). | - A missing, malformed, expired, or invalid token is `401 unauthorized` with `WWW-Authenticate: Bearer error="invalid_token"`; the client refreshes once and retries. A bearer request never falls back to the signature. - Bearer owner requests carry no `ts` or `nonce`, so there is no `stale` or `replay` for them, and repeating one is fine (`event/put` and `blob/put` stay idempotent by id). Signed parts (the `inbox/register` and writer envelopes) keep both checks. @@ -76,14 +77,14 @@ Success: HTTP 200 with `{ "ok": true, ... }`. Failure: `{ "ok": false, "error": | 401 | `stale` | `ts` outside ±5 min; carries `serverTime` (ms) and a `Date` header | | 409 | `replay` | Nonce reused | | 404 | `not_found` | Unknown inbox / invite (includes expired, deleted, or burned invites) | -| 409 | `conflict` | Inbox already registered with a different key; invite already claimed | +| 409 | `conflict` | Inbox already registered with a different key, or replaced by a successor; invite already claimed; an `inbox/delete` `successor` that isn't another registered inbox linked to the same account at its current key epoch with no successor of its own, or differs from one already recorded that still qualifies | | 410 | `gone` | Slot does not exist (buddy removed you) | | 429 | `limit` | A count cap was hit (slots, invites, devices) | | 429 | `rate_limited` | A rate limit or a stored-event cap was hit; carries `Retry-After` | | 503 | `disabled` | Kill switch is off; carries `Retry-After` | | 426 | `upgrade_required` | `inbox/live` requested without a WebSocket upgrade, or a legacy request after the legacy switch was turned off (except the Notification Service Extension's `inbox/sync` for an inbox with a confirmed link); no `Retry-After` | | 401 | `unauthorized` | A bearer request's token is missing, expired, or invalid; carries `WWW-Authenticate` | -| 403 | `forbidden` | A bearer request for an inbox linked to another account, or an `account` proof for another account than the token's | +| 403 | `forbidden` | A bearer request for an inbox linked to another account, an `account` proof for another account than the token's, or an `inbox/delete` `successor` in a legacy request | | 409 | `not_linked` | A bearer owner request for an inbox with no confirmed link: registered before accounts, or its linking registration failed part-way (link it with `inbox/register`) | | 413 | `too_large` | A `blob/put` body, declared or streamed, over 1 MiB | | 503 | `photos_disabled` | Photo blobs are off (their own switch); carries `Retry-After` | @@ -101,9 +102,9 @@ Payload fields listed are **in addition** to `ts` and `nonce` for signed ops. | Op | Payload | Response | Notes | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `inbox/register` | `inboxId`, `ownerPub`, `account?`, `attest?` | `{ ok, accountId? }` | Idempotent for the same `ownerPub`; `conflict` if different, even after the inbox was wiped. `account` (`{ accountId, signature }`) links the inbox to the User's account (required in a [bearer request](#authentication-sessions), for the token's account); `accountId` in the answer is the account the inbox belongs to. `unknown_account` for an account the relay doesn't know, `suspended` for a suspended one. `attest` is reserved (ignored). | +| `inbox/register` | `inboxId`, `ownerPub`, `account?`, `attest?` | `{ ok, accountId? }` | Idempotent for the same `ownerPub`; `conflict` if different, even after the inbox was wiped. `account` (`{ accountId, signature }`) links the inbox to the User's account (required in a [bearer request](#authentication-sessions), for the token's account); `accountId` in the answer is the account the inbox belongs to. `unknown_account` for an account the relay doesn't know, `suspended` for a suspended one. `attest` is reserved (ignored). An inbox replaced by a successor ([Inbox successor](#inbox-successor)) is `conflict` for good. | | `inbox/sync` | `inboxId`, `since` (int ≥ 0) | see below | Touches `lastActiveAt`. | -| `inbox/delete` | `inboxId` | `{ ok }` | Wipes the inbox (slots, cards, events, devices, roster) and every open invite it created, keeping only a hash of the owner key. Allowed even when disabled. | +| `inbox/delete` | `inboxId`, `successor?` | `{ ok }` | Wipes the inbox (slots, cards, events, devices, roster) and every open invite it created, keeping only a hash of the owner key. Allowed even when disabled. `successor` (bearer only) names the inbox that replaces this one after a key change; see [Inbox successor](#inbox-successor). | | `device/register` | `inboxId`, `deviceId`, `pushService?` (`"apns"` \| `"fcm"`), then for APNs `apnsToken` (hex), `apnsEnvironment` (`"sandbox"` \| `"production"`), `apnsTopic?`, or for FCM `fcmToken` and `appAlerts?`; `templates` | `"production"`), `apnsTopic?`, `templates` | Upsert by `deviceId`. Max 10 devices per inbox (least recently registered evicted). `pushService` says how the relay reaches the device: APNs (iOS; also when absent, as builds before Android send none) or FCM (Android); the other service's fields are ignored. `fcmToken` is the device's Firebase Cloud Messaging registration token (32–4096 characters of `[A-Za-z0-9_:-]`). `appAlerts: true` (FCM, a boolean when present) says the app posts its own named alerts; see [FCM](#fcm-android). `templates` is `{ [kind]: { title, body } }`, strings ≤ 200 chars — already localized by the device. `apnsTopic` is the app's bundle id (e.g. `com.leviwilkerson.jwtimebeta` for Beta); it must be one the worker accepts (`IOS_BUNDLE_ID` or `IOS_ADDITIONAL_BUNDLE_IDS`), else `bad_request`. Absent means `IOS_BUNDLE_ID`. One device per token: registering a token another `deviceId` holds moves it. Re-registering an unchanged device is an idempotent refresh that bumps its eviction order (and re-creates it if the relay removed it); apps re-register about daily. | Upsert by `deviceId`. Max 10 devices per inbox (least recently registered evicted). `templates` is `{ [kind]: { title, body } }`, strings ≤ 200 chars — already localized by the device. `apnsTopic` is the app's bundle id (e.g. `com.leviwilkerson.jwtimebeta` for Beta); it must be one the worker accepts (`IOS_BUNDLE_ID` or `IOS_ADDITIONAL_BUNDLE_IDS`), else `bad_request`. Absent means `IOS_BUNDLE_ID`. Re-registering an unchanged device is an idempotent refresh that bumps its eviction order (and re-creates it if the relay removed it); apps re-register about daily. | | `device/unregister` | `inboxId`, `deviceId` | `{ ok }` | Idempotent. | | `slot/add` | `inboxId`, `slotId`, `writerPub` | `{ ok }` | Registers a buddy's writer key. Idempotent for the same key. `limit` when `slots + open invites ≥ 5`. | @@ -138,6 +139,16 @@ Payload fields listed are **in addition** to `ts` and `nonce` for signed ops. - `cards`, `events`, and `roster` include only items with `seq > since`; `roster` is `null` when unchanged. `slots` is always the complete current list (≤ 5), so a missing slot tells the owner that buddy ended the connection. - Events created by the relay itself (invite claims) use `slotId: ""`. +### Inbox successor + +After an account key change ([ADR 0039 §5](https://github.com/leviFrosty/witness-work/blob/main/docs/adr/0039-change-the-account-key.md#5-buddies-reset-with-reconnect-invites)), the User's device registers a new inbox, deletes the old one, and sends each buddy a `pair.reconnect` naming the new inbox through the old pairing. That pairing's keys come from the old account key, so anyone holding it could forge a reconnect to an inbox of their own. The relay records which inbox replaced the old one, and a buddy accepts a reconnect only when the relay names the offered inbox. + +- **Link epoch.** Every account link (`inbox/register` with `account`) records the account's key epoch whose key signed the proof. A later registration whose proof the current key signed moves an existing link up to the current epoch. A link the old key made stays at the old epoch. +- **Recording: `inbox/delete` with `successor`.** Bearer only (`forbidden` in a legacy request), from a session of the account the inbox is linked to (`forbidden` otherwise), which isn't suspended (`403 suspended`; deleting without a successor still works), that is still live at the account's key epoch (`unauthorized` otherwise: a key change ends every other session, though their access tokens verify for minutes more). `successor` must be another registered inbox (not deleted or wiped), linked to the same account at its current key epoch, and not replaced itself (no chains or cycles), else `409 conflict` and nothing is deleted. The relay records it, then deletes the inbox; if the inbox refuses (`not_linked`, say) or can't be reached, the record is dropped again. Once the inbox is gone, its account link is dropped, and the inbox can never be registered again: `inbox/register` for it is `409 conflict`, bearer or legacy, with or without a proof, for as long as the record is kept. Repeating the deletion still works for the account that recorded it. Repeating the request answers `ok` once the inbox is gone. The first successor recorded stays while it still qualifies (naming another one is `409 conflict`); one that no longer does (recorded before a key change) is replaced. Kept 90 days. +- **Reading: `POST inbox/successor`** `{ "inboxId" }` → `{ "ok": true, "successor": "" | null }`. Any account's bearer token; the read edge tier. The relay checks at every read that the recorded successor is still linked to the same account at the account's current key epoch, and that the account isn't suspended, and answers `null` otherwise, as it does for an inbox nobody replaced and a record over 90 days old. So a successor recorded before a key change (by anyone holding the old key) is never vouched for after it, and the device that changed the key names its own. Anyone with a token who knows the old `inboxId` learns that it was replaced, and by which inbox: buddies know the old id, and the new one is in the reconnect they're sent anyway, but nobody else should be given old inbox ids. +- **Why a thief can't pass, once the key has changed.** After the change, the old key opens no session (`key_retired`) and its sessions have ended, so it can't record a successor. Its proofs no longer verify for the account, so it can't link an inbox at the new epoch. An inbox it linked before is at the old epoch, so it can't be named, and a successor it recorded before the change stops being answered once the epoch moves. +- **What it doesn't cover.** The guarantee holds only after the change. Before it, the old key is the account's key: a thief holding it can record a successor and send reconnects that check out, and buddies who accept one then are already paired with the thief's inbox. Nothing here recovers them. And the old inbox must be linked to the account before the change: the app links it first and sends no reconnect invites if it can't. An unlinked old inbox could be linked by another account holding the old inbox's keys (they come from the old key), which could then name its own inbox as the successor. + ### Live signal While the app is in the foreground it keeps a WebSocket open to its own inbox, so a buddy's claim, confirmation, invitation, reply, or request to join shows up within a second instead of at the next foreground or push. The socket only says _that_ something changed; the app then runs `inbox/sync` as usual. @@ -237,7 +248,7 @@ Content-Type: application/json Stored-event caps count a slot's events (by number and by base64url bytes) until they expire. An `event/put` past a cap fails with `rate_limited` and stores nothing; stored events are never dropped to make room, and room comes back as they expire. Cards and the roster are replaced in place, so only their blob sizes cap them. -Every op is also rate-limited per caller (the session's account for a bearer request, else the client IP, IPv6 by /64), never per target inbox. Unsigned invite ops, `inbox/register` (also capped per caller per day), reads (`inbox/sync` and `inbox/live`), `blob/put`, `blob/get`, and every other signed op each have their own budget, sized far above real use. A refused request gets 429 `rate_limited`, usually with `Retry-After` in seconds. Clients treat it like any transient failure and retry later, never in a tight loop. +Every op is also rate-limited per caller (the session's account for a bearer request, else the client IP, IPv6 by /64), never per target inbox. Unsigned invite ops, `inbox/register` (also capped per caller per day), reads (`inbox/sync`, `inbox/live`, and `inbox/successor`), `blob/put`, `blob/get`, and every other signed op each have their own budget, sized far above real use. A refused request gets 429 `rate_limited`, usually with `Retry-After` in seconds. Clients treat it like any transient failure and retry later, never in a tight loop. | Item | Limit | | ------------------------------------------ | -------------------------------------------------------------------------------------- | @@ -255,6 +266,7 @@ Every op is also rate-limited per caller (the session's account for a bearer req | Invites | Deleted at `expiresAt` (plus a short grace) | | Inbox | Wiped after 180 days without an owner op | | Owner key hash of a wiped inbox | Kept, with no expiry (see below) | +| Inbox successor | Kept 90 days after it was recorded (see [Inbox successor](#inbox-successor)) | Unsigned ops are rate-limited per caller (account or client IP, as above). diff --git a/migrations/accounts/0008_inbox-successor.sql b/migrations/accounts/0008_inbox-successor.sql new file mode 100644 index 0000000..c7ea6e8 --- /dev/null +++ b/migrations/accounts/0008_inbox-successor.sql @@ -0,0 +1,23 @@ +CREATE TABLE `inbox_successors` ( + `inbox_id` text PRIMARY KEY NOT NULL, + `successor_id` text NOT NULL, + `account_id` text NOT NULL, + `recorded_at` integer NOT NULL, + FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `inbox_successors_account_idx` ON `inbox_successors` (`account_id`);--> statement-breakpoint +CREATE INDEX `inbox_successors_recorded_idx` ON `inbox_successors` (`recorded_at`);--> statement-breakpoint +ALTER TABLE `account_inboxes` ADD `key_epoch` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +-- Links from before this column: the epoch whose key was the account's when +-- the link was made, one past the last key retired before it (0 for every +-- account that never changed its key). Never the current epoch, which would +-- let a link the old key made name itself a successor. A link made within a +-- second after a key change counts as the old key's: its proof may have been +-- checked against the old key just before the change landed. The device that +-- made it can register it again with the new key's proof to move it up. +UPDATE `account_inboxes` SET `key_epoch` = COALESCE(( + SELECT MAX(`r`.`key_epoch`) + 1 FROM `retired_account_keys` AS `r` + WHERE `r`.`account_id` = `account_inboxes`.`account_id` + AND `r`.`retired_at` <= `account_inboxes`.`created_at` - 1000 +), 0); diff --git a/migrations/accounts/meta/0008_snapshot.json b/migrations/accounts/meta/0008_snapshot.json new file mode 100644 index 0000000..9185d9a --- /dev/null +++ b/migrations/accounts/meta/0008_snapshot.json @@ -0,0 +1,1178 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6c60b203-d2fc-4c8e-ae8d-b9bb7447f090", + "prevId": "608d6769-514c-43bd-a145-74120e07e97a", + "tables": { + "account_activity": { + "name": "account_activity", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_platform": { + "name": "session_platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_device": { + "name": "session_device", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_session_id": { + "name": "actor_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_platform": { + "name": "actor_platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_device": { + "name": "actor_device", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slot_id": { + "name": "slot_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_activity_account_idx": { + "name": "account_activity_account_idx", + "columns": [ + "account_id", + "id" + ], + "isUnique": false + }, + "account_activity_created_idx": { + "name": "account_activity_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_activity_account_id_accounts_id_fk": { + "name": "account_activity_account_id_accounts_id_fk", + "tableFrom": "account_activity", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_inboxes": { + "name": "account_inboxes", + "columns": { + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_epoch": { + "name": "key_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "account_inboxes_account_idx": { + "name": "account_inboxes_account_idx", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_inboxes_account_id_accounts_id_fk": { + "name": "account_inboxes_account_id_accounts_id_fk", + "tableFrom": "account_inboxes", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_key_history": { + "name": "account_key_history", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key_epoch": { + "name": "key_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob": { + "name": "blob", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_key_history_account_id_accounts_id_fk": { + "name": "account_key_history_account_id_accounts_id_fk", + "tableFrom": "account_key_history", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_revenuecat_customers": { + "name": "account_revenuecat_customers", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_user_id": { + "name": "app_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_linked_at": { + "name": "first_linked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "0" + } + }, + "indexes": { + "account_revenuecat_customers_customer_idx": { + "name": "account_revenuecat_customers_customer_idx", + "columns": [ + "app_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_revenuecat_customers_account_id_accounts_id_fk": { + "name": "account_revenuecat_customers_account_id_accounts_id_fk", + "tableFrom": "account_revenuecat_customers", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "account_revenuecat_customers_app_user_id_revenuecat_customers_app_user_id_fk": { + "name": "account_revenuecat_customers_app_user_id_revenuecat_customers_app_user_id_fk", + "tableFrom": "account_revenuecat_customers", + "tableTo": "revenuecat_customers", + "columnsFrom": [ + "app_user_id" + ], + "columnsTo": [ + "app_user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_revenuecat_customers_account_id_app_user_id_pk": { + "columns": [ + "account_id", + "app_user_id" + ], + "name": "account_revenuecat_customers_account_id_app_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_sessions": { + "name": "account_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_hash": { + "name": "refresh_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_refresh_hash": { + "name": "previous_refresh_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_epoch": { + "name": "key_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "step_up_at": { + "name": "step_up_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "step_up_credential_id": { + "name": "step_up_credential_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_reason": { + "name": "ended_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_sessions_refresh_hash_unique": { + "name": "account_sessions_refresh_hash_unique", + "columns": [ + "refresh_hash" + ], + "isUnique": true + }, + "account_sessions_account_idx": { + "name": "account_sessions_account_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "account_sessions_previous_refresh_idx": { + "name": "account_sessions_previous_refresh_idx", + "columns": [ + "previous_refresh_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_sessions_account_id_accounts_id_fk": { + "name": "account_sessions_account_id_accounts_id_fk", + "tableFrom": "account_sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account_status_events": { + "name": "account_status_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_status_events_account_idx": { + "name": "account_status_events_account_idx", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_status_events_account_id_accounts_id_fk": { + "name": "account_status_events_account_id_accounts_id_fk", + "tableFrom": "account_status_events", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_pub": { + "name": "account_pub", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passkey_user_handle": { + "name": "passkey_user_handle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_epoch": { + "name": "key_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_via": { + "name": "created_via", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'app'" + }, + "key_changed_at": { + "name": "key_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "accounts_account_pub_unique": { + "name": "accounts_account_pub_unique", + "columns": [ + "account_pub" + ], + "isUnique": true + }, + "accounts_passkey_user_handle_unique": { + "name": "accounts_passkey_user_handle_unique", + "columns": [ + "passkey_user_handle" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inbox_successors": { + "name": "inbox_successors", + "columns": { + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "successor_id": { + "name": "successor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "inbox_successors_account_idx": { + "name": "inbox_successors_account_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "inbox_successors_recorded_idx": { + "name": "inbox_successors_recorded_idx", + "columns": [ + "recorded_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inbox_successors_account_id_accounts_id_fk": { + "name": "inbox_successors_account_id_accounts_id_fk", + "tableFrom": "inbox_successors", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey_wraps": { + "name": "passkey_wraps", + "columns": { + "wrap_id": { + "name": "wrap_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_epoch": { + "name": "key_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wrap": { + "name": "wrap", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "passkey_wraps_credential_idx": { + "name": "passkey_wraps_credential_idx", + "columns": [ + "credential_id", + "created_at" + ], + "isUnique": false + }, + "passkey_wraps_account_idx": { + "name": "passkey_wraps_account_idx", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_wraps_credential_id_passkeys_credential_id_fk": { + "name": "passkey_wraps_credential_id_passkeys_credential_id_fk", + "tableFrom": "passkey_wraps", + "tableTo": "passkeys", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "credential_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "passkey_wraps_account_id_accounts_id_fk": { + "name": "passkey_wraps_account_id_accounts_id_fk", + "tableFrom": "passkey_wraps", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "passkey_wraps_credential_id_wrap_id_pk": { + "columns": [ + "credential_id", + "wrap_id" + ], + "name": "passkey_wraps_credential_id_wrap_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkeys": { + "name": "passkeys", + "columns": { + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rp_id": { + "name": "rp_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_platform": { + "name": "created_platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkeys_account_idx": { + "name": "passkeys_account_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkeys_account_id_accounts_id_fk": { + "name": "passkeys_account_id_accounts_id_fk", + "tableFrom": "passkeys", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "retired_account_keys": { + "name": "retired_account_keys", + "columns": { + "account_pub": { + "name": "account_pub", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_epoch": { + "name": "key_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retired_at": { + "name": "retired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "retired_account_keys_account_idx": { + "name": "retired_account_keys_account_idx", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "revenuecat_customer_locks": { + "name": "revenuecat_customer_locks", + "columns": { + "app_user_id": { + "name": "app_user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "revenuecat_customer_locks_until_idx": { + "name": "revenuecat_customer_locks_until_idx", + "columns": [ + "locked_until" + ], + "isUnique": false + } + }, + "foreignKeys": { + "revenuecat_customer_locks_account_id_accounts_id_fk": { + "name": "revenuecat_customer_locks_account_id_accounts_id_fk", + "tableFrom": "revenuecat_customer_locks", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "revenuecat_customers": { + "name": "revenuecat_customers", + "columns": { + "app_user_id": { + "name": "app_user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "supporter": { + "name": "supporter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supporter_expires_at": { + "name": "supporter_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_app_user_id": { + "name": "original_app_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "revenuecat_webhook_events": { + "name": "revenuecat_webhook_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "revenuecat_webhook_events_received_idx": { + "name": "revenuecat_webhook_events_received_idx", + "columns": [ + "received_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "used_token_ids": { + "name": "used_token_ids", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "used_token_ids_expires_idx": { + "name": "used_token_ids_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/accounts/meta/_journal.json b/migrations/accounts/meta/_journal.json index bb26cdf..78a3a81 100644 --- a/migrations/accounts/meta/_journal.json +++ b/migrations/accounts/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1793389053484, "tag": "0007_rekey-supporter-lock", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1793403814357, + "tag": "0008_inbox-successor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/accounts/inboxSuccessors.ts b/src/accounts/inboxSuccessors.ts new file mode 100644 index 0000000..f6fdac8 --- /dev/null +++ b/src/accounts/inboxSuccessors.ts @@ -0,0 +1,189 @@ +import { and, eq, gt, lte, sql, type SQLWrapper } from 'drizzle-orm' +import { accountInboxes, accounts, inboxSuccessors } from './schema' +import { changes, type AccountsDb } from './store' + +/** + * A Buddies inbox's successor (witness-work ADR 0039 §5): the inbox that + * replaced it after an account key change. The device that changed the key + * names it when it deletes the old inbox (`inbox/delete` with `successor`), + * and a buddy reads it (`inbox/successor`) to check a `pair.reconnect` + * written with the old key's pairing keys, which anyone holding the old key + * could forge. Only a link the current key made can be a successor, and the + * old key opens no session after the change, so it can't name its own inbox. + * + * Privacy: inbox and account ids only; nothing here logs them. + */ + +const DAY_MS = 24 * 60 * 60 * 1_000 +/** How long a successor is kept (and answered) after it was recorded. */ +export const INBOX_SUCCESSOR_RETENTION_MS = 90 * DAY_MS + +/** `inboxId`'s link to `accountId` is at the account's current key epoch. */ +const isCurrentLink = ( + inboxId: string | SQLWrapper, + accountId: string | SQLWrapper +) => + sql`EXISTS (SELECT 1 FROM ${accountInboxes} + JOIN ${accounts} ON ${accounts.id} = ${accountInboxes.accountId} + WHERE ${accountInboxes.inboxId} = ${inboxId} + AND ${accountInboxes.accountId} = ${accountId} + AND ${accountInboxes.keyEpoch} = ${accounts.keyEpoch})` + +/** + * Records `successorId` for `inboxId` only while (in the same statement, so a + * key change can't slip in between) `inboxId` is linked to the account (or, + * once deleted, already has a successor of the account's recorded), + * `successorId` is another inbox linked to it at its current key epoch with + * no successor of its own (so no cycles), and the caller's session is live + * at that epoch (a key change ends every other session, so a token that + * outlives one records nothing). The caller checks that `successorId` is + * registered. + * + * The first record wins while it still qualifies: naming the same successor + * again refreshes it, and naming another one records nothing. A record that + * no longer qualifies (its successor's link is behind the account's key + * epoch, as one the old key made before a key change is) is replaced. + * Whether it was recorded. + */ +export const recordInboxSuccessor = async ( + db: AccountsDb, + record: { + inboxId: string + successorId: string + accountId: string + sessionId: string + now: number + } +): Promise => { + const { inboxId, successorId, accountId, sessionId, now } = record + // Columns in the table's order: inbox_id, successor_id, account_id, + // recorded_at. + const result = await db.insert(inboxSuccessors).select( + sql`SELECT ${inboxId}, ${successorId}, ${accountId}, ${now} + WHERE ${inboxId} != ${successorId} + AND (EXISTS (SELECT 1 FROM ${accountInboxes} + WHERE ${accountInboxes.inboxId} = ${inboxId} + AND ${accountInboxes.accountId} = ${accountId}) + OR EXISTS (SELECT 1 FROM inbox_successors AS r + WHERE r.inbox_id = ${inboxId} AND r.account_id = ${accountId})) + AND ${isCurrentLink(successorId, accountId)} + AND NOT EXISTS (SELECT 1 FROM inbox_successors AS r + WHERE r.inbox_id = ${successorId}) + AND EXISTS (SELECT 1 FROM account_sessions AS s + JOIN ${accounts} ON ${accounts.id} = s.account_id + WHERE s.id = ${sessionId} AND s.account_id = ${accountId} + AND s.revoked_at IS NULL AND s.expires_at > ${now} + AND s.key_epoch = ${accounts.keyEpoch}) + ON CONFLICT (inbox_id) DO UPDATE + SET successor_id = excluded.successor_id, + recorded_at = excluded.recorded_at + WHERE inbox_successors.account_id = excluded.account_id + AND (inbox_successors.successor_id = excluded.successor_id + OR NOT ${isCurrentLink(inboxSuccessors.successorId, inboxSuccessors.accountId)})` + ) + return changes(result) > 0 +} + +/** + * The account that recorded a successor for `inboxId`, whether or not it + * still qualifies, or null. A replaced inbox is never registered again, and + * a repeat of its deletion finds its account here once its link is gone. + */ +export const findSuccessorAccount = async ( + db: AccountsDb, + inboxId: string +): Promise => + ( + await db + .select({ accountId: inboxSuccessors.accountId }) + .from(inboxSuccessors) + .where(eq(inboxSuccessors.inboxId, inboxId)) + .get() + )?.accountId ?? null + +/** + * Drops a replaced inbox's link once the inbox is gone: it can't be + * registered again, so nothing may act for it through the link. + */ +export const unlinkReplacedInbox = async ( + db: AccountsDb, + inboxId: string, + accountId: string +): Promise => { + await db + .delete(accountInboxes) + .where( + and( + eq(accountInboxes.inboxId, inboxId), + eq(accountInboxes.accountId, accountId) + ) + ) +} + +/** + * Drops the record `recordInboxSuccessor` just wrote when the inbox refused + * the deletion, so a successor is only ever named for a deleted inbox. + */ +export const forgetInboxSuccessor = async ( + db: AccountsDb, + inboxId: string, + successorId: string, + recordedAt: number +): Promise => { + await db + .delete(inboxSuccessors) + .where( + and( + eq(inboxSuccessors.inboxId, inboxId), + eq(inboxSuccessors.successorId, successorId), + eq(inboxSuccessors.recordedAt, new Date(recordedAt)) + ) + ) +} + +/** + * The inbox that replaced `inboxId`, or null. Checked at every read: a + * successor whose link is no longer at its account's current key epoch isn't + * named (a key change leaves any successor the old key recorded unanswered, + * before the device that changed the key names its own), nor is a suspended + * account's. + */ +export const findInboxSuccessor = async ( + db: AccountsDb, + inboxId: string, + now: number = Date.now() +): Promise => + ( + await db + .select({ successorId: inboxSuccessors.successorId }) + .from(inboxSuccessors) + .where( + and( + eq(inboxSuccessors.inboxId, inboxId), + gt( + inboxSuccessors.recordedAt, + new Date(now - INBOX_SUCCESSOR_RETENTION_MS) + ), + isCurrentLink(inboxSuccessors.successorId, inboxSuccessors.accountId), + sql`EXISTS (SELECT 1 FROM ${accounts} + WHERE ${accounts.id} = ${inboxSuccessors.accountId} + AND ${accounts.status} = 'active')` + ) + ) + .get() + )?.successorId ?? null + +/** Deletes successors recorded 90 days ago or longer. */ +export const pruneInboxSuccessors = async ( + db: AccountsDb, + now: number = Date.now() +): Promise => { + await db + .delete(inboxSuccessors) + .where( + lte( + inboxSuccessors.recordedAt, + new Date(now - INBOX_SUCCESSOR_RETENTION_MS) + ) + ) +} diff --git a/src/accounts/schema.ts b/src/accounts/schema.ts index fe2eabc..89c9f2e 100644 --- a/src/accounts/schema.ts +++ b/src/accounts/schema.ts @@ -266,10 +266,39 @@ export const accountInboxes = sqliteTable( .notNull() .references(() => accounts.id, { onDelete: 'cascade' }), createdAt: createdAt(), + /** + * The account's key epoch (ADR 0039) whose key signed the link's proof, + * when it was made or last confirmed by a registration whose proof the + * current key signed. Only a link at the account's current epoch can be + * an inbox's successor. + */ + keyEpoch: integer('key_epoch').notNull().default(0), }, (t) => [index('account_inboxes_account_idx').on(t.accountId)] ) +/** + * The inbox that replaced a deleted one after a key change (ADR 0039 §5), + * recorded by `inbox/delete` with `successor`, so a buddy can check a + * reconnect invite the old key's pairing carried. Kept 90 days. + */ +export const inboxSuccessors = sqliteTable( + 'inbox_successors', + { + /** The deleted inbox. */ + inboxId: text('inbox_id').primaryKey(), + successorId: text('successor_id').notNull(), + accountId: text('account_id') + .notNull() + .references(() => accounts.id, { onDelete: 'cascade' }), + recordedAt: integer('recorded_at', { mode: 'timestamp_ms' }).notNull(), + }, + (t) => [ + index('inbox_successors_account_idx').on(t.accountId), + index('inbox_successors_recorded_idx').on(t.recordedAt), + ] +) + /** * The RevenueCat customers accounts link (witness-work ADR 0029), with what * RevenueCat's REST API last said about each. Never written from a client's diff --git a/src/accounts/store.ts b/src/accounts/store.ts index 1534f90..5d02c6f 100644 --- a/src/accounts/store.ts +++ b/src/accounts/store.ts @@ -1,4 +1,4 @@ -import { asc, eq, sql } from 'drizzle-orm' +import { and, asc, eq, lt, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/d1' import { bytesToBase64Url } from '../crypto' import { @@ -183,11 +183,16 @@ export const findInboxAccount = async ( * A new link goes in the account's activity (`buddies.inbox_linked`, naming * `sessionId` when a bearer session linked it) in the same batch; the batch * is atomic, so an account that loses a race logs nothing. + * + * `keyEpoch` is the epoch of the key that signed the link's proof, as read + * with that key, never re-read here: a key change landing in between leaves + * the link at the old epoch, so it can't be a successor (ADR 0039 §5). */ export const linkInbox = async ( db: AccountsDb, inboxId: string, accountId: string, + keyEpoch: number, sessionId: string | null = null ): Promise => { await db.batch([ @@ -201,7 +206,7 @@ export const linkInbox = async ( ), db .insert(accountInboxes) - .values({ inboxId, accountId }) + .values({ inboxId, accountId, keyEpoch }) .onConflictDoNothing({ target: accountInboxes.inboxId }), ]) const owner = await findInboxAccount(db, inboxId) @@ -209,6 +214,28 @@ export const linkInbox = async ( return owner } +/** + * Moves an existing link up to `keyEpoch`, the epoch of the key that signed a + * new proof for it (ADR 0039 §5); never down, and never another account's. + */ +export const confirmInboxLink = async ( + db: AccountsDb, + inboxId: string, + accountId: string, + keyEpoch: number +): Promise => { + await db + .update(accountInboxes) + .set({ keyEpoch }) + .where( + and( + eq(accountInboxes.inboxId, inboxId), + eq(accountInboxes.accountId, accountId), + lt(accountInboxes.keyEpoch, keyEpoch) + ) + ) +} + export const accountInboxIds = async ( db: AccountsDb, accountId: string diff --git a/src/buddies/contracts.ts b/src/buddies/contracts.ts index dd89b8f..90f1378 100644 --- a/src/buddies/contracts.ts +++ b/src/buddies/contracts.ts @@ -90,6 +90,13 @@ export const BUDDIES_NOTIFICATION_SERVICE_CLIENT = 'notification-service' */ export const BUDDIES_BLOB_PUT_OP = 'blob/put' export const BUDDIES_BLOB_GET_OP = 'blob/get' +/** + * `POST inbox/successor`: which inbox replaced another after its account's + * key changed (witness-work ADR 0039 §5). A bearer read by any account, with + * its own route: it is neither signed nor an owner op, so not in + * `BUDDIES_OPS`. + */ +export const BUDDIES_SUCCESSOR_OP = 'inbox/successor' /** Ops that also need the photos switch (`buddies:photos`) on. */ export const BUDDIES_PHOTO_OPS: ReadonlySet = new Set([ BUDDIES_BLOB_PUT_OP, @@ -119,6 +126,7 @@ export type BuddiesOp = BuddiesSignedOp | BuddiesUnsignedOp export type BuddiesLiveOp = typeof BUDDIES_LIVE_OP export type BuddiesBlobPutOp = typeof BUDDIES_BLOB_PUT_OP export type BuddiesBlobGetOp = typeof BUDDIES_BLOB_GET_OP +export type BuddiesSuccessorOp = typeof BUDDIES_SUCCESSOR_OP /** * Every op a signature can name: the signed POST ops, the live socket, and * `blob/put`. @@ -127,12 +135,18 @@ export type BuddiesSigningOp = | BuddiesSignedOp | BuddiesLiveOp | BuddiesBlobPutOp -/** Every op with a route, each counted against a per-caller edge tier. */ +/** + * Every op with a route, each counted against a per-caller edge tier. Most + * are signed (owner, writer, self-signed); `invite/fetch`, `invite/claim`, + * `blob/get`, and `inbox/successor` aren't, so their payloads have no `ts` or + * `nonce` (`UNSIGNED_PAYLOAD_OPS`). + */ export type BuddiesRouteOp = | BuddiesOp | BuddiesLiveOp | BuddiesBlobPutOp | BuddiesBlobGetOp + | BuddiesSuccessorOp export const BUDDIES_OPS: readonly BuddiesOp[] = [ 'inbox/register', @@ -196,8 +210,10 @@ export const BUDDIES_ERROR_STATUS = { /** * A handoff channel another account opened, a `backup/delete` signed by * another account than the bearer token's, a bearer Buddies owner request - * for an inbox linked to another account, or a bearer `inbox/register` - * whose `account` proof names another account than the token's. + * for an inbox linked to another account, a bearer `inbox/register` + * whose `account` proof names another account than the token's, or an + * `inbox/delete` naming a `successor` without a bearer token of the + * account the inbox is linked to. */ forbidden: 403, /** @@ -576,6 +592,18 @@ export interface EventPutPayload extends SlotPayload { push: boolean } +export interface InboxDeletePayload extends InboxFields { + /** + * The inbox that replaces this one after a key change: bearer requests + * only (ADR 0039 §5). + */ + successor?: string +} + +export interface InboxSuccessorPayload { + inboxId: string +} + export interface InviteFetchPayload { inviteId: string } @@ -609,7 +637,7 @@ export interface InviteClaimPayload { export interface BuddiesPayloads { 'inbox/register': RegisterPayload 'inbox/sync': SyncPayload - 'inbox/delete': InboxFields + 'inbox/delete': InboxDeletePayload 'device/register': DeviceRegisterPayload 'device/unregister': DeviceUnregisterPayload 'slot/add': SlotAddPayload @@ -626,6 +654,7 @@ export interface BuddiesPayloads { 'blob/put': BlobPutPayload 'blob/get': BlobGetPayload 'blob/delete': BlobDeletePayload + 'inbox/successor': InboxSuccessorPayload } type Fields = Record @@ -749,7 +778,12 @@ export const BUDDIES_FIELD_PARSERS: FieldParsers = { ? { ...base, since: p.since } : null }, - 'inbox/delete': inboxFields, + 'inbox/delete': (p) => { + const base = inboxFields(p) + if (!base) return null + if (p.successor === undefined) return base + return isId(p.successor) ? { ...base, successor: p.successor } : null + }, 'device/register': (p) => { const base = inboxFields(p) const parsedTemplates = templates(p.templates) @@ -834,12 +868,14 @@ export const BUDDIES_FIELD_PARSERS: FieldParsers = { if (!blobIds.every(bytes32)) return null return { ...base, blobIds: [...new Set(blobIds)] } }, + 'inbox/successor': (p) => (isId(p.inboxId) ? { inboxId: p.inboxId } : null), } /** Ops whose payload is never signed, so it has no `ts` or `nonce`. */ const UNSIGNED_PAYLOAD_OPS: ReadonlySet = new Set([ ...BUDDIES_UNSIGNED_OPS, BUDDIES_BLOB_GET_OP, + BUDDIES_SUCCESSOR_OP, ]) /** diff --git a/src/buddies/inboxDO.ts b/src/buddies/inboxDO.ts index 98a5ff7..061dfd9 100644 --- a/src/buddies/inboxDO.ts +++ b/src/buddies/inboxDO.ts @@ -1166,6 +1166,15 @@ export class BuddyInbox extends DurableObject { }) } + /** + * Whether this inbox is registered and not deleted or wiped: an inbox's + * successor must be (witness-work ADR 0039 §5). Reads only; creates + * nothing. + */ + async isRegistered(inboxId: string): Promise { + return this.#meta()?.inboxId === inboxId + } + /** * The accounts database linked this inbox to `accountId`, after this inbox * accepted the owner-signed registration: from now on that account's diff --git a/src/buddies/limits.ts b/src/buddies/limits.ts index 2d399a4..fbd795e 100644 --- a/src/buddies/limits.ts +++ b/src/buddies/limits.ts @@ -32,7 +32,7 @@ export const BUDDIES_ABUSE_LIMITS = { unsigned: { binding: 'BUDDIES_RATE_LIMITER', perMinute: 120 }, /** Register: once per install, restore, or delete-all; a household restoring ~6 devices at once → 10×. */ register: { binding: 'BUDDIES_REGISTER_LIMITER', perMinute: 60 }, - /** Sync + live: a shown invite code polls every 3 s (≤ 40/min with refetches), ~60/min per busy household → 10×. */ + /** Sync + live (and successor reads, a handful per key change): a shown invite code polls every 3 s (≤ 40/min with refetches), ~60/min per busy household → 10×. */ read: { binding: 'BUDDIES_READ_LIMITER', perMinute: 600 }, /** Other signed ops: one edit of a shared recurring Plan fans out ~60 events, cards, and a roster → 10×. */ write: { binding: 'BUDDIES_WRITE_LIMITER', perMinute: 600 }, @@ -86,6 +86,7 @@ const TIER_BY_OP: Partial> = { 'inbox/register': 'register', 'inbox/sync': 'read', 'inbox/live': 'read', + 'inbox/successor': 'read', 'blob/put': 'blobPut', 'blob/get': 'blobGet', } diff --git a/src/buddies/openapi.ts b/src/buddies/openapi.ts index 390982c..6979065 100644 --- a/src/buddies/openapi.ts +++ b/src/buddies/openapi.ts @@ -426,7 +426,7 @@ export const buddiesOpDocs: Record = { unknown_account: '`account.accountId` doesn’t exist.', replay: true, conflict: - 'The inbox is registered (or was, before a wipe) with a different `ownerPub`, or is linked to a different account.', + 'The inbox is registered (or was, before a wipe) with a different `ownerPub`, is linked to a different account, or was replaced by a successor after a key change (never registered again).', rate_limited: `${edgeLimited('register')}, or the caller’s ${count(BUDDIES_ABUSE_LIMITS.registrationsPerDay)} registrations a day are spent (\`Retry-After\` until the oldest leaves the window).`, disabled: `${DEFAULT_REASONS.disabled} Also a bearer request on a worker without the accounts database.`, }), @@ -475,15 +475,29 @@ export const buddiesOpDocs: Record = { description: paragraphs( 'Wipes the inbox (slots, cards, events, devices, roster, photo blobs) and every open invite it created, closing its live sockets with 4001. Only a hash of the owner key is kept, so only that key can register the `inboxId` again.', SIGNED_BY.owner, + '**`successor`** (after an account key change, witness-work ADR 0039 §5): names the inbox that replaces this one, which buddies then read with `inbox/successor` to check a reconnect invite. Bearer only, from a session of the account the inbox is linked to (not suspended: `suspended`) that is still live at the account’s key epoch. It must be another registered inbox linked to the same account at its current key epoch (linked, or registered again, with a proof by the current key) with no successor of its own, else `conflict` and nothing is deleted. Once deleted, the inbox’s account link is dropped and it can never be registered again (`inbox/register` answers `conflict`). The first successor recorded stays while it still qualifies (another one is `conflict`); one that no longer does (recorded before a key change) is replaced. The successor is recorded first and kept 90 days, and dropped again if the inbox refuses the deletion or can’t be reached; repeating the request answers `ok` once the inbox is gone. Without it, nothing changes.', `${tierLine('write')} Works for a suspended account.`, KILL_SWITCH_EXEMPT ), - requestBody: ownerBody('inbox/delete', { inboxId }), + requestBody: ownerBody('inbox/delete', { + inboxId, + successor: buddiesId( + 'The inbox that replaces this one after a key change. Bearer only' + ).optional(), + }), responses: { 200: jsonResponse('Deleted.', okSchema), - ...refusals( - ownerRefusals('write', { killSwitch: false, suspendable: false }) - ), + ...refusals({ + ...ownerRefusals('write', { killSwitch: false, suspendable: false }), + unauthorized: `${DEFAULT_REASONS.unauthorized} With \`successor\`, also a token whose session ended (a key change ends every other session) or is behind the account’s key epoch.`, + forbidden: `${DEFAULT_REASONS.forbidden} Also \`successor\` in a legacy request.`, + suspended: + 'With `successor`: the inbox’s account is suspended. Deleting without one still works.', + conflict: + '`successor` is this inbox, isn’t registered (deleted or wiped), isn’t linked to the same account at its current key epoch, has a successor of its own, or differs from a successor already recorded that still qualifies. Nothing is deleted.', + disabled: + 'With `successor`, on a worker without the accounts database.', + }), }, }), @@ -994,6 +1008,39 @@ export const buddiesBlobPutDocs = describeOp({ }, }) +export const buddiesSuccessorDocs = describeRoute({ + tags: TAGS, + security: [{ bearerAuth: [] }], + summary: 'Read an inbox’s successor', + description: paragraphs( + 'The inbox that replaced `inboxId` after its account’s key changed (witness-work ADR 0039 §5), as `inbox/delete` with `successor` recorded it, or `null`. A buddy accepts a `pair.reconnect` from the old inbox only when this names the offered inbox. Checked at every read: `null` unless the recorded successor is still linked to the same account at the account’s current key epoch and the account isn’t suspended (so one recorded before a key change, by anyone holding the old key, is never vouched for after it), and for an inbox nobody replaced or one recorded over 90 days ago. Anyone with a token who knows the old `inboxId` learns that it was replaced, and by which inbox.', + '**Bearer only:** `Authorization: Bearer ` of any account, and `{ "inboxId" }` as plain JSON (an envelope is `bad_request`). No legacy form: without the header it’s `unauthorized`.', + tierLine('read'), + KILL_SWITCH_OFF + ), + requestBody: jsonBody( + z.object({ inboxId: buddiesId('The replaced (old) inbox') }), + 'Plain JSON.' + ), + responses: { + 200: jsonResponse( + 'The successor, or `null`.', + z.object({ + ok: z.literal(true), + successor: buddiesId('The inbox that replaced it') + .nullable() + .meta({ description: 'The inbox that replaced it, or `null`.' }), + }) + ), + ...refusals({ + bad_request: true, + unauthorized: `${DEFAULT_REASONS.unauthorized} Also a request without one.`, + rate_limited: `${edgeLimited('read')}.`, + disabled: `${DEFAULT_REASONS.disabled} Also a worker without the accounts database.`, + }), + }, +}) + export const buddiesBlobGetDocs = describeOp({ summary: 'Download a photo blob', description: paragraphs( diff --git a/src/buddies/route.ts b/src/buddies/route.ts index 7e7d0bf..885de32 100644 --- a/src/buddies/route.ts +++ b/src/buddies/route.ts @@ -18,6 +18,7 @@ import { BUDDIES_OWNER_OPS, BUDDIES_PAYLOAD_PARSERS, BUDDIES_PHOTO_OPS, + BUDDIES_SUCCESSOR_OP, BUDDIES_SUSPENDED_REFUSED_OPS, BUDDIES_UNSIGNED_OPS, buddyBlobKey, @@ -56,10 +57,19 @@ import { import { inboxLinkMessage } from '../accounts/contracts' import { accountsDb, + confirmInboxLink, findAccount, findInboxAccount, linkInbox, } from '../accounts/store' +import { liveSessionKeyEpoch } from '../accounts/sessionStore' +import { + findInboxSuccessor, + findSuccessorAccount, + forgetInboxSuccessor, + recordInboxSuccessor, + unlinkReplacedInbox, +} from '../accounts/inboxSuccessors' import { authenticateAccount, hasAuthorization, @@ -95,12 +105,14 @@ import { buddiesBlobPutDocs, buddiesLiveDocs, buddiesOpDocs, + buddiesSuccessorDocs, } from './openapi' /** * `POST /buddies/v1/{op}` — the Buddies relay (docs/buddies-protocol.md) — * `GET /buddies/v1/inbox/live`, the owner's live socket, and the photo blob - * routes `POST /buddies/v1/blob/put` (raw body) and `blob/get` (raw answer). + * routes `POST /buddies/v1/blob/put` (raw body) and `blob/get` (raw answer), + * and `POST /buddies/v1/inbox/successor`, a bearer read. * * Authentication (witness-work ADR 0027, "Authentication (sessions)" in the * protocol): a request with an `Authorization` header is a bearer request. @@ -267,22 +279,33 @@ type SignedRunners = { /** The accounts side of `inbox/register`; see `registerInbox`. */ type InboxAccount = - | { ok: true; linked: string | null; claim: string | null } + | { + ok: true + linked: string | null + claim: string | null + /** The key epoch of the key that verified the proof; null without one. */ + keyEpoch: number | null + } | BuddiesFailure /** * Checks `inbox/register` against the accounts database (witness-work ADR * 0024). `linked` is the account the inbox already belongs to; `claim` is the * account a valid proof names, when the inbox isn't linked yet. A suspended - * account's inbox is refused, with or without a proof. + * account's inbox is refused, with or without a proof, and so is an inbox + * with a successor recorded (`conflict`). `keyEpoch` is the + * account's epoch as read with the key that verified the proof (ADR 0039). */ const checkInboxAccount = async ( db: ReturnType, call: Signed ): Promise => { + // A replaced inbox (ADR 0039 §5) is never registered again. + if (await findSuccessorAccount(db, call.inboxId)) return fail('conflict') const linked = await findInboxAccount(db, call.inboxId) if (linked?.status === 'suspended') return fail('suspended') - if (!call.account) return { ok: true, linked: linked?.id ?? null, claim: null } + if (!call.account) + return { ok: true, linked: linked?.id ?? null, claim: null, keyEpoch: null } const account = await findAccount(db, call.account.accountId) if (!account) return fail('unknown_account') const valid = await verifyEd25519( @@ -293,9 +316,10 @@ const checkInboxAccount = async ( if (!valid) return fail('bad_signature') if (linked && linked.id !== account.id) return fail('conflict') if (account.status === 'suspended') return fail('suspended') + const { keyEpoch } = account return linked - ? { ok: true, linked: linked.id, claim: null } - : { ok: true, linked: null, claim: account.id } + ? { ok: true, linked: linked.id, claim: null, keyEpoch } + : { ok: true, linked: null, claim: account.id, keyEpoch } } /** @@ -328,6 +352,11 @@ const refuseBearerRegistration = ( * account's claim may have won: `conflict`). If either step fails, the inbox * keeps answering bearer owner requests `not_linked` until a registration * gets through; registering again is idempotent. + * + * The link records the key epoch of the key that signed its proof, and a + * later proof by a newer key moves an existing link up to that epoch: only a + * link at the account's current epoch can be an inbox's successor (ADR 0039 + * §5), so a link the old key made can't be one after a key change. */ const registerInbox = async ( env: Environment, @@ -336,7 +365,12 @@ const registerInbox = async ( ): Promise => { const db = env.ACCOUNTS_DB ? accountsDb(env.ACCOUNTS_DB) : null if (account && !db) return fail('disabled') - let check: InboxAccount = { ok: true, linked: null, claim: null } + let check: InboxAccount = { + ok: true, + linked: null, + claim: null, + keyEpoch: null, + } if (db) { try { check = await checkInboxAccount(db, call) @@ -350,13 +384,23 @@ const registerInbox = async ( const stub = inbox(env, call.inboxId) const result = await stub.register(call, check.linked) if (!result.ok) return result - if (db && check.claim) { + if (db && check.linked && check.keyEpoch != null) { + try { + await confirmInboxLink(db, call.inboxId, check.linked, check.keyEpoch) + } catch (error) { + if (account) throw error + console.warn('buddies: confirming an inbox link failed') + captureFailure(error, 'inbox/register') + } + } + if (db && check.claim && check.keyEpoch != null) { let owner try { owner = await linkInbox( db, call.inboxId, check.claim, + check.keyEpoch, account?.sessionId ?? null ) } catch (error) { @@ -550,6 +594,9 @@ const runSigned = async ( const sync = call.value as unknown as Signed return syncInbox(env, sync, true) } + // A successor is recorded for a bearer session of the inbox's account only. + if (op === 'inbox/delete' && 'successor' in call.value) + return fail('forbidden') if (op === 'inbox/register') { const register = call.value as unknown as Signed< BuddiesPayloads['inbox/register'] @@ -565,6 +612,77 @@ const runSigned = async ( return outcome } +/** + * A bearer `inbox/delete` naming `successor`, the inbox that replaces this one + * after a key change (witness-work ADR 0039 §5). The inbox must be the token's + * account's: linked to it, or, once deleted and unlinked, replaced by it + * (`forbidden`). The account must not be suspended (`suspended`; deleting + * without a successor still works), and the token's session must still be + * live at the account's key epoch (`unauthorized`: a key change ends every + * other session, though their access tokens verify for minutes more). + * + * The successor must be registered (not deleted or wiped), another inbox + * linked to the same account at its current key epoch, and not replaced + * itself (no cycles), else `conflict` and nothing is deleted; so is naming + * another successor than one already recorded that still qualifies (one + * that doesn't, from before a key change, is replaced). It's recorded before + * the inbox is deleted; if the inbox refuses, or the call to it throws, the + * record goes too. Once the inbox is gone its link is dropped: a replaced + * inbox is never registered again (`registerInbox`). A repeat answers `ok` + * once the inbox is gone (`not_found` from the inbox), refreshing the + * record; `buddies.deleted` is logged only for the deletion itself. + */ +const deleteWithSuccessor = async ( + env: Environment, + inboxId: string, + successorId: string, + account: AccountAuth +): Promise => { + if (!env.ACCOUNTS_DB) return fail('disabled') + const db = accountsDb(env.ACCOUNTS_DB) + const linked = await findInboxAccount(db, inboxId) + const ownerId = linked?.id ?? (await findSuccessorAccount(db, inboxId)) + if (ownerId !== account.accountId) return fail('forbidden') + const owner = linked ?? (await findAccount(db, ownerId)) + if (!owner) return fail('forbidden') + if (owner.status === 'suspended') return fail('suspended') + const sessionEpoch = await liveSessionKeyEpoch( + db, + account.accountId, + account.sessionId + ) + if (sessionEpoch !== owner.keyEpoch) return fail('unauthorized') + if (!(await inbox(env, successorId).isRegistered(successorId))) + return fail('conflict') + const now = Date.now() + const recorded = await recordInboxSuccessor(db, { + inboxId, + successorId, + accountId: account.accountId, + sessionId: account.sessionId, + now, + }) + if (!recorded) return fail('conflict') + let result: BuddiesResult + try { + result = await inbox(env, inboxId).deleteInbox({ + inboxId, + accountId: account.accountId, + }) + } catch (error) { + // A retry records it again, and answers `ok` if the inbox did go. + await forgetInboxSuccessor(db, inboxId, successorId, now) + throw error + } + if (!result.ok && result.error !== 'not_found') { + await forgetInboxSuccessor(db, inboxId, successorId, now) + return result + } + await unlinkReplacedInbox(db, inboxId, account.accountId) + if (result.ok) await recordBuddiesDeleted(env, inboxId, account) + return ok({ body: {} }) +} + /** * A bearer owner op: the payload's fields as plain JSON, without `ts` and * `nonce` (ignored if sent), on behalf of the token's account. An envelope @@ -580,6 +698,12 @@ const runByAccount = async ( if (!isPlainObject(body) || isEnvelopeBody(body)) return fail('bad_request') const fields = BUDDIES_FIELD_PARSERS[op](body, Date.now()) if (!fields) return fail('bad_request') + if ( + op === 'inbox/delete' && + 'successor' in fields && + typeof fields.successor === 'string' + ) + return deleteWithSuccessor(env, fields.inboxId, fields.successor, account) const outcome = await OWNER_OPS[op](env, { ...fields, accountId: account.accountId, @@ -874,6 +998,39 @@ const handleBlobGet = async (c: AppContext): Promise => { } } +/** + * `POST inbox/successor`: `{ inboxId }` as plain JSON → `{ ok, successor }`, + * the inbox that replaced it after a key change, or null (witness-work ADR + * 0039 §5). Any account's bearer token reads it: a buddy checks a + * `pair.reconnect` with it. It tells anyone who knows the old inbox id that + * it was replaced, and by which inbox. No legacy form (`unauthorized` + * without a token). Counts as a read. + */ +const handleSuccessor = async (c: AppContext): Promise => { + try { + if (!(await isBuddiesEnabled(c.env))) return errorResponse('disabled') + if (!hasAuthorization(c)) return errorResponse('unauthorized') + const admission = await admitCaller(c, BUDDIES_SUCCESSOR_OP) + if (!admission.ok) return admission.response + if (!c.env.ACCOUNTS_DB) return errorResponse('disabled') + const text = await readEnvelopeText(c.req.raw) + const body = text == null ? null : parseJsonOrNull(text) + if (!isPlainObject(body) || isEnvelopeBody(body)) + return errorResponse('bad_request') + const fields = BUDDIES_FIELD_PARSERS[BUDDIES_SUCCESSOR_OP](body, Date.now()) + if (!fields) return errorResponse('bad_request') + const successor = await findInboxSuccessor( + accountsDb(c.env.ACCOUNTS_DB), + fields.inboxId + ) + return c.json({ ok: true, successor }) + } catch (error) { + console.error('buddies: request failed', { op: BUDDIES_SUCCESSOR_OP }) + captureFailure(error, BUDDIES_SUCCESSOR_OP) + return buddiesInternalError() + } +} + /** Mounted at `/buddies/v1` by the Worker entry point. */ export const createBuddiesRoutes = ( deps: BuddiesRouteDependencies = { push: defaultApnsDependencies } @@ -887,5 +1044,6 @@ export const createBuddiesRoutes = ( routes.get(`/${BUDDIES_LIVE_OP}`, buddiesLiveDocs, handleLive) routes.post(`/${BUDDIES_BLOB_PUT_OP}`, buddiesBlobPutDocs, handleBlobPut) routes.post(`/${BUDDIES_BLOB_GET_OP}`, buddiesBlobGetDocs, handleBlobGet) + routes.post(`/${BUDDIES_SUCCESSOR_OP}`, buddiesSuccessorDocs, handleSuccessor) return routes } diff --git a/src/buddies/successor.test.ts b/src/buddies/successor.test.ts new file mode 100644 index 0000000..a764fd8 --- /dev/null +++ b/src/buddies/successor.test.ts @@ -0,0 +1,772 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SigningKey, + b64u, + createHarness, + envelope, + randomBytes, + randomId, + type ApiResponse, + type Harness, +} from '../test/buddies' +import { + bearer, + openTestSession, + requestJson, + type TestSession, +} from '../test/accounts' +import { + accountSignedMessage, + inboxLinkMessage, + rekeyNewKeyMessage, +} from '../accounts/contracts' +import { createAccountsRoutes } from '../accounts/route' +import { resetAccountStatusCache } from '../accounts/auth' +import { accountsDb, setAccountStatus } from '../accounts/store' +import { BuddyInbox } from './inboxDO' +import { pruneInboxSuccessors } from '../accounts/inboxSuccessors' + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + ctx: DurableObjectState + env: unknown + constructor(ctx: DurableObjectState, env: unknown) { + this.ctx = ctx + this.env = env + } + }, +})) + +/** + * A Buddies inbox's successor (witness-work ADR 0039 §5): after an account + * key change, `inbox/delete` with `successor` records the new inbox for the + * old one, and `inbox/successor` reads it, so a buddy can check a + * `pair.reconnect` written with the old key's pairing keys. + */ + +const START = Date.UTC(2026, 9, 30, 12) +const DAY = 24 * 60 * 60 * 1_000 +const encoder = new TextEncoder() +const accountsApp = createAccountsRoutes() as never + +let h: Harness + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(START) + resetAccountStatusCache() + h = await createHarness() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +/** Who holds an account: its current key and a session's token. */ +type Holder = Pick + +/** A Buddies inbox: its id and owner key. */ +interface Inbox { + inboxId: string + owner: SigningKey +} + +const sql = (query: string, ...params: (string | number | null)[]) => + h.accountsDb!.db.prepare(query).all(...params) as Record[] + +const phone = (key?: SigningKey) => openTestSession(accountsApp, h.env, key) + +const newInbox = async (): Promise => ({ + inboxId: randomId(), + owner: await SigningKey.generate(), +}) + +/** The `account` proof, signed by `signer` (the holder's key by default). */ +const proof = async (inbox: Inbox, holder: Holder, signer = holder.key) => ({ + accountId: holder.accountId, + signature: b64u( + await signer.sign( + inboxLinkMessage(holder.accountId, inbox.inboxId, inbox.owner.publicKey) + ) + ), +}) + +const registerPayload = async ( + inbox: Inbox, + holder: Holder, + signer?: SigningKey +) => + envelope( + 'inbox/register', + { + inboxId: inbox.inboxId, + ownerPub: inbox.owner.publicKey, + account: await proof(inbox, holder, signer), + ts: Date.now(), + nonce: randomId(), + }, + inbox.owner + ) + +/** A bearer `inbox/register` linking `inbox` to the holder's account. */ +const link = async (inbox: Inbox, holder: Holder) => + h.post( + '/inbox/register', + await registerPayload(inbox, holder), + bearer(holder.accessToken) + ) + +const linked = async (holder: Holder): Promise => { + const inbox = await newInbox() + const response = await link(inbox, holder) + expect(response.status).toBe(200) + expect(response.body).toEqual({ ok: true, accountId: holder.accountId }) + return inbox +} + +const remove = ( + inbox: Inbox, + holder: Pick, + successor?: string, + fields: Record = {} +) => + h.post( + '/inbox/delete', + { inboxId: inbox.inboxId, ...(successor ? { successor } : {}), ...fields }, + bearer(holder.accessToken) + ) + +const successorOf = ( + inboxId: string, + headers: Record +): Promise => h.post('/inbox/successor', { inboxId }, headers) + +/** Whether the inbox still answers its owner (it wasn't deleted). */ +const alive = async (inbox: Inbox, holder: Pick) => + ( + await h.post( + '/inbox/sync', + { inboxId: inbox.inboxId, since: 0 }, + bearer(holder.accessToken) + ) + ).status === 200 + +const linkEpoch = (inboxId: string) => + sql('SELECT key_epoch FROM account_inboxes WHERE inbox_id = ?', inboxId)[0] + ?.key_epoch + +/** Changes the holder's account key on its session; the holder under K2. */ +const rekey = async (holder: Holder): Promise => { + const newKey = await SigningKey.generate() + const [{ key_epoch: keyEpoch }] = sql( + 'SELECT key_epoch FROM accounts WHERE id = ?', + holder.accountId + ) + const bytes = encoder.encode( + JSON.stringify({ + accountPub: holder.key.publicKey, + ts: Date.now(), + nonce: randomId(), + newAccountPub: newKey.publicKey, + keyEpoch, + history: b64u(randomBytes(300)), + }) + ) + const response = await requestJson( + accountsApp, + h.env, + 'POST', + '/account/rekey', + { + payload: b64u(bytes), + signature: b64u( + await holder.key.sign(accountSignedMessage('account/rekey', bytes)) + ), + newSignature: b64u(await newKey.sign(rekeyNewKeyMessage(bytes))), + }, + bearer(holder.accessToken) + ) + expect(response.status).toBe(200) + return { ...holder, key: newKey, accessToken: response.body.accessToken } +} + +describe('linking records the key epoch', () => { + it('records the account’s epoch when an inbox is linked', async () => { + const a = await phone() + const x = await linked(a) + expect(linkEpoch(x.inboxId)).toBe(0) + const a2 = await rekey(a) + const n = await linked(a2) + expect(linkEpoch(n.inboxId)).toBe(1) + expect(linkEpoch(x.inboxId)).toBe(0) + }) + + it('moves a link to the current epoch when the current key confirms it', async () => { + const a = await phone() + const x = await linked(a) + const a2 = await rekey(a) + // Registering again with a proof from K2 confirms the link at epoch 1. + expect((await link(x, a2)).status).toBe(200) + expect(linkEpoch(x.inboxId)).toBe(1) + }) + + it('never moves a link back', async () => { + const a = await phone() + const a2 = await rekey(a) + const n = await linked(a2) + expect((await link(n, a2)).status).toBe(200) + expect(linkEpoch(n.inboxId)).toBe(1) + }) +}) + +describe('the migration', () => { + it('backfills each link with the epoch its account had when it was linked', () => { + const dir = new URL('../../migrations/accounts/', import.meta.url) + const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort() + const last = files.indexOf('0008_inbox-successor.sql') + expect(last).toBeGreaterThan(0) + const db = new DatabaseSync(':memory:') + for (const file of files.slice(0, last)) + db.exec(readFileSync(new URL(file, dir), 'utf8')) + const at = (day: number) => START + day * DAY + // `never` never changed its key; `twice` changed it on days 10 and 20. + db.exec(`INSERT INTO accounts (id, account_pub, key_epoch, created_at) + VALUES ('never', 'p0', 0, ${at(0)}), ('twice', 'p2', 2, ${at(0)})`) + db.exec(`INSERT INTO retired_account_keys (account_pub, account_id, key_epoch, retired_at) + VALUES ('p0-old', 'twice', 0, ${at(10)}), ('p1-old', 'twice', 1, ${at(20)})`) + db.exec(`INSERT INTO account_inboxes (inbox_id, account_id, created_at) VALUES + ('n', 'never', ${at(30)}), + ('t0', 'twice', ${at(5)}), + ('t-at-change', 'twice', ${at(10)}), + ('t-just-after', 'twice', ${at(10) + 999}), + ('t-a-second-after', 'twice', ${at(10) + 1_000}), + ('t1', 'twice', ${at(15)}), + ('t2', 'twice', ${at(25)})`) + db.exec(readFileSync(new URL(files[last], dir), 'utf8')) + expect( + db.prepare('SELECT inbox_id, key_epoch FROM account_inboxes ORDER BY inbox_id').all() + ).toEqual([ + { inbox_id: 'n', key_epoch: 0 }, + { inbox_id: 't-a-second-after', key_epoch: 1 }, + { inbox_id: 't-at-change', key_epoch: 0 }, + { inbox_id: 't-just-after', key_epoch: 0 }, + { inbox_id: 't0', key_epoch: 0 }, + { inbox_id: 't1', key_epoch: 1 }, + { inbox_id: 't2', key_epoch: 2 }, + ]) + }) +}) + +describe('inbox/delete with a successor', () => { + it('records a successor linked to the same account at its current epoch', async () => { + const a = await phone() + const x = await linked(a) + const n = await linked(a) + + const response = await remove(x, a, n.inboxId) + expect(response).toEqual({ status: 200, body: { ok: true } }) + expect(await alive(x, a)).toBe(false) + expect( + sql( + 'SELECT inbox_id, successor_id, account_id, recorded_at FROM inbox_successors' + ) + ).toEqual([ + { + inbox_id: x.inboxId, + successor_id: n.inboxId, + account_id: a.accountId, + recorded_at: START, + }, + ]) + expect(await successorOf(x.inboxId, bearer(a.accessToken))).toEqual({ + status: 200, + body: { ok: true, successor: n.inboxId }, + }) + }) + + it('records one after a key change, for the inbox linked under the new key', async () => { + const a = await phone() + const x = await linked(a) + const a2 = await rekey(a) + const n = await linked(a2) + expect((await remove(x, a2, n.inboxId)).status).toBe(200) + expect(await alive(x, a2)).toBe(false) + const b = await phone() + expect((await successorOf(x.inboxId, bearer(b.accessToken))).body).toEqual({ + ok: true, + successor: n.inboxId, + }) + }) + + it('refuses another account’s inbox as the successor, deleting nothing', async () => { + const a = await phone() + const b = await phone() + const x = await linked(a) + const other = await linked(b) + const response = await remove(x, a, other.inboxId) + expect(response.status).toBe(409) + expect(response.body).toMatchObject({ ok: false, code: 'conflict' }) + expect(await alive(x, a)).toBe(true) + expect(sql('SELECT * FROM inbox_successors')).toEqual([]) + }) + + it('refuses an unknown or unlinked successor', async () => { + const a = await phone() + const x = await linked(a) + const response = await remove(x, a, randomId()) + expect(response.status).toBe(409) + expect(await alive(x, a)).toBe(true) + }) + + it('refuses a successor linked before the key change', async () => { + const a = await phone() + const x = await linked(a) + const old = await linked(a) + const a2 = await rekey(a) + const response = await remove(x, a2, old.inboxId) + expect(response.status).toBe(409) + expect(response.body).toMatchObject({ code: 'conflict' }) + expect(await alive(x, a2)).toBe(true) + expect(sql('SELECT * FROM inbox_successors')).toEqual([]) + }) + + it('refuses the deleted inbox as its own successor', async () => { + const a = await phone() + const x = await linked(a) + const response = await remove(x, a, x.inboxId) + expect(response.status).toBe(409) + expect(await alive(x, a)).toBe(true) + }) + + it('refuses a request without a bearer token (forbidden)', async () => { + const a = await phone() + const x = await linked(a) + const n = await linked(a) + const response = await h.send('inbox/delete', x.owner, { + inboxId: x.inboxId, + successor: n.inboxId, + }) + expect(response.status).toBe(403) + expect(response.body).toMatchObject({ code: 'forbidden' }) + expect(await alive(x, a)).toBe(true) + expect(sql('SELECT * FROM inbox_successors')).toEqual([]) + }) + + it('refuses another account’s token for the deleted inbox (forbidden)', async () => { + const a = await phone() + const b = await phone() + const x = await linked(a) + const n = await linked(b) + const response = await remove(x, b, n.inboxId) + expect(response.status).toBe(403) + expect(response.body).toMatchObject({ code: 'forbidden' }) + expect(await alive(x, a)).toBe(true) + }) + + it('refuses a malformed successor', async () => { + const a = await phone() + const x = await linked(a) + const response = await remove(x, a, undefined, { successor: 42 }) + expect(response.status).toBe(400) + expect(await alive(x, a)).toBe(true) + }) + + it('is idempotent, logging the deletion once', async () => { + const a = await phone() + const x = await linked(a) + const n = await linked(a) + expect((await remove(x, a, n.inboxId)).status).toBe(200) + vi.setSystemTime(START + 1_000) + expect(await remove(x, a, n.inboxId)).toEqual({ + status: 200, + body: { ok: true }, + }) + expect(sql('SELECT successor_id FROM inbox_successors')).toEqual([ + { successor_id: n.inboxId }, + ]) + expect( + sql( + "SELECT COUNT(*) AS n FROM account_activity WHERE kind = 'buddies.deleted'" + ) + ).toEqual([{ n: 1 }]) + }) + + it('keeps the first successor while it qualifies, refusing another', async () => { + const a = await phone() + const x = await linked(a) + const n = await linked(a) + const other = await linked(a) + expect((await remove(x, a, n.inboxId)).status).toBe(200) + const second = await remove(x, a, other.inboxId) + expect(second.status).toBe(409) + expect(second.body).toMatchObject({ code: 'conflict' }) + expect((await successorOf(x.inboxId, bearer(a.accessToken))).body).toEqual({ + ok: true, + successor: n.inboxId, + }) + }) + + it('stops naming a successor recorded before a key change, and takes the one after it', async () => { + const a = await phone() + const x = await linked(a) + const n0 = await linked(a) + // Recorded at epoch 0: the read checks the link's epoch every time. + expect((await remove(x, a, n0.inboxId)).status).toBe(200) + expect((await successorOf(x.inboxId, bearer(a.accessToken))).body).toEqual({ + ok: true, + successor: n0.inboxId, + }) + const a2 = await rekey(a) + expect((await successorOf(x.inboxId, bearer(a2.accessToken))).body).toEqual({ + ok: true, + successor: null, + }) + // The epoch-0 record no longer qualifies, so the User's own replaces it. + const n1 = await linked(a2) + expect(await remove(x, a2, n1.inboxId)).toEqual({ + status: 200, + body: { ok: true }, + }) + expect((await successorOf(x.inboxId, bearer(a2.accessToken))).body).toEqual({ + ok: true, + successor: n1.inboxId, + }) + expect(sql('SELECT successor_id FROM inbox_successors')).toEqual([ + { successor_id: n1.inboxId }, + ]) + }) + + it('drops the record when the inbox refuses the deletion', async () => { + const a = await phone() + const n = await linked(a) + // X is linked in the accounts database, but its inbox never confirmed + // the link (registered before accounts): the inbox answers `not_linked`. + const x = await newInbox() + const legacy = await h.send('inbox/register', x.owner, { + inboxId: x.inboxId, + ownerPub: x.owner.publicKey, + }) + expect(legacy.status).toBe(200) + sql( + 'INSERT INTO account_inboxes (inbox_id, account_id, created_at, key_epoch) VALUES (?, ?, ?, 0)', + x.inboxId, + a.accountId, + START + ) + const response = await remove(x, a, n.inboxId) + expect(response.status).toBe(409) + expect(response.body).toMatchObject({ code: 'not_linked' }) + expect(sql('SELECT * FROM inbox_successors')).toEqual([]) + expect((await successorOf(x.inboxId, bearer(a.accessToken))).body).toEqual({ + ok: true, + successor: null, + }) + }) + + it('drops the record when the deletion throws, and a retry records it again', async () => { + const a = await phone() + const x = await linked(a) + const n = await linked(a) + const failing = vi + .spyOn(BuddyInbox.prototype, 'deleteInbox') + .mockRejectedValueOnce(new Error('inbox unreachable')) + const response = await remove(x, a, n.inboxId) + expect(response.status).toBe(500) + expect(sql('SELECT * FROM inbox_successors')).toEqual([]) + expect(await alive(x, a)).toBe(true) + failing.mockRestore() + + expect(await remove(x, a, n.inboxId)).toEqual({ + status: 200, + body: { ok: true }, + }) + expect((await successorOf(x.inboxId, bearer(a.accessToken))).body).toEqual({ + ok: true, + successor: n.inboxId, + }) + }) + + it('refuses a suspended account’s successor, and stops naming one', async () => { + const a = await phone() + const x = await linked(a) + const n = await linked(a) + const y = await linked(a) + expect((await remove(y, a, n.inboxId)).status).toBe(200) + const db = accountsDb(h.env.ACCOUNTS_DB!) + await setAccountStatus(db, a.accountId, 'suspended', 'report') + resetAccountStatusCache() + + const response = await remove(x, a, n.inboxId) + expect(response.status).toBe(403) + expect(response.body).toMatchObject({ code: 'suspended' }) + expect(await remove(x, a)).toEqual({ status: 200, body: { ok: true } }) + const b = await phone() + expect((await successorOf(y.inboxId, bearer(b.accessToken))).body).toEqual({ + ok: true, + successor: null, + }) + + await setAccountStatus(db, a.accountId, 'active', 'appeal') + expect((await successorOf(y.inboxId, bearer(b.accessToken))).body).toEqual({ + ok: true, + successor: n.inboxId, + }) + }) + + it('never lets a replaced inbox be registered again, and drops its link', async () => { + const a = await phone() + const x = await linked(a) + const n = await linked(a) + expect((await remove(x, a, n.inboxId)).status).toBe(200) + expect(linkEpoch(x.inboxId)).toBeUndefined() + + // Legacy, without a proof: the owner key alone. + const legacy = await h.send('inbox/register', x.owner, { + inboxId: x.inboxId, + ownerPub: x.owner.publicKey, + }) + expect(legacy.status).toBe(409) + expect(legacy.body).toMatchObject({ code: 'conflict' }) + // Legacy and bearer, with the current key's proof. + const proved = await h.post('/inbox/register', await registerPayload(x, a)) + expect(proved.status).toBe(409) + const bearerRegister = await link(x, a) + expect(bearerRegister.status).toBe(409) + expect(bearerRegister.body).toMatchObject({ code: 'conflict' }) + + expect(await alive(x, a)).toBe(false) + expect(linkEpoch(x.inboxId)).toBeUndefined() + // The record still answers, and a repeat is still `ok` for its account + // only. + expect((await successorOf(x.inboxId, bearer(a.accessToken))).body).toEqual({ + ok: true, + successor: n.inboxId, + }) + expect(await remove(x, a, n.inboxId)).toEqual({ + status: 200, + body: { ok: true }, + }) + const b = await phone() + const theirs = await linked(b) + expect((await remove(x, b, theirs.inboxId)).status).toBe(403) + }) + + it('refuses a successor that was deleted', async () => { + const a = await phone() + const x = await linked(a) + const n = await linked(a) + expect((await remove(n, a)).status).toBe(200) + // N's link outlives the wipe, at the current epoch. + expect(linkEpoch(n.inboxId)).toBe(0) + const response = await remove(x, a, n.inboxId) + expect(response.status).toBe(409) + expect(response.body).toMatchObject({ code: 'conflict' }) + expect(await alive(x, a)).toBe(true) + expect(sql('SELECT * FROM inbox_successors')).toEqual([]) + }) + + it('refuses a successor that has a successor of its own (no cycles)', async () => { + const a = await phone() + const x = await linked(a) + const n = await linked(a) + const y = await linked(a) + expect((await remove(x, a, n.inboxId)).status).toBe(200) + // N names X back. + const back = await remove(n, a, x.inboxId) + expect(back.status).toBe(409) + expect(await alive(n, a)).toBe(true) + // Y names X, itself replaced. + expect((await remove(y, a, x.inboxId)).status).toBe(409) + expect(await alive(y, a)).toBe(true) + expect(sql('SELECT inbox_id, successor_id FROM inbox_successors')).toEqual([ + { inbox_id: x.inboxId, successor_id: n.inboxId }, + ]) + }) + + it('refuses a successor whose own deletion is under way', async () => { + const a = await phone() + const x = await linked(a) + const n = await linked(a) + const m = await linked(a) + // N's record is written before its inbox is wiped: N is still linked and + // registered in between. + sql( + 'INSERT INTO inbox_successors (inbox_id, successor_id, account_id, recorded_at) VALUES (?, ?, ?, ?)', + n.inboxId, + m.inboxId, + a.accountId, + START + ) + expect(await alive(n, a)).toBe(true) + expect((await remove(x, a, n.inboxId)).status).toBe(409) + expect(await alive(x, a)).toBe(true) + }) + + it('leaves a deletion without a successor as before', async () => { + const a = await phone() + const x = await linked(a) + expect(await remove(x, a)).toEqual({ status: 200, body: { ok: true } }) + expect(await alive(x, a)).toBe(false) + // A repeat finds no inbox, as before. + expect((await remove(x, a)).status).toBe(404) + expect(sql('SELECT * FROM inbox_successors')).toEqual([]) + }) +}) + +describe('POST /buddies/v1/inbox/successor', () => { + it('answers null for an inbox nobody replaced', async () => { + const a = await phone() + const x = await linked(a) + expect(await successorOf(x.inboxId, bearer(a.accessToken))).toEqual({ + status: 200, + body: { ok: true, successor: null }, + }) + expect((await successorOf(randomId(), bearer(a.accessToken))).body).toEqual({ + ok: true, + successor: null, + }) + await remove(x, a) + expect((await successorOf(x.inboxId, bearer(a.accessToken))).body).toEqual({ + ok: true, + successor: null, + }) + }) + + it('needs a bearer token', async () => { + const response = await successorOf(randomId(), {}) + expect(response.status).toBe(401) + expect(response.body).toMatchObject({ code: 'unauthorized' }) + const forged = await successorOf(randomId(), bearer('nope')) + expect(forged.status).toBe(401) + }) + + it('refuses a malformed body', async () => { + const a = await phone() + const response = await h.post( + '/inbox/successor', + { inboxId: 'not an id' }, + bearer(a.accessToken) + ) + expect(response.status).toBe(400) + const envelopeBody = await h.post( + '/inbox/successor', + { payload: b64u(encoder.encode(JSON.stringify({ inboxId: randomId() }))) }, + bearer(a.accessToken) + ) + expect(envelopeBody.status).toBe(400) + }) + + it('follows the kill switch', async () => { + const a = await phone() + await h.kv.put('buddies:enabled', 'false') + const response = await successorOf(randomId(), bearer(a.accessToken)) + expect(response.status).toBe(503) + expect(response.body).toMatchObject({ code: 'disabled' }) + }) + + it('forgets a successor 90 days after it was recorded', async () => { + const a = await phone() + const x = await linked(a) + const n = await linked(a) + expect((await remove(x, a, n.inboxId)).status).toBe(200) + const db = accountsDb(h.env.ACCOUNTS_DB!) + + await pruneInboxSuccessors(db, START + 90 * DAY - 1) + expect((await successorOf(x.inboxId, bearer(a.accessToken))).body).toEqual({ + ok: true, + successor: n.inboxId, + }) + await pruneInboxSuccessors(db, START + 90 * DAY) + expect(sql('SELECT * FROM inbox_successors')).toEqual([]) + expect((await successorOf(x.inboxId, bearer(a.accessToken))).body).toEqual({ + ok: true, + successor: null, + }) + }) +}) + +describe('a thief holding the old key', () => { + it('can’t make its inbox the successor after the key change', async () => { + const k1 = await SigningKey.generate() + const a = await phone(k1) + const x = await linked(a) + // The thief opens a session with K1 and links its own inbox T (epoch 0). + const thief = await phone(k1) + expect(thief.accountId).toBe(a.accountId) + const t = await linked(thief) + expect(linkEpoch(t.inboxId)).toBe(0) + + // The User changes the key (epoch 1), ending the thief's session, and + // links the new inbox N under K2. + const a2 = await rekey(a) + const n = await linked(a2) + + // Naming T is refused; nothing is deleted. + const named = await remove(x, a2, t.inboxId) + expect(named.status).toBe(409) + expect(named.body).toMatchObject({ code: 'conflict' }) + expect(await alive(x, a2)).toBe(true) + + // The thief's access token outlives its session for minutes, but can't + // record a successor: the session ended. + const stale = await remove(x, thief, t.inboxId) + expect(stale.status).toBe(401) + expect(stale.body).toMatchObject({ code: 'unauthorized' }) + expect(await alive(x, a2)).toBe(true) + + // N it is. + expect(await remove(x, a2, n.inboxId)).toEqual({ + status: 200, + body: { ok: true }, + }) + expect((await successorOf(x.inboxId, bearer(a2.accessToken))).body).toEqual({ + ok: true, + successor: n.inboxId, + }) + + // K1 opens no session any more, so links nothing by bearer request... + await expect(phone(k1)).rejects.toThrow(/403 .*key_retired/) + // ...nor by a legacy one: K1's proof no longer verifies for the account. + const t2 = await newInbox() + const legacy = await h.post('/inbox/register', await registerPayload(t2, thief)) + expect(legacy.status).toBe(401) + expect(legacy.body).toMatchObject({ code: 'bad_signature' }) + expect(sql('SELECT * FROM account_inboxes WHERE inbox_id = ?', t2.inboxId)).toEqual([]) + }) + + it('loses a successor it recorded before the change', async () => { + const k1 = await SigningKey.generate() + const a = await phone(k1) + const x = await linked(a) + const thief = await phone(k1) + const t = await linked(thief) + // Before the change the thief is as good as the User: it can delete X + // naming T. + expect((await remove(x, thief, t.inboxId)).status).toBe(200) + expect((await successorOf(x.inboxId, bearer(a.accessToken))).body).toEqual({ + ok: true, + successor: t.inboxId, + }) + + // Once the key changes, T is no longer the account's current link, so + // the relay stops naming it, before the User names N... + const a2 = await rekey(a) + expect((await successorOf(x.inboxId, bearer(a2.accessToken))).body).toEqual({ + ok: true, + successor: null, + }) + // ...and the User can name N for the already-deleted X. + const n = await linked(a2) + expect(await remove(x, a2, n.inboxId)).toEqual({ + status: 200, + body: { ok: true }, + }) + expect((await successorOf(x.inboxId, bearer(a2.accessToken))).body).toEqual({ + ok: true, + successor: n.inboxId, + }) + }) +}) diff --git a/src/e2e/inboxSuccessor.e2e.test.ts b/src/e2e/inboxSuccessor.e2e.test.ts new file mode 100644 index 0000000..d486d61 --- /dev/null +++ b/src/e2e/inboxSuccessor.e2e.test.ts @@ -0,0 +1,210 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { SigningKey, b64u, randomBytes, randomId } from '../test/buddiesClient' +import { accountSignedMessage, rekeyNewKeyMessage } from '../accounts/contracts' +import { + BearerRelayOwner, + DEV_BYPASS_TOKEN, + bearerHeaders, + http, + openSession, + writeTranscript, + type E2ESession, +} from '../test/e2e' + +/** + * A Buddies inbox's successor (witness-work ADR 0039 §5) over real HTTP: a + * thief holding the old account key links its own inbox before the User + * changes the key; afterwards it can't be named as the old inbox's + * successor, its session can't record one, and its key opens no session, + * while the inbox the User links under the new key is recorded and read back + * by another account. Writes `.verify/artifacts/e2e-inbox-successor-*.json`. + */ + +afterAll(() => { + writeTranscript('inbox-successor') +}) + +const encoder = new TextEncoder() + +const rekey = async (session: E2ESession): Promise => { + const newKey = await SigningKey.generate() + const account = await http('GET', '/accounts/v1/account', { + headers: bearerHeaders(session), + }) + const bytes = encoder.encode( + JSON.stringify({ + accountPub: session.key.publicKey, + ts: Date.now(), + nonce: randomId(), + newAccountPub: newKey.publicKey, + keyEpoch: account.body.keyEpoch, + history: b64u(randomBytes(300)), + }) + ) + const response = await http('POST', '/accounts/v1/account/rekey', { + headers: bearerHeaders(session), + body: { + payload: b64u(bytes), + signature: b64u(await session.key.sign(accountSignedMessage('account/rekey', bytes))), + newSignature: b64u(await newKey.sign(rekeyNewKeyMessage(bytes))), + }, + }) + expect(response.status).toBe(200) + expect(response.body.keyEpoch).toBe(account.body.keyEpoch + 1) + return { ...session, key: newKey, accessToken: response.body.accessToken } +} + +const remove = (inbox: BearerRelayOwner, session: E2ESession, successor?: string) => + http('POST', '/buddies/v1/inbox/delete', { + headers: bearerHeaders(session), + body: { inboxId: inbox.inboxId, ...(successor ? { successor } : {}) }, + }) + +const successorOf = (inboxId: string, headers: Record) => + http('POST', '/buddies/v1/inbox/successor', { headers, body: { inboxId } }) + +const alive = async (inbox: BearerRelayOwner, session: E2ESession) => + (await inbox.sync(0, bearerHeaders(session))).status === 200 + +describe.skipIf(!DEV_BYPASS_TOKEN)('inbox successors on the local worker', () => { + it('names only the inbox the new key linked, never the thief’s', async () => { + const k1 = await SigningKey.generate() + const user = await openSession(k1) + const x = await BearerRelayOwner.create(user) + // The thief holds K1: same account, its own session and inbox T. + const thief = await openSession(k1) + expect(thief.accountId).toBe(user.accountId) + const t = await BearerRelayOwner.create(thief) + + const user2 = await rekey(user) + const n = await BearerRelayOwner.create(user2) + + const namedThief = await remove(x, user2, t.inboxId) + expect(namedThief.status).toBe(409) + expect(namedThief.body).toMatchObject({ ok: false, code: 'conflict' }) + expect(await alive(x, user2)).toBe(true) + + const staleToken = await remove(x, thief, t.inboxId) + expect(staleToken.status).toBe(401) + expect(staleToken.body).toMatchObject({ code: 'unauthorized' }) + expect(await alive(x, user2)).toBe(true) + + const reopened = await http('POST', '/accounts/v1/sessions', { + headers: { 'x-ww-dev-bypass': DEV_BYPASS_TOKEN ?? '' }, + body: await (async () => { + const bytes = encoder.encode( + JSON.stringify({ accountPub: k1.publicKey, ts: Date.now(), nonce: randomId() }) + ) + return { + payload: b64u(bytes), + signature: b64u(await k1.sign(accountSignedMessage('session/open', bytes))), + } + })(), + }) + expect(reopened.status).toBe(403) + expect(reopened.body).toMatchObject({ code: 'key_retired' }) + + const deleted = await remove(x, user2, n.inboxId) + expect(deleted.status).toBe(200) + expect(deleted.body).toEqual({ ok: true }) + expect(await alive(x, user2)).toBe(false) + // Idempotent. + expect((await remove(x, user2, n.inboxId)).body).toEqual({ ok: true }) + + const buddy = await openSession() + const read = await successorOf(x.inboxId, bearerHeaders(buddy)) + expect(read.status).toBe(200) + expect(read.body).toEqual({ ok: true, successor: n.inboxId }) + }) + + it('stops vouching for a successor recorded before the key change', async () => { + const k1 = await SigningKey.generate() + const user = await openSession(k1) + const x = await BearerRelayOwner.create(user) + // A K1 holder deletes X naming its own T while epoch 0 is current. + const thief = await openSession(k1) + const t = await BearerRelayOwner.create(thief) + expect((await remove(x, thief, t.inboxId)).status).toBe(200) + const buddy = await openSession() + expect((await successorOf(x.inboxId, bearerHeaders(buddy))).body).toEqual({ + ok: true, + successor: t.inboxId, + }) + + const user2 = await rekey(user) + expect((await successorOf(x.inboxId, bearerHeaders(buddy))).body).toEqual({ + ok: true, + successor: null, + }) + + // The User's own successor replaces the stale record... + const n = await BearerRelayOwner.create(user2) + expect((await remove(x, user2, n.inboxId)).body).toEqual({ ok: true }) + expect((await successorOf(x.inboxId, bearerHeaders(buddy))).body).toEqual({ + ok: true, + successor: n.inboxId, + }) + // ...and, still qualifying, isn't replaced by another. + const other = await BearerRelayOwner.create(user2) + const second = await remove(x, user2, other.inboxId) + expect(second.status).toBe(409) + expect((await successorOf(x.inboxId, bearerHeaders(buddy))).body).toEqual({ + ok: true, + successor: n.inboxId, + }) + }) + + it('never registers a replaced inbox again, and refuses a deleted or replaced successor', async () => { + const user = await openSession() + const x = await BearerRelayOwner.create(user) + const n = await BearerRelayOwner.create(user) + expect((await remove(x, user, n.inboxId)).status).toBe(200) + const again = await x.register() + expect(again.status).toBe(409) + expect(again.body).toMatchObject({ code: 'conflict' }) + expect(await alive(x, user)).toBe(false) + // A cycle: N names X back. + expect((await remove(n, user, x.inboxId)).status).toBe(409) + expect(await alive(n, user)).toBe(true) + // A deleted successor. + const y = await BearerRelayOwner.create(user) + const gone = await BearerRelayOwner.create(user) + expect((await remove(gone, user)).status).toBe(200) + expect((await remove(y, user, gone.inboxId)).status).toBe(409) + expect(await alive(y, user)).toBe(true) + }) + + it('answers null, and needs a token', async () => { + const buddy = await openSession() + expect((await successorOf(randomId(), bearerHeaders(buddy))).body).toEqual({ + ok: true, + successor: null, + }) + const anonymous = await successorOf(randomId(), {}) + expect(anonymous.status).toBe(401) + expect(anonymous.body).toMatchObject({ code: 'unauthorized' }) + const malformed = await http('POST', '/buddies/v1/inbox/successor', { + headers: bearerHeaders(buddy), + body: { inboxId: 'nope' }, + }) + expect(malformed.status).toBe(400) + }) + + it('refuses another account’s inbox and the inbox itself', async () => { + const user = await openSession() + const other = await openSession() + const x = await BearerRelayOwner.create(user) + const theirs = await BearerRelayOwner.create(other) + expect((await remove(x, user, theirs.inboxId)).status).toBe(409) + expect((await remove(x, user, x.inboxId)).status).toBe(409) + expect((await remove(x, other, theirs.inboxId)).status).toBe(403) + expect(await alive(x, user)).toBe(true) + }) + + it('is documented in the OpenAPI document', async () => { + const doc = await http('GET', '/openapi.json') + expect(doc.status).toBe(200) + expect(doc.body.paths['/buddies/v1/inbox/successor']).toBeDefined() + expect(doc.text).toContain('successor') + }) +}) diff --git a/src/index.ts b/src/index.ts index d11468e..9e59c60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,14 @@ import { pruneEndedSessions } from "./accounts/sessionStore"; import { pruneActivity } from "./accounts/activity"; import { pruneSupporterRecords } from "./accounts/supporter"; import { pruneUsedTokens } from "./accounts/passkeyStore"; +import { pruneInboxSuccessors } from "./accounts/inboxSuccessors"; /** * Deletes account sessions that ended over a week ago, activity entries * over 400 days old, RevenueCat webhook event ids over 30 days old, - * RevenueCat customers no account links, and used passkey challenge and - * ticket ids whose tokens expired; failures only report. + * RevenueCat customers no account links, used passkey challenge and + * ticket ids whose tokens expired, and Buddies inbox successors over 90 days + * old; failures only report. */ async function pruneAccountSessions(environment: Environment): Promise { if (!environment.ACCOUNTS_DB) return; @@ -41,6 +43,12 @@ async function pruneAccountSessions(environment: Environment): Promise { console.error("accounts: pruning used token ids failed"); captureFailure(error, "used token prune"); } + try { + await pruneInboxSuccessors(db); + } catch (error) { + console.error("accounts: pruning inbox successors failed"); + captureFailure(error, "inbox successor prune"); + } } // Durable Object classes must be re-exported from the entry module so Wrangler