diff --git a/.agents/skills/verify-ww-api/SKILL.md b/.agents/skills/verify-ww-api/SKILL.md index 607af5a..b72b937 100644 --- a/.agents/skills/verify-ww-api/SKILL.md +++ b/.agents/skills/verify-ww-api/SKILL.md @@ -18,7 +18,7 @@ node scripts/verify/dev.mjs up --secrets-from ~/dev/ww-api/.dev.vars # opt in to - Ready when `up` prints `verify: worker ready (pid N, …)` and then the URL. It polls `GET /health` for up to 90 s and exits nonzero with the last log lines if wrangler dies. - `up` is idempotent. If `.verify/state.json` names a live, healthy worker, `up` reuses it. If the recorded worker is alive but unhealthy, `up` restarts it. - Env `dev` gives `APP_ATTEST_ENVIRONMENT=development`, `BUDDIES_ENABLED=true`, and `BUDDIES_PHOTOS=on`. The fresh KV has no `buddies:enabled` or `buddies:photos` key, so the relay and its photo blobs are on with no switch to flip. The `BUDDY_BLOBS` R2 bucket is simulated locally in the persistence dir. -- `.verify/dev.vars.env` (mode 600) holds a random `NOTES_IMPORT_DEV_BYPASS_TOKEN` and `ADMIN_API_TOKEN`, plus `APPLE_TEAM_ID=VERIFY0000` unless a real one is passed. The tokens are reused across restarts and recorded in `state.json` so tests can read them. +- `.verify/dev.vars.env` (mode 600) holds a random `NOTES_IMPORT_DEV_BYPASS_TOKEN`, `ADMIN_API_TOKEN`, and `ACCOUNT_TOKEN_SECRET` (account sessions), plus `APPLE_TEAM_ID=VERIFY0000` unless a real one is passed. The tokens are reused across restarts and recorded in `state.json` so tests can read them. - The WitnessWork app's verify loop consumes this as `node /scripts/verify/dev.mjs up` followed by `… url`. ## Doctor @@ -37,6 +37,7 @@ Read-only. Each check prints an `ok` or `FAIL` line, and the command exits 1 on - Buddies is enabled (an unknown `invite/fetch` returns `not_found`, not `disabled`); - Buddies photos are enabled (an unknown `blob/get` returns `not_found`, not `photos_disabled`); - the dev bypass works (the value is never printed); +- account sessions are on (an unknown refresh token returns `unauthorized`, not `disabled`); - which optional credentials are set. Run it first whenever anything looks off. @@ -68,7 +69,7 @@ pnpm fuzz:buddies --seed 42 --case 17 # replay one failing case The seed decides each case's mutation and its parameters. Ids, keys, and nonces are fresh every run, so you can replay a seed against the same persisted state. -There are 19 generators: +There are 28 generators, 9 of them for bearer requests (skipped, and counted as skipped, without a dev bypass token): - **Bad signatures:** tampered signature, tampered payload, wrong key or wrong role, a signature reused on another op, malformed `signature`. - **Replay and time:** sequential and parallel replay, stale `ts`. @@ -76,6 +77,7 @@ There are 19 generators: - **Size and caps:** blob sizes at and one byte over every limit, envelopes over 256 KiB (declared and chunked), slot, invite, template, kind, and expiry caps. - **Photo blobs:** `blob/put` → `blob/get` round trip with an idempotent retry, 1 MiB accepted and one byte more `too_large`, a flipped bit (`bad_request`), a stranger's signature, and the same 404 for a wrong token, an unknown blob or inbox, and a deleted blob. - **Other:** unknown routes and methods, invite claim burn after 5 wrong secrets, parallel `event/put` dedupe, per-caller edge limits (one IPv4, or rotating addresses in one IPv6 /64, until 429 `rate_limited` with `Retry-After: 60`). +- **Bearer requests** (sessions opened through the dev bypass): bad tokens on every route class (forged, truncated, `alg: none`, a refresh token, other schemes; always 401 `unauthorized` with the challenge, never the envelope), another account's token (403 `forbidden`), an inbox registered before accounts (409 `not_linked`), malformed plain-JSON bodies and envelopes sent with a token (400), `inbox/register` proofs (missing, another account's, forged, for another inbox), bearer `blob/put` headers, writer ops with any session's token, a bearer round trip, and per-account edge limits from rotating addresses. The oracle checks every request: @@ -84,7 +86,7 @@ The oracle checks every request: - Buddies errors are `{error}` JSON (the full body is `{ok: false, error, code}`); only a 200 `blob/get` is raw bytes; - each mutation gets its documented status (for example, a bad signature is 401 `bad_signature`). -After the run, a canary inbox's full `inbox/sync` must equal its pre-fuzz snapshot, and `/health` must still be ok. When a case fails, the fuzzer prints the seed, a `--case` repro command, and the exact failing request, then exits 1. +After the run, a canary inbox's full `inbox/sync` must equal its pre-fuzz snapshot (and so must the bearer canary's, registered and synced by bearer request), and `/health` must still be ok. The report records secret headers (`authorization`, `x-ww-dev-bypass`) as ``. When a case fails, the fuzzer prints the seed, a `--case` repro command, and the exact failing request, then exits 1. ## Evidence @@ -116,6 +118,7 @@ Only names are listed here; never print values. | --- | --- | --- | | `NOTES_IMPORT_DEV_BYPASS_TOKEN` | Notes Import bypass tests | generated per state dir | | `ADMIN_API_TOKEN` | admin reset positive tests | generated per state dir | +| `ACCOUNT_TOKEN_SECRET` | account sessions (`/accounts/v1/sessions*`, bearer tokens) | generated per state dir | | `APPLE_TEAM_ID` | AASA app ids | `VERIFY0000` (synthetic) | | `HERE_API_KEY` | `/geocode` + `/autocomplete` smoke (one call each) | unset, so skipped; set `WW_API_E2E_SKIP_HERE=1` to skip even when set | | `OPENROUTER_API_KEY` | real provider health and the paid kickoff | unset; the status probe fails open | @@ -131,7 +134,7 @@ Only names are listed here; never print values. ## Helpers - `scripts/verify/dev.mjs`: `up`, `doctor`, `url`, `kv [value]`, `down [--wipe]`. Also available as `pnpm verify `. -- `pnpm test:e2e` uses `vitest.e2e.config.ts`. It runs files serially because the kill-switch test flips shared KV. Helpers are in `src/test/e2e.ts`: `http`, `RelayOwner`, `RelayWriter`, `localKv`, `writeTranscript`. +- `pnpm test:e2e` uses `vitest.e2e.config.ts`. It runs files serially because the kill-switch test flips shared KV. Helpers are in `src/test/e2e.ts`: `http`, `RelayOwner`, `RelayWriter`, `openSession`, `BearerRelayOwner`, `sendWriter`, `localKv`, `writeTranscript`. - `pnpm fuzz:buddies` runs `scripts/verify/fuzz-buddies.mjs`. It uses the plain-Node wire client `scripts/verify/buddies-wire.mjs`, which `scripts/verify/buddies-wire.test.ts` (part of `pnpm test`) keeps in sync with `src/buddies/contracts.ts` and `envelope.ts`. - CI is `.github/workflows/test.yml`: typecheck, unit tests, `up`, `doctor`, e2e, `fuzz --runs 100`, then `down`. Evidence is uploaded as the `verify-evidence` artifact. - To keep the feature map honest as routes change, use `/maintain-verification-skill`. diff --git a/.agents/skills/verify-ww-api/features/README.md b/.agents/skills/verify-ww-api/features/README.md index 05d4671..059e1e0 100644 --- a/.agents/skills/verify-ww-api/features/README.md +++ b/.agents/skills/verify-ww-api/features/README.md @@ -41,8 +41,9 @@ Keep implementation details out of the map. Name only user paths, stable handles - [Health and status](./health-and-status.md) covers `/health`, `/notes-import/status`, App Store ratings, and the JSON 404. - [Notes Import](./notes-import.md) covers the attestation verify probe and challenge (`/attestation/v1/*` and their old `/notes-import/*` paths), kickoff's auth gate, and the opt-in paid kickoff. -- [Buddies relay](./buddies-relay.md) covers pairing end to end, sync read-backs, auth failures, caps, the kill switch, and fuzzing. +- [Buddies relay](./buddies-relay.md) covers pairing end to end, sync read-backs, auth failures, caps, the kill switch, bearer requests on account sessions and the legacy switch, and fuzzing both. - [Universal link pages](./universal-link-pages.md) covers the AASA, the `/contacts` (`/c`, `/c/:payload`) contact pages, and the `/buddies/invite` (`/b`) invite page. - [Admin reset](./admin.md) covers the maintainer usage reset and its 404 fail-closed auth. +- [Accounts and sessions](./accounts.md) covers sessions through the dev bypass, `GET /accounts/v1/account`, bearer handoff and backup, the legacy switch, and `/app/v1/config`. Not mapped yet: supporter route planning (`/route-planning`, needs `HERE_API_KEY`). Map it with `maintain-verification-skill` before relying on it. diff --git a/.agents/skills/verify-ww-api/features/accounts.md b/.agents/skills/verify-ww-api/features/accounts.md new file mode 100644 index 0000000..47717b2 --- /dev/null +++ b/.agents/skills/verify-ww-api/features/accounts.md @@ -0,0 +1,36 @@ +# Accounts and sessions + +The app proves it's WitnessWork once per session (`POST /accounts/v1/sessions`, attested through `/attestation/v1/challenge`; the dev bypass locally) and then calls account routes with a 15-minute bearer token it renews with `POST /accounts/v1/sessions/refresh`. Settings → Account reads the account, Set Up Another Device hands the key to a new phone, and backups go to R2. Older app versions keep using signed envelopes until the legacy switch is turned off, and `/app/v1/config` tells every new version its minimum. + +## Sub-features + +- `session-open` (`POST /accounts/v1/sessions`) answers 201 with `Location: /accounts/v1/sessions/current` and `{ok, accountId, status, sessionId, accessToken, accessExpiresAt, refreshToken, sessionExpiresAt}` for a self-signed envelope plus the dev bypass. +- `bearer-info` returns the token's account from `GET /accounts/v1/account` (and, for older builds, `POST /accounts/v1/account/info` with no body). +- `refresh` (`POST /accounts/v1/sessions/refresh`) replaces both tokens; `sessionExpiresAt` never moves. +- `close` (`DELETE /accounts/v1/sessions/current`) ends the session; its refresh token then gets 401 `unauthorized` with `WWW-Authenticate: Bearer error="invalid_token"`. +- `bearer-backup` puts with `x-ww-slot-id`/`x-ww-sha256`, gets the bytes back, and deletes only with the account key's envelope too. +- `bearer-handoff` opens and reads a channel with the token. +- `legacy-off` answers envelope-authenticated requests with 426 `upgrade_required` once KV `auth:legacy` is `off`, while bearer requests and opening sessions keep working. +- `app-config` returns `{ok}` or `{ok, minAppVersion}` from KV `app:min-version`, uncached. + +## How to get to it (user POV) + +- Opening the app (with an account) opens or refreshes a session. +- Settings → Account reads the account; Set Up Another Device and Back Up to WitnessWork use handoff and backup. +- Every launch and return to the foreground reads `/app/v1/config`. + +## Driving it with verify-ww-api + +Preconditions: + +- `node scripts/verify/dev.mjs doctor` passes, including `account sessions enabled`. + +- **Whole flow.** Run `pnpm test:e2e src/e2e/accounts.e2e.test.ts`. Vitest reports `/accounts/v1/sessions (dev bypass)`, `the legacy switch (local KV)`, and `GET /app/v1/config` as passed, and `.verify/artifacts/e2e-accounts-*.json` holds the exchanges. +- **Sessions on.** Run `curl -s -X POST $URL/accounts/v1/sessions/refresh -H 'content-type: application/json' -H "cf-connecting-ip: 198.18.4.1" -d '{"refreshToken":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}'`. The body is `{"ok":false,"error":"unauthorized","code":"unauthorized"}`; `disabled` means no `ACCOUNT_TOKEN_SECRET`. +- **App config.** Run `node scripts/verify/dev.mjs kv put app:min-version '{"minVersion":"9.8.7"}'`, then `curl -si $URL/app/v1/config`. You get 200, `cache-control: no-store`, and `{"ok":true,"minAppVersion":"9.8.7"}`. Restore it with `node scripts/verify/dev.mjs kv delete app:min-version`. + +## Gotchas + +- Session envelopes (op `session/open`) are Ed25519-signed; use the e2e helpers, not hand-built curl. +- Restore `auth:legacy` (`kv delete auth:legacy`) after flipping it, or every legacy accounts and handoff test after it gets 426. +- Real attestations need a device and the deployed dev worker. Locally only the dev bypass opens sessions, and it reports platform `dev`. diff --git a/.agents/skills/verify-ww-api/features/buddies-relay.md b/.agents/skills/verify-ww-api/features/buddies-relay.md index c4bb227..54ac467 100644 --- a/.agents/skills/verify-ww-api/features/buddies-relay.md +++ b/.agents/skills/verify-ww-api/features/buddies-relay.md @@ -2,6 +2,8 @@ Buddies lets two publishers share upcoming Plans through a blind relay. Each person owns an inbox signed with their Ed25519 key. A buddy writes cards and events into the other inbox through a per-pair writer key. Pairing runs invite → fetch → claim → confirm, and `inbox/sync` reads everything back. The wire contract is in `docs/buddies-protocol.md`. +Every op takes two forms. A **bearer request** (`Authorization: Bearer` from an account session; app versions with sessions) sends owner ops as plain JSON and is authorized by the account the inbox is linked to; writer ops still carry the writer envelope. A **legacy request** (no header; shipped builds) is the signed envelope, until KV `auth:legacy` is `off`. + ## Sub-features - `register` makes `inbox/register` idempotent per key and returns `conflict` for a different key, even after the inbox was wiped. @@ -18,6 +20,8 @@ Buddies lets two publishers share upcoming Plans through a blind relay. Each per - `kill-switch` makes every op return 503 `disabled` when KV `buddies:enabled` is `false`, except `inbox/delete`, `slot/remove`, and `slot/leave`. - `photos` stores encrypted note photos: `blob/put` (raw sealed bytes, owner-signed envelope in `x-buddies-payload`/`x-buddies-signature`, `blobId` = SHA-256 of the bytes, at most 1 MiB) writes R2 object `v1//`; `blob/get` (unsigned `{payload}` with `inboxId`, `blobId`, `token`) streams it back with `Cache-Control: no-store`, and every miss is the same 404; `blob/delete` (owner) removes them. `inbox/sync` reports `capabilities.photos`. KV `buddies:photos` (`BUDDIES_PHOTOS=on` in dev) switches them, answering 503 `photos_disabled` when off. - `live` upgrades `GET /buddies/v1/inbox/live` (signed headers `x-buddies-payload`/`x-buddies-signature`) to a WebSocket that sends `hello`, then `changed` with the inbox `seq` after each write, answers `ping` with `pong`, and closes with 4001 `gone` on `inbox/delete`. A plain GET gets 426 and the kill switch 503. +- `bearer` runs every op class as a bearer request: `inbox/register` with the token and a required `account` proof for the token's account (403 `forbidden` otherwise); owner ops as plain JSON (`inbox/live` and `blob/put` in `x-buddies-payload`, no signature header), 403 `forbidden` for another account's inbox and 409 `not_linked` for one registered before accounts; writer ops with the token plus the writer envelope; invite and blob reads with the token plus today's body. A bad token is 401 `unauthorized` with `WWW-Authenticate`, never a fallback. Bearer limits count against a hash of the account, not the IP. +- `legacy-off` answers every legacy request with 426 `upgrade_required` (no `Retry-After`) once KV `auth:legacy` is `off`, leaving ops included, while bearer requests keep working. ## How to get to it (user POV) @@ -41,8 +45,9 @@ Preconditions: - **Photo blobs.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "photo blobs"`. A 200 KiB blob round-trips byte for byte; a retry keeps the first token and only a later expiry moves it; a wrong token, an unknown blob, and an unknown inbox all get the same 404; mismatched bytes are 400, `bytes` or a body past 1 MiB 413 `too_large`, a stranger's signature 401, a resent envelope 409 `replay`; `blob/delete` and `inbox/delete` make reads 404. To see objects really leave R2, count rows in `/v3/r2/miniflare-R2BucketObject/*.sqlite` (`SELECT COUNT(*) FROM _mf_objects`) after a run. Caps (300 blobs, 150 MiB, 100 uploads a day), expiry by the alarm, and R2 failures are unit-tested in `src/buddies/blobs.test.ts`. - **Photos switch.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "photos switch"`. With KV `buddies:photos` `off`, the three blob ops return 503 `photos_disabled` (with `Retry-After: 60`) and `inbox/sync` says `capabilities.photos: false`; the stored blob reads again once the key is deleted. - **Kill switch.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "kill switch"`. `inbox/register` and `inbox/sync` return 503 `disabled`, `inbox/delete` still returns `{"ok":true}`, and the switch is restored afterwards. -- **Fuzz.** Run `pnpm fuzz:buddies --runs 300`. The last lines read `fuzz: canary intact, health after ok` and `fuzz: ok`. -- **Proof.** Keep `.verify/artifacts/e2e-buddies-*.json` (every envelope and response) and `.verify/artifacts/fuzz-*.json`, which must have `failures: []` and `canary.intact: true`. +- **Bearer requests.** Run `pnpm test:e2e src/e2e/buddiesSessions.e2e.test.ts`. Two sessions open through the dev bypass; the owner registers with a proof, adds a slot, the buddy writes an event with its own token, the owner syncs it back (twice: no replay without a nonce), a photo round-trips, an invite is fetched and claimed, and the live socket says `hello`, `changed`, and `pong`, then closes 4001 on `inbox/delete`. It also checks `not_linked` until a legacy-registered inbox links, `forbidden` for another account's token (sync, live, `blob/put`, a proof for another account), and 401 for a bad token. With the local launcher it flips `auth:legacy` off (every legacy form 426, bearer still 200) and spends one account's 120 unsigned requests from fresh addresses (429), while another account and a legacy caller from that address still pass. +- **Fuzz.** Run `pnpm fuzz:buddies --runs 300`. The last lines read `fuzz: canary intact, bearer canary intact, … health after ok` and `fuzz: ok`. +- **Proof.** Keep `.verify/artifacts/e2e-buddies-*.json` and `e2e-buddies-sessions-*.json` (every envelope and response; tokens by header name only) and `.verify/artifacts/fuzz-*.json`, which must have `failures: []`, `canary.intact: true`, and `bearerCanary.intact: true`. ## Gotchas @@ -54,3 +59,6 @@ Preconditions: - The kill-switch and photos-switch tests edit shared local KV. Don't run e2e and the fuzzer at the same time. - A `blob/put` refused before its body is read (over 1 MiB) can trip the same `wrangler dev` proxy quirk as a cancelled upload; the e2e test and the fuzzer send a resendable POST afterwards to drain it. - After the server closes a live socket, `wrangler dev` keeps the TCP connection about 10 s, so a standard WebSocket client fires `close` late even though the close frame arrived at once. `openLive` resolves `closed` on the frame. +- A bearer owner body is the plain fields; an envelope (`payload`/`signature`) with a token is 400, not a fallback. Bearer `inbox/live` and `blob/put` take only `x-buddies-payload`: a signature header (either naming) or only the short `x-buddies-p` is 400. +- Access tokens last 15 minutes. Long manual sessions open a new one (`openSession` in `src/test/e2e.ts`; the same account key gets the same account). +- The legacy-switch tests edit shared local KV (`auth:legacy`); restore it with `kv delete auth:legacy` after flipping it by hand. diff --git a/.agents/skills/verify-ww-api/features/notes-import.md b/.agents/skills/verify-ww-api/features/notes-import.md index f2d05af..313b895 100644 --- a/.agents/skills/verify-ww-api/features/notes-import.md +++ b/.agents/skills/verify-ww-api/features/notes-import.md @@ -1,6 +1,6 @@ # Notes Import -Notes Import turns a publisher's free-form notes into contacts, visits, and time entries through an LLM. Every metered call is gated by App Attest on iOS and Play Integrity on Android; simulators, emulators and agents use the development-only bypass header instead. The safe verification surface is the attested no-op `verify` probe and the auth gate in front of `kickoff`. A real kickoff spends OpenRouter credits and is opt-in only. +Notes Import turns a publisher's free-form notes into contacts, visits, and time entries through an LLM. App versions with account sessions call every route with `Authorization: Bearer ` and are metered by the account; older ones attest each metered call (App Attest on iOS, Play Integrity on Android) while the legacy switch is on. Simulators, emulators and agents open a session with the dev bypass, or send the development-only bypass header on legacy requests. The safe verification surface is the attested no-op `verify` probe and the auth gate in front of `kickoff`. A real kickoff spends OpenRouter credits and is opt-in only. ## Sub-features @@ -11,6 +11,10 @@ Notes Import turns a publisher's free-form notes into contacts, visits, and time - `challenge` (`POST /attestation/v1/challenge`) issues an App Attest challenge. - `legacy-paths`: `/notes-import/challenge`, `/attest`, and `/verify` answer like their `/attestation/v1/` successors, adding `Deprecation` and a `successor-version` `Link`. - `kickoff-gate` returns 401 `attestation_required` for kickoff without credentials (503 when no provider is healthy). No inference runs. +- `bearer-bad-token` returns 401 `unauthorized` with `WWW-Authenticate` for a bad token, even with the bypass header and a legacy body. +- `bearer-lifecycle`: a dev session's token runs kickoff (no `subscribeToken`, unlimited `credits`) → `result` → `events` → `destroy` → 404, then re-kicks the same notes. Without `OPENROUTER_API_KEY` the run ends `model_error` at no cost; with a key it needs `WW_API_E2E_ALLOW_PAID=1`. +- `bearer-cross-account`: another account's token gets 404 `not_found` on `events`, `result`, `cancel`, and `destroy`, the same as an unknown import id. +- `legacy-off`: with KV `auth:legacy` off, legacy kickoff, transcribe, and `?token=` run routes return 426 `upgrade_required` (message in `error`, no `Retry-After`) while bearer requests work. - `kickoff-paid` checks dates, duration conversion, existing contact/category reuse, and publisher role, then applies a correction and checks the complete result. It makes two real model calls, spends money, and is opt-in. ## How to get to it (user POV) @@ -33,6 +37,7 @@ Preconditions: - **Old path.** Run `curl -si -X POST $URL/notes-import/challenge -H "cf-connecting-ip: 198.18.2.5" -d '{}'`. You get 200 with a `challenge`, `deprecation: @1792022400`, and `link: ; rel="successor-version"`. - **Kickoff gate.** Run `curl -s -X POST $URL/notes-import/kickoff -H "cf-connecting-ip: 198.18.2.4" -d '{"uuid":"u1","notesText":"x","context":{"now":"2026-10-01T00:00:00Z","timeZone":"UTC","existingContacts":[],"existingCategories":[]}}'`. You get 401 `attestation_required`, and `.verify/wrangler.log` shows no OpenRouter call. - **Paid kickoff (opt-in only).** Run `WW_API_E2E_ALLOW_PAID=1 pnpm test:e2e src/e2e/notesImport.e2e.test.ts` after `up --secrets-from `. The `PAID:` test checks both complete results and their records. For a deployed dev worker, set `WW_API_URL` and `WW_API_DEV_BYPASS_TOKEN`; its existing provider secret is used and no local OpenRouter key is required. `WW_API_VERSION_OVERRIDES` optionally supplies Cloudflare's dictionary header for a 0%-traffic dev version, e.g. `ww-proxy-dev=""`. +- **Bearer.** Open a session as in `accounts.md` (dev bypass), then `curl -s -X POST $URL/notes-import/kickoff -H "authorization: Bearer $ACCESS" -H "cf-connecting-ip: 198.18.2.6" -d '{"notesText":"x","context":{"now":"2026-10-01T00:00:00Z","timeZone":"UTC","existingContacts":[],"existingCategories":[]}}'`. You get `{"importId":…,"refinement":false,"credits":{…"limit":null…}}`; poll `GET $URL/notes-import//result` with the same header. `pnpm test:e2e src/e2e/notesImportAccounts.e2e.test.ts` runs the whole bearer surface, including the legacy switch. - **Proof.** Run `pnpm test:e2e src/e2e/notesImport.e2e.test.ts`. The free tests pass, the `PAID:` test shows as skipped unless opted in, and the transcript is in `.verify/artifacts/e2e-notes-import-*.json`. ## Gotchas @@ -41,4 +46,6 @@ Preconditions: - `/notes-import*` is rate limited at 60/min per `cf-connecting-ip` (its own bucket, apart from place search; `/attestation/*` has another). Reusing one IP across a long run produces 429s (`rate_limited`, `Retry-After: 60`) that look like bugs. - While a run is live, the SSE stream sends a `:` comment line every 15 s; finished, cancelled, and unknown runs close right after the replay. - `/notes-import/:id/events` is SSE. Use `/result` for assertions. +- Bearer run routes ignore `?token=`, and legacy ones never read the bearer import's owner: a bearer import has no subscribe token, and a legacy import is 404 to every bearer token. +- `auth:legacy` is shared local KV: the accounts and Notes Import e2e files each flip it and delete it in `finally`. Files run serially, so they don't collide. - The bypass skips attestation only. Real App Attest and Play Integrity flows need a physical device against the deployed dev worker (see AGENTS.md); don't claim them from this loop. diff --git a/.dev.vars.example b/.dev.vars.example index ede0cb3..3f4ed13 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -27,6 +27,11 @@ REVENUECAT_API_KEY=sk_your_revenuecat_secret_here # sending header `x-ww-dev-bypass: `. Leave UNSET in production. NOTES_IMPORT_DEV_BYPASS_TOKEN=local-dev-bypass +# Signs account session access tokens (witness-work ADR 0027). At least 32 +# characters; without it, /accounts/v1/session/* answers `disabled`. Local +# only; generate with: openssl rand -base64 32 +ACCOUNT_TOKEN_SECRET=generate_with_openssl_rand_base64_32_local_only + # Dedicated local admin-reset token. Do not reuse the dev-bypass token. ADMIN_API_TOKEN=generate_with_openssl_rand_base64_32 diff --git a/AGENTS.md b/AGENTS.md index 22ce714..d4df6bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,12 +121,17 @@ Device attestation is API-wide, under `/attestation/v1/` (code in `ATTESTATION_PURPOSES` in `src/attestation/purposes.ts`. Installed apps sign these strings, so **never rename a shipped purpose**. `attestation-verify` is verify's purpose; `notes-import-verify` (what deployed apps sign) is - accepted on verify too. + accepted on verify too. `account-session` opens an account session + ([Sessions](#sessions-and-bearer-tokens)). - **Protecting a new route**: add a purpose, have the route recompute its `contentHash`/`requestHash` from the payload and reject a mismatch, then call `requireAttestation(ctx, body, purpose)` (`src/attestation/http.ts`; - App Attest v2 or Play Integrity, never v1). Notes Import kickoff keeps its - own gate because it still accepts v1. + App Attest v2 or Play Integrity, never v1). A route with its own error + format calls `verifyAttestation` instead, which resolves with the platform + (`ios`, `android`) or throws the provider's error with its `reason`. + Notes Import kickoff keeps its own gate because it still accepts v1. A + user-scoped route shouldn't attest at all: it takes the session's bearer + token (see Accounts). - **Old paths**: `POST /notes-import/challenge`, `/notes-import/attest`, and `/notes-import/verify` serve the same handlers for deployed apps. They add `Deprecation: @1792022400` (2026-10-15) and @@ -160,8 +165,8 @@ pnpm run deploy:dev Secrets are per environment: add `--env dev` to `wrangler secret put` to keep them off prod (the full list is at the bottom of `wrangler.toml`). -`NOTES_IMPORT_DEV_BYPASS_TOKEN` is dev only, and `ADMIN_API_TOKEN` differs -between the two workers. +`NOTES_IMPORT_DEV_BYPASS_TOKEN` is dev only, and `ADMIN_API_TOKEN` and +`ACCOUNT_TOKEN_SECRET` differ between the two workers. ## Local iteration (no deploy) @@ -217,9 +222,49 @@ pnpm run admin:reset-usage # production pnpm run admin:reset-usage --dev # development ``` +The meter id is the account id for app versions with sessions (from an inbox +id: `POST /admin/accounts/lookup`), else the shared account id or install uuid. + `ADMIN_API_TOKEN` and `ADMIN_API_TOKEN_DEV` must match separate Wrangler `ADMIN_API_TOKEN` secrets in prod/dev. Never reuse the development bypass token. +## Notes Import on account sessions + +Notes Import identifies the caller by the session's account (witness-work ADR +0027). The wire contract is in README.md ("Who's calling") and `/docs`. + +- **Bearer**: kickoff, the synchronous `POST /notes-import`, and transcribe + take `Authorization: Bearer` and plain fields (no attestation, no hashes); + `meterId` is the token's account id. They refuse a suspended account + (`requireActive`, 403). Run routes (`events`, `result`, `cancel`, `destroy`) + take the token instead of `?token=` and serve only that account's imports: + anything else is 404 `not_found`, unknown and foreign alike. Kickoff mints no + subscribe token. +- **Ownership**: a bearer run stores `owner` = + `importOwnerKey(accountId)` (a SHA-256, not the id) in its `meta` table at + `start()`; `NotesImportRun.isOwnedBy` compares it and stores nothing for an + unknown id (tables are created on the first write). Bearer import ids hash + `account::[:r:]`, which + has no `|`, so no legacy basis (`|…`, any uuid) can + produce one: a legacy kickoff can never get a subscribe token for an + account's run. +- **Supporter**: one seam, `isAccountSupporter` + (`src/accounts/supporter.ts`), at kickoff and settlement. Until RevenueCat + is linked to the account it checks the request's optional + `revenueCatAppUserId`; absent is free. The RevenueCat-linking change swaps + its body and drops the field. +- **Dev**: a `dev` session gets the dev bypass's allowances only where + `NOTES_IMPORT_DEV_BYPASS_TOKEN` is set (dev worker); the bypass header is + ignored on bearer requests. +- **Errors**: Notes Import's shape (message in `error`) for every refusal, + bearer and legacy: 401 `unauthorized` with `WWW-Authenticate`, 403 + `suspended`, 503 `disabled`, and 426 `upgrade_required` (no `Retry-After`) + for a legacy request once `auth:legacy` is off. +- **Allowances don't carry over**: usage under an install uuid or shared + account id stays on that meter; each account starts a fresh window. +- `GET /notes-import/status` is unchanged: public, the same for everyone, and + cached per isolate as below. It never reads `Authorization`. + ## Status caching and trace sampling `GET /notes-import/status` reuses completed public responses in each Worker @@ -249,6 +294,16 @@ Production status, HERE proxy, health, contact-link and other routine routes use capture and contact-data redaction remain unchanged; Cloudflare logging retains its existing sampling configuration. +Because those traces record request URLs whole, `src/sentry.ts` cuts the value +of every `token` query parameter (legacy run routes' subscribe token, `?token=`) +and `apiKey` (a client-sent HERE key) from every string in an event, +transaction, span, and breadcrumb (`token=[redacted]`), and from an object or +pair-list `request.query_string`. Names match as the worker reads them, +percent-escapes decoded (`?to%6Ben=` is `token` to Hono), and without regard +to case. A new route that takes a credential in its +query string adds its name to `SECRET_QUERY_PARAMETERS` (better: take it in a +header). + ## Buddies `POST /buddies/v1/{op}` is the Buddies relay, built to the wire contract in @@ -261,6 +316,46 @@ sent. The AASA matches `/buddies/invite#1…` and `/b#1…`, and Links. Clients are iOS and Android (witness-work ADR 0020). +- **Authentication** (witness-work ADR 0027; "Authentication (sessions)" in + the protocol): a request with an `Authorization` header is a bearer + request, and its access token must verify (401 `unauthorized`, never a + fallback). `inbox/register` sends the token plus its self-signed + envelope, whose `account` proof is then required (400) and must name the + token's account (403 `forbidden`). Owner ops send their fields as plain + JSON, no envelope, `ts`, or `nonce` (`inbox/live` and `blob/put` in + `x-buddies-payload`, no signature header); the inbox DO compares the + token's account with its `owner_account` row: another account is 403 + `forbidden`, none 409 `not_linked` (an inbox registered before accounts, + or whose link isn't confirmed; the app links it with `inbox/register` + and retries). The row counts only once `confirmed`, meaning D1's + `account_inboxes` holds the same link, where moderation finds it: the + Worker passes an existing D1 link along with the registration, and + confirms a new one (D1's winner, so concurrent claims agree; the loser + gets 409 `conflict`) only after D1 wrote it. A registration that fails + between the two leaves the inbox `not_linked` until a retry gets + through. Rows from before the column (added in place as 0) count as + unconfirmed: their owner's first bearer owner op gets `not_linked`, and + the relink confirms it. Writer ops send the + token plus the writer-signed envelope, still the slot's capability; the + token's account is never compared with the slot or stored. Invite and + blob reads send the token plus today's body. Without the header it's a + legacy request, served byte for byte as before while `auth:legacy` is on, + and 426 `upgrade_required` once it's off, leaving ops included. One + exception outlives the switch: the iOS Notification Service Extension + fetches events too big for a push with an owner-signed `inbox/sync` + (it can't hold the session's rotating refresh token without racing the + app) and sends `x-buddies-client: notification-service`. With the switch + off, the Worker admits that one legacy request (`admitCaller`, as + `linkedOnly`), and the inbox DO checks the signature, `ts`, nonce, and + suspension as ever, then serves it only for an inbox with a **confirmed** + `owner_account` link, else 426. Any other op with the header, or a wrong + value, is 426 as before; the header changes nothing while the switch is + on or on a bearer request. Nothing logs it. The live + upgrade reaches the inbox DO with the token's account in the internal + `x-buddies-session-account` header, which the Worker always strips from + client requests (the DO trusts it). Suspension and both switches work as + before in both forms; `owner_account` needs no migration (created in + place at registration, `confirmed` added in place). - **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 @@ -272,8 +367,11 @@ Links. Clients are iOS and Android each with its sizing rationale. They only stop scripted abuse; size them from the heaviest real use with at least 10× headroom. Hits log `buddies: limit hit {op, limit}` (nothing else), so tune from Workers Logs. - - Per caller, in the Worker before any DO wakes: the client IP (IPv6 by - /64), never the target inbox. `BUDDIES_RATE_LIMITER` 120/min for + - Per caller, in the Worker before any DO wakes: for a bearer request the + session's account (`account:` + a SHA-256 of its id, so the limiter never + sees the id and people behind one NAT stop sharing a budget), else the + 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`, @@ -287,8 +385,9 @@ Links. Clients are iOS and Android the Durable Objects, and `stale` carries `serverTime` and `Date`. The Worker also refuses stale `ts` and badly signed `inbox/register` before any DO. A limiter outage lets requests through. - - `inbox/register`: 2,000 validly signed calls per caller per rolling day - (`BuddyRegistrationQuota`, hourly counts under a hashed caller key). + - `inbox/register`: 2,000 validly signed calls per caller (account or IP, + as above) per rolling day (`BuddyRegistrationQuota`, hourly counts under + a hashed caller key). - Per inbox, after the signature check: stored events per slot (10,000, 16 MiB) and per inbox (128 MiB). `event/put` past them is 429 `rate_limited`; nothing stored is dropped. `slot_usage` keeps the running @@ -377,15 +476,16 @@ Links. Clients are iOS and Android the frame itself arrives at once. - **Privacy**: every id travels in the body (for the live socket, in headers; never the URL). Never log or report bodies, headers, blobs, - ids, or tokens. APNs and FCM requests use the `fetch` captured before + ids, account ids, or tokens (access tokens included; Sentry drops + `Authorization` and `x-buddies-*`). APNs and FCM requests use the `fetch` captured before Sentry wraps the global, so device tokens in APNs URLs stay out of Sentry spans (FCM tokens travel only in request bodies). The dev worker's 100% Workers traces do record subrequest URLs, APNs included. -- **Not yet built** (required before any production rollout): App Attest on - `inbox/register` and `invite/*` (`attest` is accepted and ignored; Android - will use Play Integrity). `blob/put` isn't attested either; the plan is to - attest an inbox once per few days through a Buddies purpose added to - `src/attestation/purposes.ts` and have `blob/put` require a recent one, rather than attesting each upload. The protocol's durable push outbox (retries for +- **Not yet built** (required before any production rollout): bearer + requests are attested once, when their session opens (App Attest or Play + Integrity), so nothing per request is needed for them; legacy requests + stay unattested until the legacy switch is off (`attest` is accepted and + ignored), which is the remaining step before production. The protocol's durable push outbox (retries for 6 h, outcome history, deferred instead of dropped alerts inside the 60 s spacing, `apns-expiration`) is specified but not built either; today each push gets one immediate attempt plus one retry. @@ -417,16 +517,35 @@ which every device derives from the User's account key. Accounts let us suspend someone for abuse; later phases add moving between iOS and Android. Code in `src/accounts/`, schema in `src/accounts/schema.ts` (Drizzle on D1). -- `POST /accounts/v1/account/register`: self-signed under the +**The account is the source of truth for who a User is** (witness-work ADR +0027). Every user-scoped route identifies the caller by the `accountId` of an +attested session's bearer token, through the `requireAccount()` middleware, +and keys its authorization, metering, limits, and audit records on that id. +Don't add a route that trusts a client-sent install id, RevenueCat id, or +inbox id to say who the User is, or that builds its own signed envelope or +per-request attestation: attestation happens once, when a session opens +(`POST /accounts/v1/sessions`). Feature ids (inboxes, backup slots, imports) +belong to an account. The RevenueCat identity is being linked to the account +so Supporter status is kept in step from RevenueCat webhooks and answered from +the account, not from an id the app sends. The pre-session ways of +authenticating survive only behind the legacy switch (`auth:legacy`), for app +versions from before sessions. + +- `GET /accounts/v1/account`: Bearer, read-only. Returns the token's + account (`accountId`, `accountPub`, `status`, `createdAt`, `inboxIds`, and + `statusChanges` without their reasons) for Settings → Account and the + developer tools. Counts against the Buddies read edge tier. +- `POST /accounts/v1/account/register` (legacy): self-signed under the `ww-account/v1` context; idempotent per key. Counts against the Buddies - register edge tier and the per-caller daily quota. -- `POST /accounts/v1/account/info`: the same self-signed envelope (op - `account/info`), read-only. Returns the key's own account (`accountId`, - `accountPub`, `status`, `createdAt`, `inboxIds`, and `statusChanges` - without their reasons) for the app's developer tools; an unregistered key - is 404 `unknown_account`. Counts against the Buddies read edge tier. -- `inbox/register` takes an optional `account: { accountId, signature }`, - the account key's signature over the inbox id and owner key. The link + register edge tier and the per-caller daily quota. Replaced by opening a + session; 426 `upgrade_required` once the legacy switch is off. +- `POST /accounts/v1/account/info` (legacy): `GET /account`'s answer for + builds before it. A bearer request with no body reads the token's account; + while the legacy switch is on it also takes the old self-signed envelope + (op `account/info`), where an unregistered key is 404 `unknown_account`. +- `inbox/register` takes an optional `account: { accountId, signature }` + (required in a bearer request, for the token's account), the account + key's signature over the inbox id and owner key. The link (`account_inboxes`) is written only after the inbox accepted the owner-signed registration, so it takes both keys (Option A in the ADR). - A suspended account's inboxes refuse every owner op except `inbox/delete` @@ -434,7 +553,7 @@ Code in `src/accounts/`, schema in `src/accounts/schema.ts` (Drizzle on D1). 4003. The database is the authority (registration checks it); each inbox keeps a copy so its hot path never reads D1. -Without the `ACCOUNTS_DB` binding, accounts are off: `account/register` +Without the `ACCOUNTS_DB` binding, accounts are off: every accounts route answers `disabled` and proofs are ignored. Prod uses `ww-accounts`, dev `ww-accounts-dev`. @@ -462,21 +581,207 @@ curl -sX POST "$BASE/admin/accounts/status" -H "x-ww-admin-token: $TOKEN" \ `/status` records every change in `account_status_events` and updates each of the account's inboxes. If `inboxesFailed` is above 0, send the same -request again. +request again. Routes that refuse suspended accounts through +`requireAccount({ requireActive: true })` see the change within 30 seconds +(per-isolate cache). + +### Sessions and bearer tokens + +Attest once, then bearer tokens (witness-work ADR 0027; "Sessions" in its +`docs/accounts-protocol.md`). Code in `src/accounts/session.ts` (handlers), +`attestation.ts`, `tokens.ts`, `sessionStore.ts`, and `auth.ts`. + +| Route | What | +| --- | --- | +| `POST /accounts/v1/sessions` | Open a session: self-signed, attested; 201 | +| `POST /accounts/v1/sessions/refresh` | `{ refreshToken }` → new token pair | +| `DELETE /accounts/v1/sessions/current` | Bearer; end the token's session | +| `GET /accounts/v1/account` | Bearer; the token's account | + +- **Attesting**: `POST /attestation/v1/challenge` with purpose + `account-session`, App Attest v2 `assert` (the install's key, bound + through `/attestation/v1/register`) or Play Integrity v1, with + `contentHash` = `requestHash` = `hex(SHA-256(UTF-8("ww-account/v1/session\n" + + accountPub)))`. The answer rides in the open request's `attestation`. +- **Open**: an envelope (`{ payload, signature }`, op `session/open`) + self-signed by the account key like `account/register`, carrying that + attestation. Its hashes must name the envelope's own key and purpose (400 + `bad_request` otherwise); `verifyAttestation` checks the rest. Creates the + account if needed, stores a session, and answers 201 with `Location: + /accounts/v1/sessions/current` and `{ ok, accountId, status, sessionId, + accessToken, accessExpiresAt, refreshToken, sessionExpiresAt }`. A refused + attestation is 403 `attestation_failed` with the attestation routes' + `reason` and `action`. Optional sealed `device` label (≤ 512 chars, else + `too_large`), stored opaque. An eleventh live session ends the account's + oldest. Limits: the register edge tier, and 20 a rolling day per account + key (`BUDDY_REGISTRATION_QUOTA` under a hashed `session-open` name), + counted before the attestation. +- **Refresh**: plain JSON `{ refreshToken }`. A refresh token is + `..` (b64u, 16, 32, and 32 bytes; opaque to the + app), so the session is found by id. The MAC is HMAC-SHA256 over + `.` under its own key from `ACCOUNT_TOKEN_SECRET` (or + the previous secret), proving the worker issued it: the session id is no + secret (every access token's `sid`), so a made-up token for it must not + end the session. A malformed token, or one whose MAC doesn't verify + (two-part tokens from before the MAC included), is 401 before any D1 + read and ends nothing. Each refresh replaces the token with one derived + from it (HMAC under another key from the secret), with a compare-and-swap + on the hash it read, so the row rotates once and every refresh presenting + the same token gets the same next one. The one just replaced gets it + again for 60 seconds from its rotation (a lost response, or two refreshes + at once; retries don't extend it and write nothing), so a device's double + refresh can't strand it with a dead token. Presenting it later, or any older token of a + live session that carries a valid MAC, ends the session. An unknown, + ended, or expired session is 401 `unauthorized`; a refresh that lost its + rotation race to a session that has since moved on again is 401 without + ending it. Sessions last 90 days from opening and refreshing never + extends them. Read edge tier. +- **Close**: `DELETE /sessions/current`, Bearer, no body; ends the token's + own session, idempotent. Read tier. Session ids never go in URLs. +- **Storage**: D1 `account_sessions` (migration + `migrations/accounts/0001_account-sessions.sql`): id, account, platform + (`ios`, `android`, `dev`), sealed device label, SHA-256 of the current + and previous refresh token, and times. No install id, App Attest key, + `accountPub`, or IP. The hourly cron deletes sessions a week after they + were revoked or expired. +- **Access tokens**: HS256 JWTs, 15 minutes (never past the session's end), + claims `sub` (account), `sid` (session), `platform`, `iss` + `ww-api/`, `aud` `ww-account`, `iat`, `exp`. + Nothing is stored; a token is valid until it expires, so closing a + session stops its refresh token at once and its access token within 15 + minutes. Without a usable `ACCOUNT_TOKEN_SECRET` (at least 32 characters) + the session routes are 503 `disabled` and no bearer token passes. +- **Dev bypass**: on the dev worker, `x-ww-dev-bypass` with + `NOTES_IMPORT_DEV_BYPASS_TOKEN` stands in for the attestation (platform + `dev`; `src/devBypass.ts`). Production ignores it (and fails loudly if the + token is set there), and a `dev` token never verifies there. + +`ACCOUNT_TOKEN_SECRET` takes a different value per worker; keep a copy in +1Password's Agents vault, since a rotation needs the old value: + +```bash +openssl rand -base64 32 | wrangler secret put ACCOUNT_TOKEN_SECRET # add --env dev for dev +# Rotation: move the current value to the verify-only slot, then set a new +# one. Its kid is derived from the secret, so old tokens keep verifying. +wrangler secret put ACCOUNT_TOKEN_SECRET_PREVIOUS # the old value +openssl rand -base64 32 | wrangler secret put ACCOUNT_TOKEN_SECRET +# Routine rotation: after 90 days (the longest a session lasts). After a +# leak: at once. +wrangler secret delete ACCOUNT_TOKEN_SECRET_PREVIOUS +``` + +The previous slot keeps access tokens verifying for their 15 minutes and, +because refresh tokens carry a MAC under the secret, keeps refresh tokens +issued under the old secret refreshing; each refresh signs its replacement +under the new one. Once the slot is gone, a device still holding an old +refresh token gets 401 and opens a new session (attesting again), so +deleting it before 90 days sends every device that hasn't refreshed since +the rotation through a new session, and skipping it sends every device. +After a leak that's what you want: the old secret could mint access tokens +and MACs. Local +`wrangler dev` reads `ACCOUNT_TOKEN_SECRET` from `.dev.vars` +(`.dev.vars.example`); the verify launcher and the test harness set their +own. + +Building a route on accounts (`src/accounts/auth.ts`): + +- `requireAccount(options?)`: Hono middleware for bearer-only routes. Sets + `c.var.account = { accountId, sessionId, platform }` (read it with + `accountOf(c)`). `{ requireActive: true }` also refuses a suspended + account with 403 `suspended` (one D1 read, cached 30 s per isolate). +- `authenticateAccount(c, options?)`: the same check as a call, for routes + that order their own gates (`GET /account` counts its edge tier first). + Returns `{ ok: true, account }` or `{ ok: false, error, response }` + (`unauthorized`, `suspended`, `disabled`, `internal`), so routes with + their own error format can map `error`. +- `bearerOrLegacy(c, { bearer, legacy }, options?)`: for converted routes. + A request with an `Authorization` header is a bearer request, always (a + bad token is 401, never a fallback to the envelope). Without one, `legacy` + runs while the legacy switch is on, else 426. +- `isLegacyAuthEnabled(env)` and `refuseLegacyAuth(env)` + (`src/accounts/legacySwitch.ts`) for legacy-only paths. +- `unauthorized` (401, always with `WWW-Authenticate: Bearer + error="invalid_token"`), `upgrade_required` (426, no `Retry-After`), and + `attestation_failed` (403) are in the shared Buddies codes + (`buddiesErrorResponse`). +- Name new routes as resources (REST), with ids out of URLs; document the + bearer requirement with `security: [{ bearerAuth: [] }]` in the route's + `openapi.ts`. +- Tests: `createHarness()` turns sessions on by default, `openTestSession()` + in `src/test/accounts.ts` opens one with the bypass, and `requestJson()` + sends any method. + +**Privacy**: never log or report access or refresh tokens, account ids, +session ids, or device labels. Sentry drops `Authorization`. Drizzle's +query errors quote the query's parameters (ids, token hashes), so report a +caught error with `captureFailure(error, operation)` (`src/sentry.ts`), +never `Sentry.captureException`: a database error, or one it caused, goes +out as a `DatabaseError` naming the operation only. As a backstop, +`beforeSend` cuts `Failed query:`, `params:`, and `D1_…:` text from every +string and drops the exceptions chained under it. + +### Updates and the legacy switch + +`GET /app/v1/config` (no auth, `src/appConfig.ts`) returns `{ ok, +minAppVersion? }`. The value comes from KV `app:min-version` +(`{"minVersion":"x.y.z"}`), then the `APP_MIN_VERSION` var, else it's +absent. Invalid values are logged and ignored, and the KV read is +edge-cached for 60 seconds. It has its own per-IP bucket (`app`). App +versions from sessions on read it at launch and on returning to the +foreground, and show Update Required below it. + +KV `auth:legacy` (`"off"` or `"on"`; absent falls back to the `AUTH_LEGACY` +var, then on; edge-cached 60 s). While it's on, converted routes also take +the way they authenticated before sessions. Off, those requests get 426 +`upgrade_required`, `account/register` included, except the iOS +Notification Service Extension's owner-signed Buddies `inbox/sync` +(`x-buddies-client: notification-service`) for an inbox with a confirmed +account link (see Buddies → Authentication). + +#### Turning off backward compatibility + +Do these in order. Each step takes up to 60 seconds to reach every colo. + +Pick `X.Y.Z` as the first version that opens sessions **and** whose +Notification Service Extension sends `x-buddies-client: notification-service` +on its `inbox/sync`. An older extension's fetch gets 426, so its named +Buddies alerts for events too big for the push fall back to the template. + +```bash +# 1. Raise the app-wide floor to the first version that opens sessions, so +# current apps show Update Required before anything is refused. +wrangler kv key put --binding NOTES_KV app:min-version '{"minVersion":"X.Y.Z"}' +# 2. Raise Notes Import's floor too: versions before sessions never read +# app:min-version, but their composer honors this one. +wrangler kv key put --binding NOTES_KV notes-import:min-version '{"minVersion":"X.Y.Z"}' +# 3. Refuse the old ways of authenticating (426 upgrade_required). +wrangler kv key put --binding NOTES_KV auth:legacy off +``` + +Add `--env dev` to each for the dev worker. To undo, take them back in +reverse: `wrangler kv key delete --binding NOTES_KV auth:legacy` (back to +`AUTH_LEGACY`, on), then put the previous floors back, or delete +`app:min-version` and `notes-import:min-version` to remove them. ### Handoff and backup Moving the account to another phone and backing it up (witness-work ADR 0026; "Handoff", "Backup", and "Recovery key" in its `docs/accounts-protocol.md`). -Shared helpers for the self-signed ops are in `src/accounts/signed.ts`. +Shared helpers for the self-signed ops, and `accountCall` (bearer or legacy +envelope, then the account), are in `src/accounts/signed.ts`. Both stay +`POST /accounts/v1/{op}` like Buddies, so channel and slot ids travel in +bodies and headers, never URLs. - **Handoff** (Set Up Another Device): `POST /accounts/v1/handoff/open|claim| status|send|receive|done|cancel`, code in `src/accounts/handoff.ts`. The old - phone's ops (`open`, `status`, `send`, and `cancel` with `payload`/`signature`) are signed - by the account key like `account/register` and need a registered account - (`unknown_account` otherwise; a suspended one may still hand off). The new - phone's (`claim`, `receive`, `done`, and `cancel` without `payload`) are plain JSON - carrying `claimToken`. One `AccountHandoff` SQLite DO per `channelId` + phone's ops (`open`, `status`, `send`, and `cancel` without `claimToken`) + are Bearer with their fields as plain JSON; while the legacy switch is on + they also take envelopes (`payload`/`signature`) signed by the account key + like `account/register`, which need a registered account + (`unknown_account` otherwise). A suspended account may still hand off. The + new phone's (`claim`, `receive`, `done`, and `cancel` with `claimToken`) + are plain JSON carrying `claimToken`, with or without an `Authorization` + header. One `AccountHandoff` SQLite DO per `channelId` (binding `ACCOUNT_HANDOFF`, migration `v7`, `src/accounts/handoffDO.ts`) holds the opening account, `verifier = b64u(SHA-256(claimToken))`, the new phone's temporary key, and the sealed hello (≤ 2,048 chars) and key @@ -492,9 +797,14 @@ Shared helpers for the self-signed ops are in `src/accounts/signed.ts`. `handoff-open` name; `admit` takes the limit), and every other op the read tier. - **Backup**: `POST /accounts/v1/backup/put|list|get|delete`, code in - `src/accounts/backup.ts`. All four are signed; `put` sends its envelope in - `x-ww-payload`/`x-ww-signature` (Sentry drops both, and the legacy - `x-ww-p`/`x-ww-s`) and the blob as the raw body. R2 + `src/accounts/backup.ts`. `put`, `list`, and `get` are Bearer. `put` + sends the blob as the raw body, with `x-ww-slot-id`, `x-ww-sha256`, and + `Content-Length` as its size. `delete` is Bearer **and** the account key's + envelope; its key must be the token's account (403 `forbidden`), so a + leaked token can't delete backups. While the legacy switch is on, all four + also take the envelope alone; the old `put` sends it in + `x-ww-payload`/`x-ww-signature` (or the short `x-ww-p`/`x-ww-s`). Sentry + drops all four envelope headers and `x-ww-slot-id`. R2 bucket `ACCOUNT_BACKUPS` (`ww-account-backups` prod, `ww-account-backups-dev` dev), one object per device slot at `v1//` with `customMetadata.uploadedAt` (epoch ms). `put` @@ -557,18 +867,21 @@ it commented out. | Event | When | Properties beyond the shared ones | | --- | --- | --- | -| `api_notes_import_started` | An attested import starts a fresh model run (kickoff or legacy; not reconnects) | `notes_chars`, `new_content`, `imports_remaining` | +| `api_notes_import_started` | An authenticated import starts a fresh model run (kickoff or legacy; not reconnects) | `notes_chars`, `new_content`, `imports_remaining` | | `api_notes_import_finished` | Once per started run | `outcome` (`success`, `model_error`, `cancelled`, `interrupted`), `duration_ms`, `notes_chars`; on success: `empty`, `empty_charged`, record counts (`contacts`, `visits`, `time_entries`, `categories`, `warnings`, `publisher_detected`), `imports_remaining`, `model`, `provider`, `input_tokens`, `output_tokens`, `reasoning_tokens` | | `api_notes_import_limit_reached` | An authenticated request is refused by an allowance or the concurrency cap | `limit` (`imports`, `refinements`, `active_cap`) | -Every event carries `platform` (`ios`, `android`, `dev` for the bypass), -`transport` (`stream`, `legacy`), `refinement`, `supporter`, `has_account`, and -`environment`. The internal-TestFlight Beta app talks to prod, so its imports +Every event carries `platform` (`ios`, `android`, `dev`: the session's +platform on bearer requests, else App Attest, Play Integrity, or the bypass +header), `transport` (`stream`, `legacy`), `refinement`, `supporter`, +`has_account` (true on every bearer request), and `environment`. The internal-TestFlight Beta app talks to prod, so its imports are counted as `ios`. - **Identity**: `distinct_id` is `ww_` plus a truncated SHA-256 of the meter id - (account id, else install uuid), so unique users, DAU/WAU/MAU and retention - work without the id itself reaching PostHog. It can't be joined to the app's + (the session's account id on bearer requests; on legacy ones the shared + account id, else the install uuid), so unique users, DAU/WAU/MAU and + retention work without the id itself reaching PostHog. A User's bearer + imports get a new `distinct_id`, not their legacy one. It can't be joined to the app's own anonymous PostHog ids. Events set `$process_person_profile: false` and `$geoip_disable: true`. - **Privacy**: properties are structural only. Never add notes text, model @@ -725,7 +1038,7 @@ on raw `wrangler deploy`. Each import runs in a `NotesImportRun` SQLite Durable Object (`NotesImportIndex` per user holds the concurrency cap) via AI SDK v6 `streamText` + -`Output.object`: attested kickoff (`POST /notes-import/kickoff`) → SSE progress +`Output.object`: authenticated kickoff (`POST /notes-import/kickoff`) → SSE progress (`GET /notes-import/:id/events`) → result snapshot (`/result`). The legacy `POST /notes-import` remains as a fallback. Model, providers, reasoning effort, and the output-token budget are `NOTES_IMPORT_*` vars in `wrangler.toml` diff --git a/README.md b/README.md index f2d3406..c58dbb7 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,10 @@ to read: - **Standard HTTP.** The right methods and status codes, JSON bodies, one error envelope (`{ "ok": false, "error", "code" }`), and `Retry-After` on every 429 and 503. Where a route departs from REST on purpose, its docs say - why. For example, the Buddies and accounts relays use `POST /{op}` so ids - stay out of URLs, which production logs. + why. For example, the Buddies relay and the accounts handoff and backup + ops use `POST /{op}` so ids stay out of URLs, which production logs, while + account sessions are plain resources (`POST /accounts/v1/sessions`, + `DELETE /accounts/v1/sessions/current`). - **Documented.** Every route and field is described in `/docs`, generated from the routes, so you can call the API without reading the source. @@ -163,6 +165,39 @@ live WebSocket), send the same two values in `x-buddies-payload` / lists its decoded `payload` fields. The wire contract is [`docs/buddies-protocol.md`](docs/buddies-protocol.md). +App versions with account sessions sign far less: they sign only to open a +session (and to delete backups), and call every other accounts route with +`Authorization: Bearer ` (below). + +### Accounts and sessions (`/accounts/v1/*`) + +Anonymous accounts (a random id and a public key). An app proves it's +WitnessWork once per session and then calls with short-lived bearer tokens: + +- **`POST /accounts/v1/sessions`** opens a session: an envelope self-signed + by the account key (op `session/open`) carrying an attestation from + `POST /attestation/v1/challenge` (purpose `account-session`, both hashes + `hex(SHA-256("ww-account/v1/session\n" + accountPub))`). Answers 201 with + an access token (a 15-minute JWT), a refresh token, and `Location: + /accounts/v1/sessions/current`. Sessions last 90 days. +- **`POST /accounts/v1/sessions/refresh`** trades `{ refreshToken }` for a + new pair; each refresh token works once (plus a 60-second grace for a lost + response, which gets the same new refresh token again), and presenting an + older one ends the session. Refresh tokens are opaque strings + (`..`); one the worker didn't issue is 401 and + never ends a session. +- **`DELETE /accounts/v1/sessions/current`** ends the token's session. +- **`GET /accounts/v1/account`** reads the token's account. +- Handoff (`/accounts/v1/handoff/*`) and backup (`/accounts/v1/backup/*`) + take the bearer token too, as `POST /{op}` with ids in bodies. + +Older app versions authenticate the pre-session way (signed envelopes, +`account/register`, `account/info`) while the legacy switch (KV +`auth:legacy`) is on; once it's off they get 426 `upgrade_required`. +`GET /app/v1/config` (no auth) tells the app its minimum version, so it can +show Update Required first. The runbook is in `AGENTS.md`, "Turning off +backward compatibility". + ### `/autocomplete` Proxies to HERE Autocomplete API. @@ -307,30 +342,92 @@ registration, the verify probe) lives under `/attestation/v1/` (below); the old `POST /notes-import/challenge`, `/attest`, and `/verify` still serve the same handlers for deployed apps. -- **`POST /notes-import/kickoff`** starts a streaming run and returns its handle, - capability token, and strict `credits` preview; `GET .../events` / `.../result` - carry the terminal result and authoritative `credits` snapshot. +- **`POST /notes-import/kickoff`** starts a streaming run and returns its handle + (and, for legacy requests, a capability token) and strict `credits` preview; + `GET .../events` / `.../result` carry the terminal result and authoritative + `credits` snapshot. - **`POST /notes-import/transcribe`** (Import from Paper) reads one page image (base64 JPEG or PNG, ≤ `NOTES_IMPORT_TRANSCRIBE_MAX_BYTES`) into plain text with `NOTES_IMPORT_TRANSCRIBE_MODEL` on the same ZDR allowlist, and returns - `{ text, truncated, pagesRemaining }`. Attested with App Attest v2 or Play - Integrity under purpose `notes-import-transcribe`: the content hash is the - SHA-256 of the base64 string, and the request hash covers - `{ imageHash, mimeType }`. The image is never stored or logged. Pages spend - no credit (the app sends the edited text through kickoff), but a free user - with no imports left is refused, and each identity gets + `{ text, truncated, pagesRemaining }`. Legacy requests are attested with + App Attest v2 or Play Integrity under purpose `notes-import-transcribe`: the + content hash is the SHA-256 of the base64 string, and the request hash + covers `{ imageHash, mimeType }`. The image is never stored or logged. + Pages spend no credit (the app sends the edited text through kickoff), but a + free user with no imports left is refused, and each identity gets `NOTES_IMPORT_TRANSCRIBE_PAGES_PER_DAY` pages per rolling day (`…_SUPPORTER` for Supporters); over it → 429 `transcribe_limit` with `Retry-After`. `GET /notes-import/status` advertises `capabilities.transcribe`. - **`POST /notes-import`** is the synchronous fallback under identical rules. +- Both import routes refuse empty or whitespace-only `notesText` with 400 + `bad_request`, bearer and legacy alike, before attestation, the allowance, + or the model. - **`GET /notes-import/status`** is public and supplies a fresh allowance schedule only when availability is confirmed. -Auth is enforced by Apple **App Attest** on every inference kickoff. A dev worker +#### Who's calling: account sessions (bearer) or legacy + +App versions with account sessions call every Notes Import route with +`Authorization: Bearer ` (from `POST /accounts/v1/sessions`, see +[Accounts and sessions](#accounts-and-sessions-accountsv1)). The session +attested once, so these requests carry no attestation fields and no hashes, and +the token's account is the meter: allowances, replays, the concurrency cap, and +Import from Paper's page limit all key on it. A request with an `Authorization` +header is always a bearer request; a bad token is never a fallback to the old +way. Older app versions send no `Authorization` header and authenticate per +request (below) while the legacy switch (KV `auth:legacy`) is on. + +**Bearer contract.** JSON bodies; any field not listed is ignored (`uuid`, +`accountId`, `contentHash`, `requestHash`, and every attestation field +included). + +| Route | Body | 200 | +| --- | --- | --- | +| `POST /notes-import/kickoff` | `{ notesText, context, refinement?, revenueCatAppUserId? }` | `{ importId, refinement, credits }` (no `subscribeToken`) | +| `POST /notes-import` (synchronous fallback) | the same | `{ result, contentHash, refinement, emptyCharged, credits }` | +| `POST /notes-import/transcribe` | `{ image, mimeType, revenueCatAppUserId? }` | `{ ok, text, truncated, pagesRemaining }` | +| `GET /notes-import/{importId}/events` | none; `Last-Event-ID` header to resume | the SSE stream | +| `GET /notes-import/{importId}/result` | none | `{ status, payload?, error? }` | +| `POST /notes-import/{importId}/cancel` | none | `{ status, payload?, error? }` | +| `POST /notes-import/{importId}/destroy` | none | `{ ok: true }` | + +- `revenueCatAppUserId` (optional, 8-64 of `A-Z a-z 0-9 _ -`): the app's + RevenueCat app user id. The server asks RevenueCat whether it's a Supporter, + at kickoff and again when the run settles; absent means not a Supporter. + It's a stopgap until RevenueCat is linked to the account, when the server + will use the account's own id and ignore this field. +- **Run routes** take the access token instead of `?token=` (a bearer request + never reads it) and serve only imports the token's account kicked off. + Another account's import, an unknown id, and one deleted or past its 1-hour + retention are all 404 `not_found`, so a response never says whether an + import exists. A destroy of an import already deleted is that same 404. +- **Errors** keep Notes Import's shape, `{ ok: false, error: , code }`, + on both kinds of request: 401 `unauthorized` (missing, expired, or invalid + token, with `WWW-Authenticate: Bearer error="invalid_token"`: refresh once + and retry), 403 `suspended` on the metered routes (a suspended account can + still read and delete its runs), 404 `not_found` (run routes), 503 `disabled` + (sessions off on this worker), and every existing code (`limit_reached`, + `refinement_limit`, `active_cap`, `transcribe_limit`, `unavailable`, + `too_large`, `bad_request`, `model_error`, `server_error`). +- **Allowances start fresh.** Usage recorded under an install id or shared + account id doesn't carry over to the account: each account gets a new + window (decided in witness-work ADR 0027). +- **Dev sessions** (opened with the dev bypass on the dev worker) get the + bypass's unlimited allowances and never Supporter status; production never + verifies a `dev` token. +- `GET /notes-import/status` stays public and the same for everyone; it + ignores `Authorization`. The account's balance is in kickoff's `credits`. + +**Legacy** (no `Authorization` header). Inference is attested per request: +App Attest on iOS, Play Integrity on Android, with the body fields in `/docs`. +Run routes take the kickoff's `subscribeToken` as `?token=`. Once the legacy +switch is off, every legacy Notes Import request is 426 `upgrade_required` in +Notes Import's shape (message in `error`), with no `Retry-After`. A dev worker may set `NOTES_IMPORT_DEV_BYPASS_TOKEN` so the simulator can send `x-ww-dev-bypass: ` to skip attestation. It overrides both effective usage allowances to unlimited while keeping real Supporter status false and retaining hash/refinement accounting. Existing active-concurrency behavior is unchanged. +On a bearer request the header is ignored: the session's platform decides. **Never set this token in production.** **One-time setup** (beyond HERE/Sentry): @@ -371,6 +468,10 @@ pnpm run admin:reset-usage # production pnpm run admin:reset-usage --dev # development ``` +The meter id is the account id for app versions with account sessions (find it +from a Buddies inbox with `POST /admin/accounts/lookup`), and the shared +account id or install uuid for older ones. + The secret route clears only the aggregate import window and Empty Import rows; permanent hash replay and refinement records survive. Missing configuration or bad auth returns 404. Data Studio remains the manual emergency fallback. Never @@ -447,6 +548,10 @@ Every JSON route answers errors with one envelope (`src/errors.ts`): `limit_reached` and `refinement_limit`, Buddies' `limit`). - Routes add their own fields beside these (`reason`, `action`, `credits`, `serverTime`, HERE's own error fields on the place-search proxy). +- A bearer route's missing, expired, or invalid token is 401 `unauthorized` + with `WWW-Authenticate: Bearer error="invalid_token"`: refresh once and + retry. A request authenticated the pre-session way after the legacy switch + is off is 426 `upgrade_required`, without `Retry-After`. - `/geocode` and `/autocomplete` pass HERE's status through; a HERE error body keeps its fields and gains the envelope (`bad_request`, `not_found`, `rate_limited`, `upstream_error`). HERE unreachable or answering non-JSON is @@ -459,9 +564,10 @@ Every JSON route answers errors with one envelope (`src/errors.ts`): Per-IP limits (`RATE_LIMITER`, 60/min) count each route family in its own bucket: `places` (`/geocode`, `/autocomplete`), `notes-import`, -`attestation`, `route-planning`, `admin` (`src/rateLimit.ts`). The old -`/notes-import/*` attestation paths still count in `notes-import`. Refusals are 429 -`rate_limited` with `Retry-After: 60`. +`attestation`, `route-planning`, `admin`, and `app` (`/app/v1/*`) +(`src/rateLimit.ts`). The old `/notes-import/*` attestation paths still +count in `notes-import`. Refusals are 429 `rate_limited` with +`Retry-After: 60`. Buddies and accounts have their own per-caller tiers. Unexpected errors are 500 `server_error` and are reported to Sentry. diff --git a/docs/buddies-protocol.md b/docs/buddies-protocol.md index 1fb4907..bd512f9 100644 --- a/docs/buddies-protocol.md +++ b/docs/buddies-protocol.md @@ -10,7 +10,7 @@ The relay is **blind**: it stores ciphertext, random ids, public keys, and push In: inbox registration, device/push registration, invite create → fetch → claim → confirm, Buddy Cards (Plans), removal from either side, delete-all, encrypted roster for multi-device restore, generic localized pushes, kill switch. -Deferred (must land before any production rollout): App Attest on `inbox/register` and `invite/*` (reserved `attest` field below; Android will use Play Integrity, as Notes Import does in ADR 0017), QR invites, safety codes, activity sharing. +Deferred (must land before any production rollout): retiring unattested legacy requests (the legacy switch; [bearer requests](#authentication-sessions) are attested once, when their session opens, with App Attest or Play Integrity; the reserved `attest` field below stays ignored), QR invites, safety codes, activity sharing. Since the MVP: shared Plans and Follow-up invitations, requests to join, and badges with reactions to them (Encourage; event kinds below), which need no relay changes; [named alerts](#named-alerts), whose pushes carry the event they're about; Android (FCM pushes, a `pushService` on `device/register`, and a Block Store root seed), with every pairing of iOS and Android devices; [photo blobs](#photo-blobs) for photos in Plan notes (`blob/put`, `blob/get`, `blob/delete`, and `capabilities` on `inbox/sync`). @@ -40,10 +40,31 @@ Since the MVP: shared Plans and Follow-up invitations, requests to join, and bad | Self-signed | `inbox/register` | `payload.ownerPub` (proof of possession) | | Owner | `inbox/sync`, `inbox/delete`, `device/register`, `device/unregister`, `slot/add`, `slot/remove`, `roster/put`, `invite/create`, `invite/delete`, `blob/delete`, `blob/put` (envelope in headers) | the `ownerPub` stored for `payload.inboxId` | | Writer | `card/put`, `event/put`, `slot/leave` | the `writerPub` stored for `(payload.inboxId, payload.slotId)` | -| Unsigned | `invite/fetch`, `invite/claim`, `blob/get` | none — capability is knowledge of `inviteId` / `claimSecret` / a blob's read `token`; rate-limited by IP | +| Unsigned | `invite/fetch`, `invite/claim`, `blob/get` | none — capability is knowledge of `inviteId` / `claimSecret` / a blob's read `token`; rate-limited per caller | Ed25519 public keys are 32 raw bytes, `b64u`-encoded (43 chars). +The table above is how a **legacy request** authenticates. App versions with account sessions send bearer requests instead (next section). + +### Authentication (sessions) + +Every op also takes `Authorization: Bearer ` from an attested account session ([ADR 0027](https://github.com/leviFrosty/witness-work/blob/main/docs/adr/0027-account-sessions-attest-once-then-bearer-tokens.md), [Sessions](https://github.com/leviFrosty/witness-work/blob/main/docs/accounts-protocol.md#sessions)). A request with that header is a **bearer request**: + +| Kind | Bearer request | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `inbox/register` | The token, plus the self-signed envelope above. The `account` proof is required (`bad_request` without it) and must name the token's account (`forbidden` otherwise), so the inbox is linked to it. | +| Owner ops | The token only. The body is the payload's fields as plain JSON, without `payload`, `signature`, `ts`, or `nonce` (an envelope is `bad_request`; stray `ts`/`nonce` fields are ignored). The inbox must be linked to the token's account: `forbidden` (403) otherwise, and `not_linked` (409) for an inbox registered before accounts or whose link the relay hasn't confirmed (a registration that failed part-way, or one from before confirmation), which the app links with `inbox/register` and retries. | +| `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 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. | + +- 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. +- A request without the header is a **legacy request**, served exactly as before while ww-api's legacy switch is on and refused with `426 upgrade_required` (no `Retry-After`) once it's off, `inbox/delete`, `slot/remove`, and `slot/leave` included ([Updates and the legacy switch](https://github.com/leviFrosty/witness-work/blob/main/docs/accounts-protocol.md#updates-and-the-legacy-switch)). +- **One exception: the iOS Notification Service Extension's fetch.** It reads events too big for a push ([Named alerts](#named-alerts)) and can't hold the session's rotating refresh token without racing the app, so it keeps sending the legacy owner-signed `inbox/sync` with the header `x-buddies-client: notification-service`. Once the switch is off, the relay still serves that one request for an inbox with a **confirmed** account link (its owner registered it on a session; see `not_linked`), checking the owner signature, `ts`, nonce, and suspension as ever. An inbox without a confirmed link gets `426 upgrade_required` (after the signature check), as does every other op with the header. While the switch is on, and on bearer requests, the header changes nothing. +- Suspension applies as before, by the inbox's copy of the account status, not by the token. The kill switch and the photos switch apply to both forms alike. +- **Per-caller limits** count a bearer request against its account (a hash of it, so the relay's limiter never holds the id), a legacy one against its client IP (IPv6 by /64). The budgets are the same. + ### Responses Success: HTTP 200 with `{ "ok": true, ... }`. Failure: `{ "ok": false, "error": "", "code": "" }` (`code` repeats `error`; older relays sent `error` alone) with: @@ -60,7 +81,10 @@ Success: HTTP 200 with `{ "ok": true, ... }`. Failure: `{ "ok": false, "error": | 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 | +| 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 | +| 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` | | 403 | `no_buddies` | A `blob/put` from an inbox with no buddies (no slots) | @@ -76,7 +100,7 @@ Payload fields listed are **in addition** to `ts` and `nonce` for signed ops. | Op | Payload | Response | Notes | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `inbox/register` | `inboxId`, `ownerPub`, `attest?` | `{ ok }` | Idempotent for the same `ownerPub`; `conflict` if different, even after the inbox was wiped. `attest` is reserved (ignored in MVP). | +| `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/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. | | `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. | @@ -117,11 +141,11 @@ Payload fields listed are **in addition** to `ts` and `nonce` for signed ops. 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. -- **Request:** `GET {BASE}/buddies/v1/inbox/live` with `Upgrade: websocket`. Owner-signed exactly like a POST owner op, with op `inbox/live` and payload `{ inboxId, ts, nonce }`, but the envelope travels in headers so no id reaches a URL: `x-buddies-payload` (the `payload` value) and `x-buddies-signature` (the `signature` value). Same stale and replay checks. Not a POST op (`POST /inbox/live` is 404). -- **Refusals (before upgrading):** kill switch off → `503 disabled`; too many requests from this caller → `429 rate_limited` (with `Retry-After`); not a WebSocket upgrade → `426 upgrade_required`; then the usual `bad_request`, `bad_signature`, `stale`, `replay`, `not_found`. A refused upgrade is retried with the usual reconnect backoff. +- **Request:** `GET {BASE}/buddies/v1/inbox/live` with `Upgrade: websocket`. A bearer request sends the token and `x-buddies-payload` = `b64u(JSON { inboxId })`, with the same account check as an owner op. A legacy request is owner-signed exactly like a POST owner op, with op `inbox/live` and payload `{ inboxId, ts, nonce }`, but the envelope travels in headers so no id reaches a URL: `x-buddies-payload` (the `payload` value) and `x-buddies-signature` (the `signature` value), with the same stale and replay checks. Not a POST op (`POST /inbox/live` is 404). +- **Refusals (before upgrading):** kill switch off → `503 disabled`; a bad token → `401 unauthorized`, or a legacy request with the legacy switch off → `426 upgrade_required`; too many requests from this caller → `429 rate_limited` (with `Retry-After`); not a WebSocket upgrade → `426 upgrade_required`; then the usual `bad_request`, `bad_signature`, `stale`, `replay`, `not_found`, and for a bearer upgrade `forbidden` and `not_linked`. A refused upgrade is retried with the usual reconnect backoff. - **Messages (server → app):** `{ "type": "hello", "seq": N }` right after the upgrade, where `N` is the inbox's current `seq` (what a full `inbox/sync` returns); then `{ "type": "changed", "seq": N }` after every committed write that changes what `inbox/sync` returns: `event/put` (new events only), `card/put`, `roster/put`, a delivered claim, and `slot/add`, `slot/remove`, `slot/leave` that changed a slot. No content, ever. Slot changes don't advance `seq`, so the app syncs on every `changed` and on a `hello` past its cursor. - **Keepalive:** the app sends `ping` about every 25 s; the relay answers `pong` without waking the inbox's Durable Object. No `pong` within 10 s means the connection is dead and the app reconnects. Anything else the app sends is ignored. -- **Closes:** `4001 "gone"` when the inbox is deleted or wiped for inactivity (the app syncs, which restores it); `4002 "replaced"` when an 11th socket opens, closing the oldest. The app reconnects with backoff (1 s → 60 s, jittered) while in the foreground and closes the socket when backgrounded. +- **Closes:** `4001 "gone"` when the inbox is deleted or wiped for inactivity (the app syncs, which restores it); `4002 "replaced"` when an 11th socket opens, closing the oldest; `4003 "suspended"` when the inbox's account is suspended. The app reconnects with backoff (1 s → 60 s, jittered) while in the foreground and closes the socket when backgrounded. - An open socket is not owner activity for the 180-day wipe. Turning the kill switch off refuses new sockets but doesn't close open ones. ### Buddy writes (writer) @@ -174,7 +198,8 @@ x-buddies-signature: - **Signature:** an owner op like any other, with op `blob/put`: Ed25519 by the owner key over the bytes `UTF-8("ww-buddies/v1\nblob/put\n") ‖ decoded(x-buddies-payload)`, `b64u` into `x-buddies-signature`. Same `ts` (±5 min, `stale`) and `nonce` (`replay`) rules. Because the signed `blobId` is the body's SHA-256, which the relay recomputes, the signature covers the bytes too. In the app this is `envelope('blob/put', ownerSeed, { inboxId, blobId, bytes, expiresAt, readTokenHash })`, sent as headers as `openLive` does. - **`bytes`:** the body's exact length, 1 to 1,048,576. Over that is `413 too_large` before the body is read. `Content-Length`, when sent, must equal `bytes` (over 1 MiB: `too_large`; otherwise different: `bad_request`). The relay stops reading one chunk past `bytes`: more than 1 MiB is `too_large`, any other length mismatch is `bad_request`. - **`expiresAt`:** integer ms with `now < expiresAt ≤ now + 90 days` (the relay's clock), else `bad_request`. The app keeps a photo until a month after its Plan ends, which covers a Plan 8 weeks out; for a later Plan it asks for 90 days and uploads the photo again as the Plan nears. -- **Checks, in order:** `disabled`, `photos_disabled`, the per-caller limit (`rate_limited`), envelope and payload shape (`bad_request`), signature shape (`bad_signature`), `stale`, `too_large`, the body (`too_large`, `bad_request`), `SHA-256(body) ≠ blobId` (`bad_request`), then in the inbox: unknown inbox (`not_found`), signature (`bad_signature`), `replay`, no slots (`no_buddies`: a photo is for buddies to read, so an inbox with none can't store one), the caps (`rate_limited`). +- **Bearer:** the token, and `x-buddies-payload` without `ts`, `nonce`, or `x-buddies-signature`; the account check (`forbidden`, `not_linked`) replaces the signature, `stale`, and `replay`. The body rules below are the same. +- **Checks, in order:** `disabled`, `photos_disabled`, the token (`unauthorized`) or the legacy switch (`upgrade_required`), the per-caller limit (`rate_limited`), envelope and payload shape (`bad_request`), signature shape (`bad_signature`), `stale`, `too_large`, the body (`too_large`, `bad_request`), `SHA-256(body) ≠ blobId` (`bad_request`), then in the inbox: unknown inbox (`not_found`), signature (`bad_signature`) or account (`not_linked`, `forbidden`), `replay`, no slots (`no_buddies`: a photo is for buddies to read, so an inbox with none can't store one), the caps (`rate_limited`). - **Idempotent per `blobId`.** A repeat keeps the first `readTokenHash` (a different one is ignored, so retries must reuse the token) and moves the expiry to the later of the two. The answer's `expiresAt` is the stored expiry. A repeat that doesn't move the expiry stores nothing and costs no upload; one that moves it rewrites the object and counts as an upload. - **Answer:** `200 { "ok": true, "expiresAt": }`. A `not_found` after the body was accepted means the blob (or the whole inbox) was deleted while it uploaded. If storing the object fails, the answer is a `500` and the blob's record stays, unreadable, for 10 minutes: a retry finishes the upload, and otherwise its expiry deletes the object in case it was stored anyway. @@ -202,16 +227,16 @@ Content-Type: application/json - **Deleted:** at `expiresAt` (unreadable from that instant, removed by the inbox's alarm), by `blob/delete`, by `inbox/delete`, and by the 180-day inactivity wipe, so a blob never outlives its sender's inbox. A wipe also deletes everything under the inbox's prefix (`v1//`), records or not. - **Leftovers:** while an inbox stores blobs, its alarm lists its prefix once a week and deletes objects no record knows (older than an hour, so an upload in flight is left alone), then once more a week after it stores none. As the last resort, the bucket's lifecycle rule deletes any object 91 days after its last write; a blob is never live longer than 90 days past its last write, so the rule only removes leftovers. `scripts/r2-lifecycle.mjs` creates the bucket and sets the rule on every deploy. - **Failed deletions** are retried from the alarm after a minute, then twice as long after each failure in a row, up to every 6 hours. -- **Caps per sender inbox:** 1 MiB per blob; 300 live blobs and 150 MiB of live blobs (until they expire or are deleted); 100 uploads (object writes) per rolling 24 h. Over a cap, `blob/put` is `429 rate_limited` with `Retry-After` until the soonest live blob expires (count and bytes) or the oldest upload leaves the rolling day. Per caller (client IP, IPv6 by /64): 60 `blob/put` and 600 `blob/get` a minute; `blob/delete` counts with the other signed ops. +- **Caps per sender inbox:** 1 MiB per blob; 300 live blobs and 150 MiB of live blobs (until they expire or are deleted); 100 uploads (object writes) per rolling 24 h. Over a cap, `blob/put` is `429 rate_limited` with `Retry-After` until the soonest live blob expires (count and bytes) or the oldest upload leaves the rolling day. Per caller (the session's account for a bearer request, else the client IP, IPv6 by /64): 60 `blob/put` and 600 `blob/get` a minute; `blob/delete` counts with the other signed ops. - **Switch:** photo blobs need Buddies on and their own switch, KV `buddies:photos`: `"on"` (or `"true"`) enables, any other value disables, and an absent key falls back to `BUDDIES_PHOTOS` (dev `"on"`, prod unset, so off). Off, all three ops answer `503 photos_disabled` (with `Retry-After: 60`) and `inbox/sync` says `capabilities.photos: false`; stored blobs stay until they expire, and `inbox/delete` still deletes them. The app offers photos only while its last sync said `photos: true`, and treats `photos_disabled` on a read as transient. -- **What the relay learns:** blob sizes and times, which inbox stored them, and which callers fetch them (by IP); never keys, plaintext, or which buddy a token was given to (every recipient of a note gets the same token). +- **What the relay learns:** blob sizes and times, which inbox stored them, and which callers fetch them (by IP, or for a bearer request, by account session, which it doesn't store); never keys, plaintext, or which buddy a token was given to (every recipient of a note gets the same token). - **Attestation:** not required, like every Buddies op today. ## Limits and retention 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 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` 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. | Item | Limit | | ------------------------------------------ | -------------------------------------------------------------------------------------- | @@ -230,7 +255,7 @@ Every op is also rate-limited per caller (the client IP, IPv6 by /64), never per | Inbox | Wiped after 180 days without an owner op | | Owner key hash of a wiped inbox | Kept, with no expiry (see below) | -Unsigned ops are rate-limited by client IP. +Unsigned ops are rate-limited per caller (account or client IP, as above). A wipe (180 days without an owner op, or `inbox/delete`) keeps one value: `b64u(SHA-256(UTF-8("ww-buddies/v1/inbox-owner\n" + ownerPub)))`, with `ownerPub` the owner's canonical `b64u` key. It is pseudonymous (linking it to anyone takes their public key), it is all that's kept, and it never expires, since an expiry would let another key take the `inboxId` over. Only that key can register the `inboxId` again; every other op treats the inbox as unknown, as before. The app derives `inboxId` and `ownerPub` from the same root seed, so an owner who comes back (after the inactivity wipe, or on another device) registers with the same key. Delete-all also deletes the seed, so later use of Buddies starts a new inbox; the old one keeps only the hash. @@ -261,7 +286,7 @@ Each device gets its alerts through the service it registered: APNs for iOS, Fir The device words each alert from its event, with keys that never leave it. The app does this in `src/features/buddies/lib/pushAlerts.ts` (Android, in its push task); on iOS the Notification Service Extension (`targets/notification-service`) mirrors it in Swift. -1. **The event:** from `ww.eventId` and `ww.blob`, or, without them, an owner-signed `inbox/sync` with `since: seq − 1`, keeping the event with that `seq`. It only reads; the app's own sync applies the event as usual. +1. **The event:** from `ww.eventId` and `ww.blob`, or, without them, an owner-signed `inbox/sync` with `since: seq − 1`, keeping the event with that `seq`. It only reads; the app's own sync applies the event as usual. On iOS the extension sends it with `x-buddies-client: notification-service`, so it keeps working after the legacy switch is off ([Authentication (sessions)](#authentication-sessions)). 2. **The sender:** an `invite.claimed` opens with the key of the open invite whose id is the `eventId` (AAD `ww-buddies/v1/invite-claim|{inviteId}`). Anything else opens with the incoming content key of the buddy it authenticates for (AAD `ww-buddies/v1/event|{inboxId}|{slotId}|{eventId}`), trying each buddy (active or awaiting confirmation) in turn; a fetched event names its slot, so only that buddy is tried. 3. **The words:** who, by the name this device shows (the User's nickname for them, else their own name), and what: diff --git a/docs/notes-import-streaming-durable-objects.md b/docs/notes-import-streaming-durable-objects.md index e3fbe9d..9f1787a 100644 --- a/docs/notes-import-streaming-durable-objects.md +++ b/docs/notes-import-streaming-durable-objects.md @@ -150,6 +150,11 @@ heartbeat). - *Retention/cleanup* → alarm, `resultRetentionSeconds` (default 3600). - *Caps per tier* → `activeImportCap` (2) / `activeImportCapSupporter` (5), enforced in the index DO; kickoff returns `409`/`active_cap` at the cap. + The legacy synchronous `POST /notes-import` takes a slot per request + (`legacy:`, identical requests included), released in a `finally` + and aged out after 15 minutes in case that never runs. Its model call is + aborted at 10 minutes (`SYNC_IMPORT_DEADLINE_MS`, `502` `model_error`, + nothing charged), so a request still running never outlives its slot. - *Idempotency* → `importId = imp_` → reconnect lands on the same run; a refinement is a distinct run. - *Rollout* → legacy `POST /notes-import` retained as a fallback. diff --git a/migrations/accounts/0001_account-sessions.sql b/migrations/accounts/0001_account-sessions.sql new file mode 100644 index 0000000..cc860b1 --- /dev/null +++ b/migrations/accounts/0001_account-sessions.sql @@ -0,0 +1,17 @@ +CREATE TABLE `account_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `platform` text NOT NULL, + `device` text, + `refresh_hash` text NOT NULL, + `previous_refresh_hash` text, + `rotated_at` integer, + `created_at` integer NOT NULL, + `expires_at` integer NOT NULL, + `revoked_at` integer, + FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `account_sessions_refresh_hash_unique` ON `account_sessions` (`refresh_hash`);--> statement-breakpoint +CREATE INDEX `account_sessions_account_idx` ON `account_sessions` (`account_id`);--> statement-breakpoint +CREATE INDEX `account_sessions_previous_refresh_idx` ON `account_sessions` (`previous_refresh_hash`); \ No newline at end of file diff --git a/migrations/accounts/meta/0001_snapshot.json b/migrations/accounts/meta/0001_snapshot.json new file mode 100644 index 0000000..ce6bf8e --- /dev/null +++ b/migrations/accounts/meta/0001_snapshot.json @@ -0,0 +1,301 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a22c5681-4eed-4c5c-b307-0b9ae5991125", + "prevId": "2d4f5a24-ee93-40ea-9e89-626eddbb7984", + "tables": { + "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 + } + }, + "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_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 + } + }, + "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 + } + }, + "indexes": { + "accounts_account_pub_unique": { + "name": "accounts_account_pub_unique", + "columns": [ + "account_pub" + ], + "isUnique": true + } + }, + "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 7cb5e46..b970d4c 100644 --- a/migrations/accounts/meta/_journal.json +++ b/migrations/accounts/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1791836804119, "tag": "0000_accounts", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1792123450382, + "tag": "0001_account-sessions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/verify/buddies-wire.mjs b/scripts/verify/buddies-wire.mjs index ce6023b..39a6188 100644 --- a/scripts/verify/buddies-wire.mjs +++ b/scripts/verify/buddies-wire.mjs @@ -45,6 +45,20 @@ export const WIRE_EDGE_LIMITS = { blobGet: 600, } +/** Owner ops: a bearer request sends their fields as plain JSON (no envelope). */ +export const OWNER_OPS = [ + 'inbox/sync', + 'inbox/delete', + 'device/register', + 'device/unregister', + 'slot/add', + 'slot/remove', + 'roster/put', + 'invite/create', + 'invite/delete', + 'blob/delete', +] + export const SIGNED_OPS = [ 'inbox/register', 'inbox/sync', @@ -106,3 +120,31 @@ export const unsignedEnvelope = (payload) => ({ }) export const sha256 = async (bytes) => new Uint8Array(await subtle.digest('SHA-256', bytes)) + +/** A bearer `inbox/live` or `blob/put`'s fields: b64u(UTF-8 JSON) for `x-buddies-payload`. */ +export const fieldsHeader = (fields) => b64u(encoder.encode(JSON.stringify(fields))) + +// --- Accounts (sessions) -------------------------------------------------- + +export const ACCOUNT_SIGNING_CONTEXT = 'ww-account/v1\n' + +/** The bytes an account-key envelope's signature covers (`src/accounts/contracts.ts`). */ +export const accountSignedMessage = (op, payloadBytes) => { + const prefix = encoder.encode(`${ACCOUNT_SIGNING_CONTEXT}${op}\n`) + const message = new Uint8Array(prefix.length + payloadBytes.length) + message.set(prefix, 0) + message.set(payloadBytes, prefix.length) + return message +} + +/** A self-signed account envelope for `op` (e.g. `session/open`). */ +export const accountEnvelope = async (op, key, fields = {}) => { + const bytes = encoder.encode( + JSON.stringify({ accountPub: key.publicKey, ts: Date.now(), nonce: b64u(webcrypto.getRandomValues(new Uint8Array(16))), ...fields }) + ) + return { payload: b64u(bytes), signature: b64u(await key.sign(accountSignedMessage(op, bytes))) } +} + +/** What the account key signs to link an inbox (`inbox/register`'s `account` proof). */ +export const inboxLinkMessage = (accountId, inboxId, ownerPub) => + encoder.encode(`${ACCOUNT_SIGNING_CONTEXT}inbox/link\n${accountId}\n${inboxId}\n${ownerPub}`) diff --git a/scripts/verify/buddies-wire.test.ts b/scripts/verify/buddies-wire.test.ts index c109e96..6963118 100644 --- a/scripts/verify/buddies-wire.test.ts +++ b/scripts/verify/buddies-wire.test.ts @@ -3,8 +3,10 @@ import { BUDDIES_ENVELOPE_HEADERS, BUDDIES_LIMITS, BUDDIES_OPS, + BUDDIES_OWNER_OPS, BUDDIES_UNSIGNED_OPS, } from '../../src/buddies/contracts' +import { accountSignedMessage, inboxLinkMessage } from '../../src/accounts/contracts' import { BUDDIES_ABUSE_LIMITS } from '../../src/buddies/limits' import { signedMessage, verifyBuddiesSignature } from '../../src/buddies/envelope' import * as wire from './buddies-wire.mjs' @@ -27,6 +29,7 @@ it('mirrors the per-caller edge limits', () => { it('knows every op', () => { expect([...wire.ALL_OPS].sort()).toEqual([...BUDDIES_OPS].sort()) expect(wire.UNSIGNED_OPS).toEqual([...BUDDIES_UNSIGNED_OPS]) + expect(wire.OWNER_OPS).toEqual([...BUDDIES_OWNER_OPS]) expect(wire.ENVELOPE_HEADERS).toEqual(BUDDIES_ENVELOPE_HEADERS) }) @@ -41,3 +44,17 @@ it('signs exactly what the relay verifies', async () => { await verifyBuddiesSignature(key.publicKey, 'inbox/sync', payloadBytes, wire.fromB64u(envelope.signature)) ).toBe(true) }) + +it('signs account envelopes and inbox links as the accounts routes verify them', async () => { + const key = await wire.signingKeyFromSeed(new Uint8Array(32).fill(9)) + const envelope = await wire.accountEnvelope('session/open', key) + const payloadBytes = wire.fromB64u(envelope.payload) + expect(wire.accountSignedMessage('session/open', payloadBytes)).toEqual( + accountSignedMessage('session/open', payloadBytes) + ) + expect(JSON.parse(new TextDecoder().decode(payloadBytes))).toMatchObject({ + accountPub: key.publicKey, + }) + const ids = ['A'.repeat(22), 'B'.repeat(22), key.publicKey] as const + expect(wire.inboxLinkMessage(...ids)).toEqual(inboxLinkMessage(...ids)) +}) diff --git a/scripts/verify/dev.mjs b/scripts/verify/dev.mjs index 4d4b2d8..5a43336 100755 --- a/scripts/verify/dev.mjs +++ b/scripts/verify/dev.mjs @@ -215,14 +215,18 @@ const writeVarsFile = (flags, previousTokens) => { if (value) optional[name] = value } // Synthetic per-state tokens; reused across restarts so tests keep working. - const tokens = previousTokens ?? { + const tokens = { devBypass: `verify-bypass-${randomBytes(16).toString('hex')}`, admin: `verify-admin-${randomBytes(16).toString('hex')}`, + // Signs session access tokens; tests get theirs from POST /accounts/v1/sessions. + accountToken: `verify-account-token-${randomBytes(24).toString('hex')}`, + ...previousTokens, } const lines = [ '# Generated by scripts/verify/dev.mjs. Synthetic local-only values.', `NOTES_IMPORT_DEV_BYPASS_TOKEN=${quoteEnv(tokens.devBypass)}`, `ADMIN_API_TOKEN=${quoteEnv(tokens.admin)}`, + `ACCOUNT_TOKEN_SECRET=${quoteEnv(tokens.accountToken)}`, `APPLE_TEAM_ID=${quoteEnv(optional.APPLE_TEAM_ID ?? SYNTHETIC_TEAM_ID)}`, ...OPTIONAL_SECRETS.filter((n) => n !== 'APPLE_TEAM_ID' && optional[n]).map( (name) => `${name}=${quoteEnv(optional[name])}` @@ -485,6 +489,21 @@ async function doctor() { bad(`dev bypass probe failed: ${error.message}`) } + try { + // Unknown refresh token: `unauthorized` proves sessions are on; `disabled` means no ACCOUNT_TOKEN_SECRET. + const probe = await fetchJson(`${state.url}/accounts/v1/sessions/refresh`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'cf-connecting-ip': '198.51.100.4' }, + body: JSON.stringify({ refreshToken: Buffer.alloc(32).toString('base64url') }), + }) + if (probe.status === 401 && probe.body?.error === 'unauthorized') ok('account sessions enabled') + else if (probe.body?.error === 'disabled') + bad('account sessions disabled: ACCOUNT_TOKEN_SECRET is missing; restart with `node scripts/verify/dev.mjs down && … up`') + else bad(`account sessions probe returned ${probe.status} ${JSON.stringify(probe.body)}`) + } catch (error) { + bad(`account sessions probe failed: ${error.message}`) + } + const configured = Object.entries(state.configured ?? {}) .map(([name, on]) => `${name}=${on ? 'set' : 'unset'}`) .join(' ') diff --git a/scripts/verify/fuzz-buddies.mjs b/scripts/verify/fuzz-buddies.mjs index 2f7b305..02d8d50 100755 --- a/scripts/verify/fuzz-buddies.mjs +++ b/scripts/verify/fuzz-buddies.mjs @@ -11,6 +11,13 @@ * status (e.g. tampered signature -> 401). After all cases a canary identity's * full `inbox/sync` must equal its pre-fuzz snapshot. Writes a JSON report to * .verify/artifacts/fuzz-.json and exits 1 on any failure. + * + * Both ways to authenticate are fuzzed: legacy requests (signed envelopes, no + * `Authorization` header) and bearer requests on account sessions opened + * with the dev bypass (`WW_API_DEV_BYPASS_TOKEN`, else the token + * `dev.mjs up` recorded). A second canary, registered and synced by bearer + * request, must come through intact too. Without a bypass token the bearer + * generators are skipped (and counted as such). Tokens never reach the report. */ import { createHash, randomBytes } from 'node:crypto' @@ -20,11 +27,15 @@ import { fileURLToPath } from 'node:url' import { ALL_OPS, ENVELOPE_HEADERS, + OWNER_OPS, SIGNED_OPS, WIRE_EDGE_LIMITS, WIRE_LIMITS as L, + accountEnvelope, b64u, + fieldsHeader, fromB64u, + inboxLinkMessage, sha256, signedEnvelope, signingKeyFromSeed, @@ -43,11 +54,13 @@ const flag = (name, fallback) => { const inline = argv.find((arg) => arg.startsWith(`--${name}=`)) return inline ? inline.split('=')[1] : fallback } -const stateUrl = () => { +const readState = () => { const path = join(ROOT, '.verify', 'state.json') - return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')).url : undefined + return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {} } -const BASE = String(flag('url', process.env.WW_API_URL ?? stateUrl() ?? '')).replace(/\/$/, '') +const BASE = String(flag('url', process.env.WW_API_URL ?? readState().url ?? '')).replace(/\/$/, '') +/** Opens account sessions without attestation; bearer generators skip without it. */ +const DEV_BYPASS = process.env.WW_API_DEV_BYPASS_TOKEN ?? (process.env.WW_API_URL ? undefined : readState().tokens?.devBypass) const RUNS = Number(flag('runs', process.env.WW_FUZZ_RUNS ?? 300)) const SEED = Number(flag('seed', process.env.WW_FUZZ_SEED ?? randomBytes(4).readUInt32BE(0))) >>> 0 const ONLY = flag('case', undefined) @@ -106,6 +119,7 @@ class FuzzFailure extends Error { const stats = { requests: 0, + sessionsOpened: 0, devProxyRetries: 0, statuses: {}, latencies: [], @@ -122,6 +136,11 @@ const describeBody = (body) => { return '' } +/** Tokens (bearer, dev bypass) are recorded by name only. */ +const SECRET_HEADERS = new Set(['authorization', 'x-ww-dev-bypass']) +const redact = (headers) => + Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, SECRET_HEADERS.has(name.toLowerCase()) ? '' : value])) + const clip = (text, max) => text && text.length > max ? `${text.slice(0, max)}…<${text.length} chars, sha256 ${createHash('sha256').update(text).digest('hex').slice(0, 16)}>` @@ -141,7 +160,7 @@ const send = async ({ op, path, method = 'POST', body, headers = {}, expect, err } const exchange = { note, - request: { method, url: url.slice(BASE.length), headers: finalHeaders, body: describeBody(payload) }, + request: { method, url: url.slice(BASE.length), headers: redact(finalHeaders), body: describeBody(payload) }, } caseLog.push(exchange) const started = performance.now() @@ -386,6 +405,110 @@ const getBlob = (inboxId, blob, token = blob.token, expect = [200], error) => const sameBytes = (a, b) => a.length === b.length && a.every((byte, i) => byte === b[i]) +// --- Account sessions (bearer requests) ----------------------------------------- + +/** A generator's answer when it can't run here (no dev bypass token). */ +const SKIPPED = Symbol('skipped') +/** Access tokens last 15 minutes; reopen well before that. */ +const SESSION_REFRESH_MS = 10 * 60_000 + +/** Opens a session for `key` (a fresh one by default) with the dev bypass. */ +const openSession = async (key) => { + const signingKey = key ?? (await newKey()) + const response = await send({ + path: '/accounts/v1/sessions', + body: await accountEnvelope('session/open', signingKey), + headers: { 'x-ww-dev-bypass': DEV_BYPASS }, + expect: [201], + note: 'open a session (dev bypass)', + }) + stats.sessionsOpened++ + const { accountId, accessToken, refreshToken } = response.json + return { + key: signingKey, + accountId, + accessToken, + refreshToken, + openedAt: Date.now(), + auth: { authorization: `Bearer ${accessToken}` }, + } +} + +/** Reopens a long-lived session (same key, so the same account) before its token expires. */ +const keepFresh = async (session) => { + if (Date.now() - session.openedAt < SESSION_REFRESH_MS) return + Object.assign(session, await openSession(session.key)) +} + +const linkProof = async (account, inboxId, ownerPub) => ({ + accountId: account.accountId, + signature: b64u(await account.key.sign(inboxLinkMessage(account.accountId, inboxId, ownerPub))), +}) + +/** + * An inbox on a session: registered by bearer request (token plus the + * self-signed envelope with its `account` proof), owner ops sent as plain JSON. + */ +const newBearerOwner = async (session) => { + const account = session ?? (await openSession()) + const key = await newKey() + const owner = { key, inboxId: rid(), account } + owner.register = async (fields = {}, expect = [200], error, headers = account.auth, note) => + send({ + op: 'inbox/register', + body: await signed('inbox/register', key, { + inboxId: owner.inboxId, + ownerPub: key.publicKey, + account: await linkProof(account, owner.inboxId, key.publicKey), + ...fields, + }), + headers, + expect, + error, + note: note ?? 'bearer inbox/register', + }) + owner.send = (op, fields = {}, expect = [200], error, headers = account.auth, note) => + send({ op, body: { inboxId: owner.inboxId, ...fields }, headers, expect, error, note: note ?? `bearer ${op}` }) + await owner.register() + return owner +} + +/** `blob/put` as a bearer request: the fields in `x-buddies-payload`, no signature. */ +const putBlobBearer = (owner, blob, { fields = {}, body = blob.bytes, headers = owner.account.auth, expect = [200], error, note } = {}) => + send({ + op: 'blob/put', + body, + headers: { + 'content-type': 'application/octet-stream', + [ENVELOPE_HEADERS.payload]: fieldsHeader({ + inboxId: owner.inboxId, + blobId: blob.blobId, + bytes: blob.bytes.length, + expiresAt: Date.now() + 86_400_000, + readTokenHash: blob.readTokenHash, + ...fields, + }), + ...headers, + }, + expect, + error, + note: note ?? `bearer blob/put ${body.length} bytes`, + }) + +/** A writer op as a bearer request: the writer-signed envelope plus a token. */ +const sendWriterBearer = async (writer, op, fields, headers, { key = writer.key, expect = [200], error, note } = {}) => + send({ + op, + body: await signed(op, key, { inboxId: writer.inboxId, slotId: writer.slotId, ...fields }), + headers, + expect, + error, + note: note ?? `bearer ${op}`, + }) + +/** Fields a bearer request ignores (it has no signature to bind them). */ +const BEARER_IGNORED = new Set(['ts', 'nonce']) + // --- Generators ----------------------------------------------------------------- // Each receives (rng, canary) and throws FuzzFailure on an oracle violation. @@ -843,6 +966,292 @@ const GENERATORS = { const stored = sync.json.events.filter((event) => event.eventId === eventId) if (stored.length !== 1) throw new FuzzFailure(`dedupe: sync shows ${stored.length} copies of the event`, caseLog.at(-1)) }, + + // --- Bearer requests (account sessions) --------------------------------------- + + /** + * A bad `Authorization` header on any route class: always 401 `unauthorized` + * with the bearer challenge, never a fallback to the envelope (a valid + * legacy envelope rides along in some cases). + */ + 'bearer-bad-token': async (rng, canary) => { + const bearer = canary.bearer + if (!bearer) return SKIPPED + await keepFresh(bearer.owner.account) + const token = bearer.owner.account.accessToken + const [head, claims, signature] = token.split('.') + const forgedClaims = b64u(Buffer.from(JSON.stringify({ ...JSON.parse(Buffer.from(claims, 'base64url').toString('utf8')), sub: rid() }))) + const flip = signature.at(-2) === 'A' ? 'B' : 'A' + const [variant, header] = rng.pick([ + ['flipped signature', `Bearer ${head}.${claims}.${signature.slice(0, -2)}${flip}${signature.at(-1)}`], + ['no signature', `Bearer ${head}.${claims}`], + ['forged sub', `Bearer ${head}.${forgedClaims}.${signature}`], + ['alg none', `Bearer ${b64u(Buffer.from('{"alg":"none","typ":"JWT"}'))}.${claims}.`], + ['refresh token', `Bearer ${bearer.owner.account.refreshToken}`], + ['basic scheme', `Basic ${token}`], + ['no scheme', token], + ['empty bearer', 'Bearer'], + ['trailing junk', `Bearer ${token} x`], + ['random', `Bearer ${b64u(randomBytes(rng.int(1, 300)))}`], + ]) + const target = rng.pick(['owner', 'owner-envelope', 'writer', 'unsigned', 'register', 'blob/get']) + let request + if (target === 'owner') { + const op = rng.pick(OWNER_OPS) + request = { op, body: await validPayload(op, bearer) } + } else if (target === 'owner-envelope') { + const op = rng.pick(OWNER_OPS) + request = { op, body: await signed(op, bearer.owner.key, await validPayload(op, bearer)) } + } else if (target === 'writer') { + const op = rng.pick(['card/put', 'event/put', 'slot/leave']) + request = { op, body: await signed(op, bearer.writer.key, await validPayload(op, bearer)) } + } else if (target === 'unsigned') { + const op = rng.pick(['invite/fetch', 'invite/claim']) + request = { op, body: unsignedEnvelope(await validPayload(op, bearer)) } + } else if (target === 'register') { + request = { op: 'inbox/register', body: await signed('inbox/register', bearer.owner.key, await validPayload('inbox/register', bearer)) } + } else { + request = { op: 'blob/get', body: unsignedEnvelope({ inboxId: bearer.owner.inboxId, blobId: b64u(randomBytes(32)), token: b64u(randomBytes(32)) }) } + } + const response = await send({ ...request, headers: { authorization: header }, expect: [401], error: 'unauthorized', note: `${variant} on ${target} ${request.op}` }) + if (response.headers.get('www-authenticate') !== 'Bearer error="invalid_token"') + throw new FuzzFailure('unauthorized without WWW-Authenticate: Bearer error="invalid_token"', caseLog.at(-1)) + }, + + /** Another account's valid token on the bearer canary: 403 `forbidden`, nothing lands. */ + 'bearer-cross-account': async (rng, canary) => { + const bearer = canary.bearer + if (!bearer) return SKIPPED + await keepFresh(bearer.stranger) + if (rng.pick(['op', 'op', 'op', 'blob']) === 'blob') { + await putBlobBearer(bearer.owner, await photo(64), { headers: bearer.stranger.auth, expect: [403], error: 'forbidden', note: "bearer blob/put into another account's inbox" }) + return + } + const op = rng.pick(OWNER_OPS) + await bearer.owner.send(op, await validPayload(op, bearer), [403], 'forbidden', bearer.stranger.auth, `bearer ${op} with another account's token`) + }, + + /** A bearer owner op on the legacy canary, which no account owns: 409 `not_linked`. */ + 'bearer-not-linked': async (rng, canary) => { + const bearer = canary.bearer + if (!bearer) return SKIPPED + await keepFresh(bearer.stranger) + const op = rng.pick(OWNER_OPS) + await send({ + op, + body: await validPayload(op, canary), + headers: bearer.stranger.auth, + expect: [409], + error: 'not_linked', + note: `bearer ${op} on an inbox registered before accounts`, + }) + }, + + /** + * Bearer owner bodies that aren't the op's plain fields: one field invalid + * or missing, an envelope, or not a JSON object. All 400 on the bearer + * canary, which must not change. + */ + 'bearer-bad-fields': async (rng, canary) => { + const bearer = canary.bearer + if (!bearer) return SKIPPED + await keepFresh(bearer.owner.account) + const op = rng.pick(OWNER_OPS) + const fields = await validPayload(op, bearer) + const variant = rng.pick(['wrong-type', 'wrong-type', 'missing', 'envelope', 'short-envelope', 'not-object']) + let body + let note + if (variant === 'wrong-type') { + const field = rng.pick(Object.keys(fields).filter((name) => INVALID[name] && !BEARER_IGNORED.has(name))) + let value = rng.pick(INVALID[field]) + if (typeof value === 'function') value = value() + body = { ...fields, [field]: value } + note = `bearer ${op}.${field} = ${clip(JSON.stringify(value) ?? String(value), 80)}` + } else if (variant === 'missing') { + const field = rng.pick(Object.keys(fields).filter((name) => !OPTIONAL_FIELDS.has(name))) + body = { ...fields } + delete body[field] + note = `bearer ${op} without ${field}` + } else if (variant === 'envelope' || variant === 'short-envelope') { + const env = await signed(op, bearer.owner.key, fields) + body = variant === 'envelope' ? { ...fields, ...env } : { p: env.payload, s: env.signature } + note = `bearer ${op} with a ${variant}` + } else { + body = rng.pick(['[]', 'null', '"x"', '42', '{', '']) + note = `bearer ${op} body ${body || '(empty)'}` + } + await send({ + op, + body, + headers: { ...bearer.owner.account.auth, ...(typeof body === 'string' ? { 'content-type': 'application/json' } : {}) }, + expect: [400], + error: 'bad_request', + note, + }) + }, + + /** + * A bearer `inbox/register` must carry an `account` proof for the token's + * own account, signed by that account's key over this inbox. + */ + 'bearer-register-proof': async (rng, canary) => { + const bearer = canary.bearer + if (!bearer) return SKIPPED + await keepFresh(bearer.stranger) + const owner = await newBearerOwner() + const { account } = owner + const variant = rng.pick(['missing', 'other-account', 'unknown-account', 'forged-signature', 'wrong-inbox', 'stranger-token', 'repeat']) + const proof = { + missing: () => undefined, + 'other-account': () => linkProof(bearer.stranger, owner.inboxId, owner.key.publicKey), + 'unknown-account': async () => ({ ...(await linkProof(account, owner.inboxId, owner.key.publicKey)), accountId: rid() }), + 'forged-signature': async () => linkProof({ ...account, key: await newKey() }, owner.inboxId, owner.key.publicKey), + 'wrong-inbox': () => linkProof(account, rid(), owner.key.publicKey), + 'stranger-token': () => linkProof(account, owner.inboxId, owner.key.publicKey), + repeat: () => linkProof(account, owner.inboxId, owner.key.publicKey), + }[variant] + const [expect, error] = { + missing: [[400], 'bad_request'], + 'other-account': [[403], 'forbidden'], + 'unknown-account': [[403], 'forbidden'], + 'forged-signature': [[401], 'bad_signature'], + 'wrong-inbox': [[401], 'bad_signature'], + 'stranger-token': [[403], 'forbidden'], + repeat: [[200], undefined], + }[variant] + const headers = variant === 'stranger-token' ? bearer.stranger.auth : account.auth + await owner.register({ account: await proof() }, expect, error, headers, `bearer inbox/register: ${variant}`) + await owner.send('inbox/delete') + }, + + /** + * Bearer `blob/put`: the fields in `x-buddies-payload` and no signature. + * A signature header (either naming) or only the short payload header is + * 400, `ts`/`nonce` are ignored, the bytes must still hash to `blobId`, + * and the account checks hold. + */ + 'bearer-blob-put': async (rng, canary) => { + const bearer = canary.bearer + if (!bearer) return SKIPPED + await keepFresh(bearer.stranger) + const variant = rng.pick(['roundtrip', 'signature-header', 'short-signature-header', 'short-payload', 'ignored-ts-nonce', 'flipped-bit', 'stranger', 'not-linked']) + const blob = await photo(rng.int(1, 64 * 1024)) + if (variant === 'not-linked') { + const legacy = await newOwner() + await newWriter(legacy) + await putBlobBearer({ ...legacy, account: bearer.stranger }, blob, { expect: [409], error: 'not_linked', note: 'bearer blob/put into an inbox registered before accounts' }) + await legacy.send('inbox/delete', {}) + return + } + const owner = await newBearerOwner() + await newWriter(owner) + const auth = owner.account.auth + if (variant === 'roundtrip') { + await putBlobBearer(owner, blob) + const got = await send({ op: 'blob/get', body: unsignedEnvelope({ inboxId: owner.inboxId, blobId: blob.blobId, token: blob.token }), headers: bearer.stranger.auth, raw: true, expect: [200], note: 'bearer blob/get' }) + if (!sameBytes(got.bytes, blob.bytes)) throw new FuzzFailure('bearer blob/get bytes differ from blob/put', caseLog.at(-1)) + await putBlobBearer(owner, blob, { note: 'bearer blob/put retry (no replay)' }) + } else if (variant === 'signature-header' || variant === 'short-signature-header') { + const name = variant === 'signature-header' ? ENVELOPE_HEADERS.signature : 'x-buddies-s' + await putBlobBearer(owner, blob, { headers: { ...auth, [name]: b64u(randomBytes(64)) }, expect: [400], error: 'bad_request', note: `bearer blob/put with ${name}` }) + } else if (variant === 'short-payload') { + await send({ + op: 'blob/put', + body: blob.bytes, + headers: { 'content-type': 'application/octet-stream', 'x-buddies-p': fieldsHeader({ inboxId: owner.inboxId, blobId: blob.blobId, bytes: blob.bytes.length, expiresAt: Date.now() + 86_400_000, readTokenHash: blob.readTokenHash }), ...auth }, + expect: [400], + error: 'bad_request', + note: 'bearer blob/put with only x-buddies-p', + }) + } else if (variant === 'ignored-ts-nonce') { + await putBlobBearer(owner, blob, { fields: { ts: rng.pick([0, -1, Date.now() - 86_400_000, 'x']), nonce: rng.pick(['', null, rid()]) }, note: 'bearer blob/put with ts/nonce (ignored)' }) + } else if (variant === 'flipped-bit') { + const body = new Uint8Array(blob.bytes) + body[rng.int(0, body.length - 1)] ^= 1 << rng.int(0, 7) + await putBlobBearer(owner, blob, { body, expect: [400], error: 'bad_request', note: 'bearer blob/put with a flipped bit' }) + } else { + await putBlobBearer(owner, blob, { headers: bearer.stranger.auth, expect: [403], error: 'forbidden', note: "bearer blob/put with another account's token" }) + } + await owner.send('inbox/delete') + }, + + /** + * Writer ops in bearer requests: the writer-signed envelope stays the + * capability whoever's token rides along; a bad writer signature, a stale + * `ts`, a bad token, or plain JSON fields are refused. + */ + 'bearer-writer': async (rng, canary) => { + const bearer = canary.bearer + if (!bearer) return SKIPPED + await keepFresh(bearer.stranger) + const owner = await newBearerOwner() + const writer = await newWriter(owner) + const op = rng.pick(['card/put', 'event/put', 'slot/leave']) + const fields = await validPayload(op, { owner, writer }) + const variant = rng.pick(['any-session', 'any-session', 'bad-writer-signature', 'stale', 'bad-token', 'plain-json']) + if (variant === 'any-session') { + await sendWriterBearer(writer, op, fields, bearer.stranger.auth, { note: `bearer ${op} with an unrelated session` }) + } else if (variant === 'bad-writer-signature') { + await sendWriterBearer(writer, op, fields, bearer.stranger.auth, { key: await newKey(), expect: [401], error: 'bad_signature', note: `bearer ${op} signed by a stranger` }) + } else if (variant === 'stale') { + await sendWriterBearer(writer, op, { ...fields, ts: Date.now() - L.timestampSkewMs - rng.int(1_000, 86_400_000) }, bearer.stranger.auth, { expect: [401], error: 'stale', note: `bearer ${op} with a stale ts` }) + } else if (variant === 'bad-token') { + await sendWriterBearer(writer, op, fields, { authorization: `Bearer ${b64u(randomBytes(40))}` }, { expect: [401], error: 'unauthorized', note: `${op} with a bad token` }) + } else { + await send({ op, body: fields, headers: bearer.stranger.auth, expect: [400], error: 'bad_request', note: `${op} as plain JSON` }) + } + await owner.send('inbox/delete') + }, + + /** + * A bearer round trip: register with a proof, add a slot, a buddy's + * event/put, sync it back (twice, no replay), delete, then the inbox is gone. + */ + 'bearer-round-trip': async (rng, canary) => { + const bearer = canary.bearer + if (!bearer) return SKIPPED + await keepFresh(bearer.stranger) + const owner = await newBearerOwner() + const writer = await newWriter(owner) + const eventId = rid() + await sendWriterBearer(writer, 'event/put', { eventId, kind: 'plan.joined', blob: blob(rng.int(1, 512)), push: false }, bearer.stranger.auth) + for (let i = 0; i < 2; i++) { + const sync = await owner.send('inbox/sync', { since: 0, ts: 0, nonce: 'same' }) + if (sync.json?.events?.filter((event) => event.eventId === eventId).length !== 1) + throw new FuzzFailure('bearer sync is missing the buddy event', caseLog.at(-1)) + } + await owner.send('inbox/delete') + await owner.send('inbox/sync', { since: 0 }, [404], 'not_found') + }, + + /** + * Per-account edge limits: one session's unsigned tier, from a fresh + * address every request, runs out after `limit`; another session from the + * same address still has its own budget. + */ + 'bearer-caller-limits': async (_rng, canary) => { + if (!canary.bearer) return SKIPPED + const limit = WIRE_EDGE_LIMITS.unsigned + const session = await openSession() + for (let i = 0; i < 2 * limit + 1; i++) { + const response = await send({ + op: 'invite/fetch', + body: unsignedEnvelope({ inviteId: rid() }), + headers: session.auth, + expect: i < limit ? [404] : [404, 429], + note: `bearer invite/fetch ${i + 1} from a new address`, + }) + if (response.status === 429) { + if (response.json?.error !== 'rate_limited' || response.headers.get('retry-after') !== '60') + throw new FuzzFailure('edge refusal without rate_limited + Retry-After: 60', caseLog.at(-1)) + const shared = caseLog.at(-1).request.headers['cf-connecting-ip'] + const other = await openSession() + await send({ op: 'invite/fetch', body: unsignedEnvelope({ inviteId: rid() }), headers: { ...other.auth, 'cf-connecting-ip': shared }, expect: [404], note: 'another account from the same address' }) + return + } + } + throw new FuzzFailure(`no 429 after ${2 * limit + 1} bearer requests from one account`, caseLog.at(-1)) + }, } // --- Canary ------------------------------------------------------------------- @@ -858,6 +1267,23 @@ const setupCanary = async () => { return { owner, writer, snapshot } } +/** + * The bearer canary: an inbox registered by bearer request on its own + * session, with a buddy's card and event (sent with the buddy's token), a + * roster, and a device; plus a stranger session for cross-account cases. + */ +const setupBearerCanary = async () => { + const owner = await newBearerOwner() + const writer = await newWriter(owner) + const stranger = await openSession() + await sendWriterBearer(writer, 'card/put', { blob: blob(512) }, stranger.auth) + await sendWriterBearer(writer, 'event/put', { eventId: rid(), kind: 'plan.joined', blob: blob(64), push: false }, stranger.auth) + await owner.send('roster/put', { blob: blob(256) }) + await owner.send('device/register', { deviceId: rid(), apnsToken: randomBytes(32).toString('hex'), apnsEnvironment: 'sandbox', templates: TEMPLATES }) + const snapshot = (await owner.send('inbox/sync', { since: 0 })).json + return { owner, writer, stranger, snapshot } +} + const stable = (value) => JSON.stringify(value) // --- Main ----------------------------------------------------------------------- @@ -879,10 +1305,12 @@ caseLog = [] let canary try { canary = await setupCanary() + canary.bearer = DEV_BYPASS ? await setupBearerCanary() : null } catch (error) { console.error('fuzz: canary setup failed (harness or server bug):', error.message, JSON.stringify(error.exchange ?? {}, null, 2)) process.exit(1) } +if (!canary.bearer) console.log('fuzz: no dev bypass token; bearer generators are skipped') for (const index of indices) { const name = names[(index + offset) % names.length] @@ -891,7 +1319,7 @@ for (const index of indices) { const bucket = (stats.byGenerator[name] ??= { cases: 0, failures: 0 }) bucket.cases++ try { - await GENERATORS[name](rng, canary) + if ((await GENERATORS[name](rng, canary)) === SKIPPED) bucket.skipped = (bucket.skipped ?? 0) + 1 } catch (error) { bucket.failures++ const failure = { @@ -923,10 +1351,29 @@ if (!canaryOk) { failures.push({ case: 'canary', generator: 'canary', reason: 'canary inbox changed or became unsyncable', detail: canaryDetail, transcript: caseLog }) console.log('FAIL canary: inbox state changed or unsyncable after fuzzing') } +let bearerCanaryOk = null +let bearerCanaryDetail = null +if (canary.bearer) { + caseLog = [] + try { + await keepFresh(canary.bearer.owner.account) + const after = await canary.bearer.owner.send('inbox/sync', { since: 0 }) + bearerCanaryOk = stable(after.json) === stable(canary.bearer.snapshot) + if (!bearerCanaryOk) bearerCanaryDetail = { before: canary.bearer.snapshot, after: after.json } + } catch (error) { + bearerCanaryOk = false + bearerCanaryDetail = { error: error.message, exchange: error.exchange } + } + if (!bearerCanaryOk) { + failures.push({ case: 'bearer-canary', generator: 'canary', reason: 'bearer canary inbox changed or became unsyncable', detail: bearerCanaryDetail, transcript: caseLog }) + console.log('FAIL bearer canary: inbox state changed or unsyncable after fuzzing') + } +} const healthAfter = await fetch(`${BASE}/health`, { signal: AbortSignal.timeout(TIMEOUT_MS) }).then((r) => r.ok).catch(() => false) if (!healthAfter) failures.push({ case: 'health', generator: 'health', reason: '/health not ok after fuzzing' }) -// Clean up the canary so repeated runs don't accumulate state. +// Clean up the canaries so repeated runs don't accumulate state. await canary.owner.send('inbox/delete', {}).catch(() => undefined) +if (canary.bearer) await canary.bearer.owner.send('inbox/delete').catch(() => undefined) const sorted = [...stats.latencies].sort((a, b) => a - b) const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))] ?? 0 @@ -938,11 +1385,14 @@ const report = { startedAt: startedAt.toISOString(), durationMs: Date.now() - startedAt.getTime(), requests: stats.requests, + bearer: canary.bearer != null, + sessionsOpened: stats.sessionsOpened, devProxyRetries: stats.devProxyRetries, statuses: stats.statuses, latencyMs: { p50: pct(50), p95: pct(95), max: sorted.at(-1) ?? 0 }, generators: stats.byGenerator, canary: { intact: canaryOk, detail: canaryDetail }, + bearerCanary: canary.bearer ? { intact: bearerCanaryOk, detail: bearerCanaryDetail } : 'skipped (no dev bypass token)', healthAfter, failures, } @@ -953,7 +1403,9 @@ writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`) console.log( `fuzz: ${indices.length} cases, ${stats.requests} requests, statuses ${JSON.stringify(stats.statuses)}, p95 ${pct(95)}ms, max ${report.latencyMs.max}ms, wrangler-dev proxy retries ${stats.devProxyRetries}` ) -console.log(`fuzz: canary ${canaryOk ? 'intact' : 'BROKEN'}, health after ${healthAfter ? 'ok' : 'FAILED'}`) +console.log( + `fuzz: canary ${canaryOk ? 'intact' : 'BROKEN'}, bearer canary ${canary.bearer ? (bearerCanaryOk ? 'intact' : 'BROKEN') : 'skipped'}, ${stats.sessionsOpened} sessions, health after ${healthAfter ? 'ok' : 'FAILED'}` +) console.log(`fuzz: report ${reportPath}`) if (failures.length) { console.log(`fuzz: ${failures.length} failure(s); seed ${SEED}. First repro:`) diff --git a/src/accounts/admin.ts b/src/accounts/admin.ts index e92d7e7..1c6c3b8 100644 --- a/src/accounts/admin.ts +++ b/src/accounts/admin.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' -import type { AppContext, Environment } from '../types' -import { Sentry } from '../sentry' +import type { AppContext, AppEnv } from '../types' +import { captureFailure } from '../sentry' import { timingSafeEqual } from '../crypto' import { apiError } from '../errors' import { BUDDIES_ID_PATTERN } from '../buddies/contracts' @@ -117,7 +117,8 @@ const handleStatus = async (c: AppContext): Promise => { ) ) const failed = results.filter((result) => result.status === 'rejected') - for (const result of failed) Sentry.captureException(result.reason) + for (const result of failed) + captureFailure(result.reason, 'admin/accounts/status') return c.json({ ok: failed.length === 0, account: await describe(db, { ...account, status }), @@ -128,7 +129,7 @@ const handleStatus = async (c: AppContext): Promise => { /** Mounted at `/admin/accounts` (behind the `admin` rate limit). */ export const createAccountsAdminRoutes = () => { - const routes = new Hono<{ Bindings: Environment }>() + const routes = new Hono() routes.post('/lookup', accountLookupDocs, handleLookup) routes.post('/status', accountStatusDocs, handleStatus) return routes diff --git a/src/accounts/attestation.ts b/src/accounts/attestation.ts new file mode 100644 index 0000000..73ee6e5 --- /dev/null +++ b/src/accounts/attestation.ts @@ -0,0 +1,110 @@ +import type { AppContext } from '../types' +import { captureFailure } from '../sentry' +import { isPlainObject } from '../buddies/contracts' +import { + buddiesErrorResponse, + buddiesInternalError, +} from '../buddies/errorResponse' +import { + AppAttestError, + type AppAttestAction, + type AppAttestReason, +} from '../appAttest' +import { APP_ATTEST_CHALLENGE_TTL_SECONDS } from '../appAttest/protocol' +import { PlayIntegrityError, type PlayIntegrityReason } from '../playIntegrity' +import type { PlayIntegrityAction } from '../playIntegrity/errors' +import { verifyAttestation, type AttestedPlatform } from '../attestation/http' +import { ACCOUNT_SESSION_PURPOSE } from '../attestation/purposes' +import { matchesDevBypass, resolveDevBypassToken } from '../devBypass' +import { sessionAttestationHash } from './contracts' + +/** + * Attesting a session (witness-work ADR 0027): the answer to a + * `POST /attestation/v1/challenge` challenge under purpose `account-session`, + * App Attest v2 `assert` (iOS) or Play Integrity v1 (Android), with + * `contentHash` and `requestHash` both `sessionAttestationHash(accountPub)`. + * The App Attest key is the install's (`AppAttestIdentity` per install uuid), + * bound through `POST /attestation/v1/register`. Verified by the shared + * registry's `verifyAttestation` (src/attestation/http.ts). + * + * Refusals use the accounts envelope: a malformed attestation, or one for + * another key, is 400 `bad_request`, too many open challenges 429 + * `rate_limited`, our own storage failing 500 `internal`, and everything + * else 403 `attestation_failed`. Each carries the stable `reason` and + * `action` the attestation routes use. + * + * Privacy: logs carry the reason only, never tokens, assertions, or ids. + */ + +type Reason = AppAttestReason | PlayIntegrityReason +type Action = AppAttestAction | PlayIntegrityAction + +const refusal = (reason: Reason, action: Action, cause?: unknown): Response => { + console.warn('accounts: session attestation rejected', { reason }) + const extras = { reason, action } + switch (reason) { + case 'invalid_request': + case 'unsupported_protocol': + return buddiesErrorResponse('bad_request', { extras }) + case 'too_many_challenges': + return buddiesErrorResponse('rate_limited', { + retryAfterSeconds: APP_ATTEST_CHALLENGE_TTL_SECONDS, + extras, + }) + case 'storage_unavailable': + captureFailure(cause, 'session/open attestation') + return buddiesInternalError() + default: + return buddiesErrorResponse('attestation_failed', { extras }) + } +} + +/** + * The development worker's stand-in for attestation: `x-ww-dev-bypass` + * matching `NOTES_IMPORT_DEV_BYPASS_TOKEN` (src/devBypass.ts). Production + * ignores the header, and throws when the token is set there. + */ +export const isSessionDevBypass = (c: AppContext): boolean => + matchesDevBypass( + resolveDevBypassToken(c.env), + c.req.header('x-ww-dev-bypass') + ) + +/** + * The session's attestation: the answer to an `account-session` challenge + * whose hashes are `sessionAttestationHash(accountPub)` for the envelope's + * own key. Consumes the challenge. + */ +export const verifySessionAttestation = async ( + c: AppContext, + attestation: Record | null, + accountPub: string +): Promise< + { ok: true; platform: AttestedPlatform } | { ok: false; response: Response } +> => { + const expected = await sessionAttestationHash(accountPub) + if ( + !isPlainObject(attestation) || + attestation.purpose !== ACCOUNT_SESSION_PURPOSE || + attestation.contentHash !== expected || + attestation.requestHash !== expected + ) + return { ok: false, response: refusal('invalid_request', 'none') } + try { + return { + ok: true, + platform: await verifyAttestation( + c, + attestation, + ACCOUNT_SESSION_PURPOSE + ), + } + } catch (error) { + if (error instanceof AppAttestError || error instanceof PlayIntegrityError) + return { + ok: false, + response: refusal(error.reason, error.action, error), + } + throw error + } +} diff --git a/src/accounts/auth.test.ts b/src/accounts/auth.test.ts new file mode 100644 index 0000000..6bc4aa1 --- /dev/null +++ b/src/accounts/auth.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' +import type { AppEnv } from '../types' +import { createHarness, type Harness } from '../test/buddies' +import { bearer, openTestSession, postJson } from '../test/accounts' +import { makeMemoryKv } from '../test/memoryKv' +import { + accountOf, + bearerOrLegacy, + bearerToken, + requireAccount, + resetAccountStatusCache, +} from './auth' +import { isLegacyAuthEnabled } from './legacySwitch' +import { accountsDb, setAccountStatus } from './store' +import { createAccountsRoutes } from './route' + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + ctx: DurableObjectState + env: unknown + constructor(ctx: DurableObjectState, env: unknown) { + this.ctx = ctx + this.env = env + } + }, +})) + +const START = Date.UTC(2026, 9, 15, 12) +const accounts = createAccountsRoutes() as never + +/** A route group the way a later feature would gate itself. */ +const probe = new Hono() +probe.post('/bearer', requireAccount(), (c) => c.json(accountOf(c))) +probe.post('/active', requireAccount({ requireActive: true }), (c) => + c.json(accountOf(c)) +) +probe.post('/either', async (c) => + bearerOrLegacy(c, { + bearer: async (account) => c.json({ via: 'bearer', ...account }), + legacy: async () => c.json({ via: 'legacy' }), + }) +) + +let h: Harness + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(START) + h = await createHarness() + resetAccountStatusCache() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +const call = (path: string, headers: Record = {}) => + postJson(probe as never, h.env, path, {}, headers) + +describe('requireAccount', () => { + it('hands the route the token’s account and session', async () => { + const session = await openTestSession(accounts, h.env) + const response = await call('/bearer', bearer(session.accessToken)) + expect(response.status).toBe(200) + expect(response.body).toEqual({ + accountId: session.accountId, + sessionId: session.sessionId, + platform: 'dev', + }) + }) + + it('refuses a missing, malformed, or expired token with 401', async () => { + const session = await openTestSession(accounts, h.env) + for (const headers of [ + {}, + { authorization: '' }, + { authorization: session.accessToken }, + { authorization: `Basic ${session.accessToken}` }, + { authorization: `Bearer ${session.accessToken}x` }, + { authorization: 'Bearer' }, + ]) { + const response = await call('/bearer', headers) + expect(response.status, JSON.stringify(headers)).toBe(401) + expect(response.body).toEqual({ + ok: false, + error: 'unauthorized', + code: 'unauthorized', + }) + expect(response.headers.get('www-authenticate')).toBe( + 'Bearer error="invalid_token"' + ) + } + vi.setSystemTime(START + 15 * 60_000) + expect((await call('/bearer', bearer(session.accessToken))).status).toBe(401) + }) + + it('refuses a suspended account only when the route asks', async () => { + const session = await openTestSession(accounts, h.env) + expect((await call('/active', bearer(session.accessToken))).status).toBe(200) + await setAccountStatus( + accountsDb(h.env.ACCOUNTS_DB!), + session.accountId, + 'suspended', + 'report' + ) + // Cached for 30 seconds per isolate. + expect((await call('/active', bearer(session.accessToken))).status).toBe(200) + vi.setSystemTime(START + 30_000) + const refused = await call('/active', bearer(session.accessToken)) + expect(refused.status).toBe(403) + expect(refused.body.code).toBe('suspended') + // A suspended account still gets through routes that don't ask. + expect((await call('/bearer', bearer(session.accessToken))).status).toBe(200) + }) + + it('reads the token from the header only', () => { + expect(bearerToken('Bearer a.b.c')).toBe('a.b.c') + expect(bearerToken('bearer a.b.c ')).toBe('a.b.c') + expect(bearerToken('Bearer a b')).toBeNull() + expect(bearerToken(undefined)).toBeNull() + }) +}) + +describe('bearerOrLegacy', () => { + it('takes a request with Authorization as bearer, never as legacy', async () => { + const session = await openTestSession(accounts, h.env) + const ok = await call('/either', bearer(session.accessToken)) + expect(ok.body).toMatchObject({ via: 'bearer', accountId: session.accountId }) + const bad = await call('/either', bearer('not-a-token')) + expect(bad.status).toBe(401) + const empty = await call('/either', { authorization: '' }) + expect(empty.status).toBe(401) + }) + + it('serves a legacy request while the switch is on, and 426 once it’s off', async () => { + expect((await call('/either')).body).toEqual({ via: 'legacy' }) + await h.kv.put('auth:legacy', 'off') + const refused = await call('/either') + expect(refused.status).toBe(426) + expect(refused.body).toEqual({ + ok: false, + error: 'upgrade_required', + code: 'upgrade_required', + }) + expect(refused.headers.get('retry-after')).toBeNull() + // Bearer requests don't care. + const session = await openTestSession(accounts, h.env) + expect((await call('/either', bearer(session.accessToken))).status).toBe(200) + }) +}) + +describe('the legacy switch', () => { + const read = async (kv: string | null, AUTH_LEGACY?: string) => { + const store = makeMemoryKv() + if (kv != null) await store.put('auth:legacy', kv) + return isLegacyAuthEnabled({ NOTES_KV: store, AUTH_LEGACY }) + } + + it('is on unless KV or the var says off', async () => { + expect(await read(null)).toBe(true) + expect(await read('off')).toBe(false) + expect(await read(' OFF ')).toBe(false) + expect(await read('false')).toBe(false) + expect(await read('on', 'off')).toBe(true) + expect(await read(null, 'off')).toBe(false) + expect(await read('garbage', 'off')).toBe(false) + expect(await read('garbage')).toBe(true) + expect(await read(null, 'on')).toBe(true) + }) + + it('falls back to the var when KV fails', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const failing = { + get: async () => { + throw new Error('kv down') + }, + } + expect(await isLegacyAuthEnabled({ NOTES_KV: failing, AUTH_LEGACY: 'off' })).toBe(false) + expect(await isLegacyAuthEnabled({ NOTES_KV: failing })).toBe(true) + }) +}) diff --git a/src/accounts/auth.ts b/src/accounts/auth.ts new file mode 100644 index 0000000..8597771 --- /dev/null +++ b/src/accounts/auth.ts @@ -0,0 +1,195 @@ +import type { MiddlewareHandler } from 'hono' +import type { AppContext, AppEnv, Environment } from '../types' +import { captureFailure } from '../sentry' +import { readEnvelopeText } from '../buddies/envelope' +import { + buddiesErrorResponse, + buddiesInternalError, +} from '../buddies/errorResponse' +import type { AccountStatus, SessionPlatform } from './schema' +import { accountsDb, findAccount } from './store' +import { accessTokenKeys, verifyAccessToken } from './tokens' +import { refuseLegacyAuth } from './legacySwitch' + +/** + * Bearer authentication for every user-scoped route (witness-work ADR 0027): + * the caller is the account in an attested session's access token. + * + * - `requireAccount()`: Hono middleware for bearer-only routes. + * - `authenticateAccount(c)`: the same check as a call, for routes that order + * their own gates (Notes Import's kill switch, Buddies' tiers). + * - `bearerOrLegacy(c, …)`: for routes that still accept the pre-session way + * while the legacy switch is on. + * + * Each sets `c.var.account` on success (read it with `accountOf(c)`), and a + * refusal is a ready `Response` plus its code, for routes with their own + * error format: 401 `unauthorized` (with `WWW-Authenticate`), 403 + * `suspended`, 503 `disabled` (no token secret, or `requireActive` without + * the accounts database), or 500 `internal`. + * + * Privacy: nothing here logs or reports a token, an account id, or a session + * id. + */ + +/** Who's calling, from a verified access token. */ +export interface AccountAuth { + accountId: string + sessionId: string + platform: SessionPlatform +} + +export type AccountAuthError = + | 'unauthorized' + | 'suspended' + | 'disabled' + | 'internal' + +export type AccountAuthResult = + | { ok: true; account: AccountAuth } + | { ok: false; error: AccountAuthError; response: Response } + +export interface RequireAccountOptions { + /** + * Also refuse a suspended account (403 `suspended`). Costs a D1 read of the + * account's status, cached per isolate for 30 seconds, so a suspension or + * reinstatement takes up to that long to apply. Leave it off for routes a + * suspended account may still use (handoff, backup). + */ + requireActive?: boolean +} + +const STATUS_CACHE_MS = 30_000 +const STATUS_CACHE_ENTRIES = 1_000 +const statusCache = new Map() + +/** Clears the per-isolate status cache (tests). */ +export const resetAccountStatusCache = (): void => statusCache.clear() + +/** The account's status, or null when it no longer exists. */ +const accountStatus = async ( + env: Environment, + accountId: string, + now: number +): Promise => { + const cached = statusCache.get(accountId) + if (cached && now - cached.at < STATUS_CACHE_MS) return cached.status + const account = await findAccount(accountsDb(env.ACCOUNTS_DB!), accountId) + statusCache.delete(accountId) + if (!account) return null + if (statusCache.size >= STATUS_CACHE_ENTRIES) + statusCache.delete(statusCache.keys().next().value!) + statusCache.set(accountId, { status: account.status, at: now }) + return account.status +} + +const refuse = (error: AccountAuthError): AccountAuthResult => ({ + ok: false, + error, + response: + error === 'internal' + ? buddiesInternalError() + : buddiesErrorResponse(error), +}) + +/** True when the request has an `Authorization` header: a bearer request. */ +export const hasAuthorization = (c: AppContext): boolean => + c.req.header('authorization') != null + +/** The token in `Authorization: Bearer `, or null. */ +export const bearerToken = (header: string | undefined): string | null => + /^Bearer +([A-Za-z0-9._~+/-]+=*) *$/i.exec(header ?? '')?.[1] ?? null + +/** + * Verifies the request's bearer token and, with `requireActive`, the + * account's status; on success sets `c.var.account`. + */ +export const authenticateAccount = async ( + c: AppContext, + options: RequireAccountOptions = {} +): Promise => { + const keys = await accessTokenKeys(c.env) + if (!keys) return refuse('disabled') + const token = bearerToken(c.req.header('authorization')) + const claims = token ? await verifyAccessToken(keys, token) : null + if (!claims) return refuse('unauthorized') + const account: AccountAuth = { + accountId: claims.accountId, + sessionId: claims.sessionId, + platform: claims.platform, + } + if (options.requireActive) { + if (!c.env.ACCOUNTS_DB) return refuse('disabled') + let status: AccountStatus | null + try { + status = await accountStatus(c.env, account.accountId, Date.now()) + } catch (error) { + console.error('accounts: status read failed') + captureFailure(error, 'account status') + return refuse('internal') + } + if (status == null) return refuse('unauthorized') + if (status === 'suspended') return refuse('suspended') + } + c.set('account', account) + return { ok: true, account } +} + +/** + * Middleware for a bearer-only route: refuses the request unless it carries a + * valid access token (and, with `requireActive`, an active account). + */ +export const requireAccount = + (options: RequireAccountOptions = {}): MiddlewareHandler => + async (c, next) => { + const result = await authenticateAccount(c, options) + if (!result.ok) return result.response + await next() + } + +/** The caller `requireAccount` or `bearerOrLegacy` verified. */ +export const accountOf = (c: AppContext): AccountAuth => { + const account = c.get('account') + if (!account) throw new Error('accounts: route ran without requireAccount') + return account +} + +/** + * Runs a route converted to bearer tokens. A request with an `Authorization` + * header is a bearer request, always: its token must verify, and it never + * falls back to the envelope. A request without one is a legacy request, + * served by `legacy` while the legacy switch is on and refused with 426 + * `upgrade_required` once it's off. + */ +export const bearerOrLegacy = async ( + c: AppContext, + handlers: { + bearer: (account: AccountAuth) => Promise + legacy: () => Promise + }, + options: RequireAccountOptions = {} +): Promise => { + if (hasAuthorization(c)) { + const result = await authenticateAccount(c, options) + return result.ok ? handlers.bearer(result.account) : result.response + } + return (await refuseLegacyAuth(c.env)) ?? handlers.legacy() +} + +/** + * A plain JSON request body (a bearer request's fields), read under the + * envelope's size limit. Null for a missing, oversized, or unparsable body; + * never surfaces the parser's message, which quotes the input. + */ +export const readPlainJson = async (c: AppContext): Promise => { + const text = await readEnvelopeText(c.req.raw) + return parseJsonOrNull(text) +} + +export const parseJsonOrNull = (text: string | null): unknown => { + if (text == null) return null + try { + return JSON.parse(text) + } catch { + return null + } +} diff --git a/src/accounts/backup.ts b/src/accounts/backup.ts index 2512766..41d9b5d 100644 --- a/src/accounts/backup.ts +++ b/src/accounts/backup.ts @@ -4,19 +4,24 @@ import { readBlobBody } from '../buddies/envelope' import { buddiesErrorResponse } from '../buddies/errorResponse' import { ACCOUNT_LIMITS, + type AccountFields, + type AccountsOp, backupObjectKey, backupPrefix, - type AccountsOp, } from './contracts' +import { readPlainJson } from './auth' import { + accountCall, accountForKey, guarded, + plainFields, refuseAtEdge, verifiedBody, verifiedHeaders, + type AccountCallRead, + type BearerFields, type Verified, } from './signed' -import type { AccountRecord } from './store' /** * End-to-end encrypted backups (witness-work `docs/accounts-protocol.md`, @@ -26,6 +31,11 @@ import type { AccountRecord } from './store' * back up. R2's lifecycle rule deletes a slot 400 days after its last upload * (`scripts/r2-lifecycle.mjs`). * + * `put`, `list`, and `get` are bearer requests; `delete` is a bearer request + * that also carries the account key's envelope, signed by the token's own + * account, so a leaked token alone can't delete backups. While the legacy + * switch is on, each also takes the envelope alone, as before sessions. + * * `backup/put` counts against the Buddies photo-upload edge tier, the rest * against the read tier. * @@ -35,6 +45,12 @@ import type { AccountRecord } from './store' const { backupBytes, backupSlots, backupPutIntervalMs } = ACCOUNT_LIMITS +/** `backup/put`'s bearer form names its slot and digest in these headers. */ +export const BACKUP_PUT_HEADERS = { + slotId: 'x-ww-slot-id', + sha256: 'x-ww-sha256', +} as const + interface Slot { slotId: string size: number @@ -73,40 +89,43 @@ const listSlots = async ( } /** - * The bucket, the verified call, and its registered account; or the refusal. - * Off (`disabled`) without the bucket or the accounts database. + * Runs a backup op for its account: off (`disabled`) without the bucket or + * the accounts database, then the edge tier, then `accountCall`. */ -const authorized = async ( +const backupCall = async ( c: AppContext, edgeAs: 'blob/put' | 'inbox/sync', - verify: () => Promise> -): Promise< - | { - bucket: R2Bucket - verified: Exclude, { refusal: Response }> - account: AccountRecord - } - | { refusal: Response } -> => { + read: AccountCallRead, + run: ( + bucket: R2Bucket, + accountId: string, + fields: AccountFields + ) => Promise +): Promise => { const bucket = c.env.ACCOUNT_BACKUPS - if (!bucket || !c.env.ACCOUNTS_DB) - return { refusal: buddiesErrorResponse('disabled') } + if (!bucket || !c.env.ACCOUNTS_DB) return buddiesErrorResponse('disabled') const limited = await refuseAtEdge(c, edgeAs) - if (limited) return { refusal: limited } - const verified = await verify() - if ('refusal' in verified) return verified - const found = await accountForKey(c.env, verified.payload.accountPub) - if ('refusal' in found) return found - return { bucket, verified, account: found.account } + if (limited) return limited + return accountCall(c, read, (accountId, fields) => + run(bucket, accountId, fields) + ) } +/** A bearer request's fields from its JSON body. */ +const jsonFields = + (c: AppContext, op: K) => + async (): Promise> => + plainFields(op, await readPlainJson(c)) + const hex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') /** - * `backup/put`: the envelope in `x-ww-payload`/`x-ww-signature`, the blob as the raw body. - * A declared `Content-Length` over the cap is refused before anything is - * read; otherwise the body is read up to the signed `size` and checked + * `backup/put`: the blob as the raw body, with `x-ww-slot-id`, `x-ww-sha256`, + * and `Content-Length` (bearer) or the envelope in `x-ww-payload`/ + * `x-ww-signature` (legacy; older builds send `x-ww-p`/`x-ww-s`). A declared + * `Content-Length` over the cap is refused before anything is read; + * otherwise the body is read up to `size` and checked * against `size` and `sha256` before it replaces the slot. */ const handlePut = guarded('backup/put', async (c) => { @@ -115,89 +134,130 @@ const handlePut = guarded('backup/put', async (c) => { return c.env.ACCOUNT_BACKUPS && c.env.ACCOUNTS_DB ? buddiesErrorResponse('too_large') : buddiesErrorResponse('disabled') - const call = await authorized(c, 'blob/put', () => - verifiedHeaders(c, 'backup/put') - ) - if ('refusal' in call) return call.refusal - const { bucket, account } = call - const { slotId, size, sha256 } = call.verified.fields - if (size > backupBytes) return buddiesErrorResponse('too_large') - - const now = Date.now() - const slots = await listSlots(bucket, account.id) - const existing = slots.find((slot) => slot.slotId === slotId) - if (existing) { - const wait = existing.uploadedAt + backupPutIntervalMs - now - if (wait > 0) - return buddiesErrorResponse('rate_limited', { - retryAfterSeconds: Math.max(1, Math.ceil(wait / 1_000)), - }) - } else if (slots.length >= backupSlots) { - return buddiesErrorResponse('limit') - } + return backupCall( + c, + 'blob/put', + { + bearer: async () => + plainFields('backup/put', { + slotId: c.req.header(BACKUP_PUT_HEADERS.slotId), + sha256: c.req.header(BACKUP_PUT_HEADERS.sha256), + size: declared == null ? undefined : Number(declared), + }), + legacy: () => verifiedHeaders(c, 'backup/put'), + }, + async (bucket, accountId, { slotId, size, sha256 }) => { + if (size > backupBytes) return buddiesErrorResponse('too_large') - const body = await readBlobBody(c.req.raw, size, backupBytes) - if (!body.ok) return buddiesErrorResponse(body.error) - const digest = await sha256Bytes(body.value) - if (!timingSafeEqual(bytesToBase64Url(digest), sha256)) - return buddiesErrorResponse('bad_request') - - await bucket.put(backupObjectKey(account.id, slotId), body.value, { - sha256: hex(digest), - httpMetadata: { contentType: 'application/octet-stream' }, - customMetadata: { uploadedAt: String(now) }, - }) - return c.json({ ok: true, uploadedAt: now }) -}) + const now = Date.now() + const slots = await listSlots(bucket, accountId) + const existing = slots.find((slot) => slot.slotId === slotId) + if (existing) { + const wait = existing.uploadedAt + backupPutIntervalMs - now + if (wait > 0) + return buddiesErrorResponse('rate_limited', { + retryAfterSeconds: Math.max(1, Math.ceil(wait / 1_000)), + }) + } else if (slots.length >= backupSlots) { + return buddiesErrorResponse('limit') + } -const handleList = guarded('backup/list', async (c) => { - const call = await authorized(c, 'inbox/sync', () => - verifiedBody(c, 'backup/list') + const body = await readBlobBody(c.req.raw, size, backupBytes) + if (!body.ok) return buddiesErrorResponse(body.error) + const digest = await sha256Bytes(body.value) + if (!timingSafeEqual(bytesToBase64Url(digest), sha256)) + return buddiesErrorResponse('bad_request') + + await bucket.put(backupObjectKey(accountId, slotId), body.value, { + sha256: hex(digest), + httpMetadata: { contentType: 'application/octet-stream' }, + customMetadata: { uploadedAt: String(now) }, + }) + return c.json({ ok: true, uploadedAt: now }) + } ) - if ('refusal' in call) return call.refusal - const slots = await listSlots(call.bucket, call.account.id) - return c.json({ ok: true, slots }) }) +const handleList = guarded('backup/list', async (c) => + backupCall( + c, + 'inbox/sync', + { + bearer: async () => ({ fields: {} }), + legacy: () => verifiedBody(c, 'backup/list'), + }, + async (bucket, accountId) => + c.json({ ok: true, slots: await listSlots(bucket, accountId) }) + ) +) + /** Streams the slot's bytes back exactly as put. */ -const handleGet = guarded('backup/get', async (c) => { - const call = await authorized(c, 'inbox/sync', () => - verifiedBody(c, 'backup/get') +const handleGet = guarded('backup/get', async (c) => + backupCall( + c, + 'inbox/sync', + { + bearer: jsonFields(c, 'backup/get'), + legacy: () => verifiedBody(c, 'backup/get'), + }, + async (bucket, accountId, { slotId }) => { + const object = await bucket.get(backupObjectKey(accountId, slotId)) + if (!object) return buddiesErrorResponse('not_found') + return new Response(object.body, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(object.size), + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }) + } ) - if ('refusal' in call) return call.refusal - const object = await call.bucket.get( - backupObjectKey(call.account.id, call.verified.fields.slotId) +) + +/** + * The bearer form of `backup/delete`: the envelope must verify and be signed + * by the token's own account (`forbidden` otherwise). + */ +const stepUp = async ( + c: AppContext, + accountId: string +): Promise> => { + const verified: Verified<'backup/delete'> = await verifiedBody( + c, + 'backup/delete' ) - if (!object) return buddiesErrorResponse('not_found') - return new Response(object.body, { - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Length': String(object.size), - 'Cache-Control': 'no-store', - 'X-Content-Type-Options': 'nosniff', - }, - }) -}) + if ('refusal' in verified) return verified + const found = await accountForKey(c.env, verified.payload.accountPub) + if ('refusal' in found) return found + return found.account.id === accountId + ? { fields: verified.fields } + : { refusal: buddiesErrorResponse('forbidden') } +} /** One slot, or every slot without `slotId`. Idempotent. */ -const handleDelete = guarded('backup/delete', async (c) => { - const call = await authorized(c, 'inbox/sync', () => - verifiedBody(c, 'backup/delete') +const handleDelete = guarded('backup/delete', async (c) => + backupCall( + c, + 'inbox/sync', + { + bearer: (account) => stepUp(c, account.accountId), + legacy: () => verifiedBody(c, 'backup/delete'), + }, + async (bucket, accountId, { slotId }) => { + const keys = + slotId != null + ? [backupObjectKey(accountId, slotId)] + : (await listSlots(bucket, accountId)).map((slot) => + backupObjectKey(accountId, slot.slotId) + ) + // R2 deletes at most 1,000 keys a call. + for (let start = 0; start < keys.length; start += 1_000) + await bucket.delete(keys.slice(start, start + 1_000)) + return c.json({ ok: true }) + } ) - if ('refusal' in call) return call.refusal - const { bucket, account } = call - const { slotId } = call.verified.fields - const keys = - slotId != null - ? [backupObjectKey(account.id, slotId)] - : (await listSlots(bucket, account.id)).map((slot) => - backupObjectKey(account.id, slot.slotId) - ) - // R2 deletes at most 1,000 keys a call. - for (let start = 0; start < keys.length; start += 1_000) - await bucket.delete(keys.slice(start, start + 1_000)) - return c.json({ ok: true }) -}) +) export const backupHandlers = { put: handlePut, diff --git a/src/accounts/bearerRoutes.test.ts b/src/accounts/bearerRoutes.test.ts new file mode 100644 index 0000000..63db7b5 --- /dev/null +++ b/src/accounts/bearerRoutes.test.ts @@ -0,0 +1,510 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SigningKey, + b64u, + blobOfSize, + createHarness, + randomBytes, + randomId, + sha256B64u, + type Harness, +} from '../test/buddies' +import { + accountEnvelope, + bearer, + nextTestIp, + openTestSession, + postJson, + requestJson, + type TestSession, +} from '../test/accounts' +import { resetAccountStatusCache } from './auth' +import { createAccountsRoutes } from './route' + +/** + * The account routes converted to bearer tokens (witness-work ADR 0027): + * bearer requests with plain JSON, the legacy envelope while the legacy + * switch is on, and 426 once it's off. The legacy paths' own behavior is in + * accounts.test.ts, handoff.test.ts, and backup.test.ts, which run with the + * switch on. + */ + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + ctx: DurableObjectState + env: unknown + constructor(ctx: DurableObjectState, env: unknown) { + this.ctx = ctx + this.env = env + } + }, +})) + +const START = Date.UTC(2026, 9, 15, 12) +const app = createAccountsRoutes() as never + +let h: Harness + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(START) + h = await createHarness() + resetAccountStatusCache() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +const post = (path: string, body: unknown, headers?: Record) => + postJson(app, h.env, path, body, headers) +const session = (key?: SigningKey) => openTestSession(app, h.env, key) +const legacyOff = () => h.kv.put('auth:legacy', 'off') + +describe('GET /account', () => { + const read = (headers?: Record) => + requestJson(app, h.env, 'GET', '/account', undefined, headers) + + it('reads the token’s account, with or without the legacy switch', async () => { + const s = await session() + const first = await read(bearer(s.accessToken)) + expect(first.status).toBe(200) + expect(first.body).toMatchObject({ + ok: true, + accountId: s.accountId, + accountPub: s.key.publicKey, + }) + await legacyOff() + expect((await read(bearer(s.accessToken))).status).toBe(200) + }) + + it('is bearer only: no token is 401, never a legacy request', async () => { + const missing = await read() + expect(missing.status).toBe(401) + expect(missing.body.code).toBe('unauthorized') + expect(missing.headers.get('www-authenticate')).toBe( + 'Bearer error="invalid_token"' + ) + expect((await read(bearer('expired.or.forged'))).status).toBe(401) + }) + + it('answers disabled without the accounts database', async () => { + const s = await session() + delete h.env.ACCOUNTS_DB + const refused = await read(bearer(s.accessToken)) + expect(refused.status).toBe(503) + expect(refused.body.code).toBe('disabled') + }) +}) + +describe('account/info and account/register', () => { + it('answers a bearer request without a body, and ignores one', async () => { + const s = await session() + for (const body of [undefined, {}, await accountEnvelope('account/info', await SigningKey.generate())]) { + const read = await post('/account/info', body, bearer(s.accessToken)) + expect(read.status).toBe(200) + expect(read.body.accountId).toBe(s.accountId) + } + }) + + it('never falls back to the envelope when the token is bad', async () => { + const s = await session() + const refused = await post( + '/account/info', + await accountEnvelope('account/info', s.key), + bearer('expired.or.forged') + ) + expect(refused.status).toBe(401) + expect(refused.body.code).toBe('unauthorized') + }) + + it('refuses the envelope forms with 426 once the legacy switch is off', async () => { + const s = await session() + await legacyOff() + const info = await post('/account/info', await accountEnvelope('account/info', s.key)) + expect(info.status).toBe(426) + expect(info.body.code).toBe('upgrade_required') + const register = await post( + '/account/register', + await accountEnvelope('account/register', await SigningKey.generate()) + ) + expect(register.status).toBe(426) + // Sessions and bearer requests carry on. + expect((await post('/account/info', undefined, bearer(s.accessToken))).status).toBe(200) + await session() + }) + + it('follows AUTH_LEGACY when the KV key is absent', async () => { + h.env.AUTH_LEGACY = 'off' + const refused = await post( + '/account/register', + await accountEnvelope('account/register', await SigningKey.generate()) + ) + expect(refused.status).toBe(426) + await h.kv.put('auth:legacy', 'on') + const allowed = await post( + '/account/register', + await accountEnvelope('account/register', await SigningKey.generate()) + ) + expect(allowed.status).toBe(200) + }) +}) + +// --- Handoff ----------------------------------------------------------------- + +const newChannel = async () => { + const token = randomBytes(16) + return { + channelId: randomId(), + claimToken: b64u(token), + verifier: await sha256B64u(token), + } +} +type Channel = Awaited> + +const claim = async (ch: Channel, headers?: Record) => + post( + '/handoff/claim', + { + channelId: ch.channelId, + claimToken: ch.claimToken, + claimPub: (await SigningKey.generate()).publicKey, + hello: blobOfSize(200), + }, + headers + ) + +describe('handoff, old phone with a bearer token', () => { + it('opens, reads, sends, and the new phone receives', async () => { + const s = await session() + const ch = await newChannel() + const auth = bearer(s.accessToken) + expect( + await post('/handoff/open', { channelId: ch.channelId, verifier: ch.verifier }, auth) + ).toMatchObject({ status: 200, body: { ok: true, expiresAt: START + 10 * 60_000 } }) + expect((await post('/handoff/status', { channelId: ch.channelId }, auth)).body).toMatchObject({ + ok: true, + status: 'open', + }) + expect((await claim(ch)).status).toBe(200) + expect((await post('/handoff/status', { channelId: ch.channelId }, auth)).body.status).toBe( + 'claimed' + ) + const sealedKey = blobOfSize(80) + expect( + (await post('/handoff/send', { channelId: ch.channelId, key: sealedKey }, auth)).status + ).toBe(200) + const received = await post('/handoff/receive', { + channelId: ch.channelId, + claimToken: ch.claimToken, + }) + expect(received.body).toEqual({ ok: true, status: 'sent', key: sealedKey }) + }) + + it('is the same channel owner whichever way the old phone authenticates', async () => { + const s = await session() + const ch = await newChannel() + await post( + '/handoff/open', + { channelId: ch.channelId, verifier: ch.verifier }, + bearer(s.accessToken) + ) + // The envelope form, signed by the same account key, reads it too. + const legacy = await post( + '/handoff/status', + await accountEnvelope('handoff/status', s.key, { channelId: ch.channelId }) + ) + expect(legacy.body.status).toBe('open') + // Another account's token can't. + const other = await session() + const refused = await post( + '/handoff/status', + { channelId: ch.channelId }, + bearer(other.accessToken) + ) + expect(refused.status).toBe(403) + expect(refused.body.code).toBe('forbidden') + }) + + it('checks the plain JSON fields', async () => { + const s = await session() + const auth = bearer(s.accessToken) + expect((await post('/handoff/open', { channelId: 'short' }, auth)).status).toBe(400) + expect((await post('/handoff/status', 'not json', auth)).status).toBe(400) + expect( + (await post('/handoff/send', { channelId: randomId(), key: 'A'.repeat(1_025) }, auth)).body + .code + ).toBe('too_large') + // An envelope is not a bearer request's body. + const envelope = await accountEnvelope('handoff/status', s.key, { channelId: randomId() }) + expect((await post('/handoff/status', envelope, auth)).status).toBe(400) + }) + + it('counts handoff/open against the token’s account', async () => { + const s = await session() + for (let i = 0; i < 20; i++) { + const ch = await newChannel() + const opened = await post( + '/handoff/open', + { channelId: ch.channelId, verifier: ch.verifier }, + bearer(s.accessToken) + ) + expect(opened.status).toBe(200) + } + const ch = await newChannel() + // The same account through its envelope shares the quota. + const refused = await post( + '/handoff/open', + await accountEnvelope('handoff/open', s.key, { + channelId: ch.channelId, + verifier: ch.verifier, + }) + ) + expect(refused.status).toBe(429) + }) + + it('tells the phones apart on handoff/cancel', async () => { + const owner = await session() + const ch = await newChannel() + await post( + '/handoff/open', + { channelId: ch.channelId, verifier: ch.verifier }, + bearer(owner.accessToken) + ) + // The new phone may have a session of its own: claimToken still decides. + const newPhone = await session() + expect((await claim(ch, bearer(newPhone.accessToken))).status).toBe(200) + const byToken = await post( + '/handoff/cancel', + { channelId: ch.channelId, claimToken: ch.claimToken }, + bearer(newPhone.accessToken) + ) + expect(byToken).toMatchObject({ status: 200, body: { ok: true } }) + + // The old phone cancels with its token and no claimToken. + const second = await newChannel() + await post( + '/handoff/open', + { channelId: second.channelId, verifier: second.verifier }, + bearer(owner.accessToken) + ) + const stranger = await post( + '/handoff/cancel', + { channelId: second.channelId }, + bearer(newPhone.accessToken) + ) + expect(stranger.status).toBe(403) + const byOwner = await post( + '/handoff/cancel', + { channelId: second.channelId }, + bearer(owner.accessToken) + ) + expect(byOwner.status).toBe(200) + const status = await post( + '/handoff/status', + { channelId: second.channelId }, + bearer(owner.accessToken) + ) + expect(status.body.status).toBe('cancelled') + + // Without either, it's neither phone's. + expect((await post('/handoff/cancel', { channelId: second.channelId })).status).toBe(400) + }) + + it('refuses the old phone’s envelopes with 426 once the legacy switch is off', async () => { + const s = await session() + const ch = await newChannel() + await legacyOff() + for (const [op, fields] of [ + ['handoff/open', { channelId: ch.channelId, verifier: ch.verifier }], + ['handoff/status', { channelId: ch.channelId }], + ['handoff/send', { channelId: ch.channelId, key: blobOfSize(80) }], + ['handoff/cancel', { channelId: ch.channelId }], + ] as const) { + const refused = await post(`/${op}`, await accountEnvelope(op, s.key, fields)) + expect(refused.status, op).toBe(426) + expect(refused.body.code).toBe('upgrade_required') + } + // The new phone's claimToken ops are unchanged. + await post('/handoff/open', { channelId: ch.channelId, verifier: ch.verifier }, bearer(s.accessToken)) + expect((await claim(ch)).status).toBe(200) + expect( + (await post('/handoff/cancel', { channelId: ch.channelId, claimToken: ch.claimToken })).status + ).toBe(200) + }) +}) + +// --- Backup ------------------------------------------------------------------ + +const putBearer = async ( + s: TestSession, + options: { + slotId?: string + bytes?: Uint8Array + headers?: Record + } = {} +) => { + const bytes = options.bytes ?? randomBytes(4_096) + const headers: Record = { + ...bearer(s.accessToken), + 'content-type': 'application/octet-stream', + 'cf-connecting-ip': nextTestIp(), + 'x-ww-slot-id': options.slotId ?? randomId(), + 'x-ww-sha256': await sha256B64u(bytes), + 'content-length': String(bytes.byteLength), + } + for (const [name, value] of Object.entries(options.headers ?? {})) { + if (value == null) delete headers[name] + else headers[name] = value + } + const response = await (app as { request: typeof fetch }).request( + '/backup/put', + { method: 'POST', headers, body: bytes, duplex: 'half' } as RequestInit, + h.env as never + ) + return { status: response.status, body: await response.json() } +} + +describe('backup with a bearer token', () => { + it('puts, lists, gets, and deletes (with the account key’s signature)', async () => { + const s = await session() + const slotId = randomId() + const bytes = randomBytes(10_000) + expect(await putBearer(s, { slotId, bytes })).toEqual({ + status: 200, + body: { ok: true, uploadedAt: START }, + }) + expect(h.backups.objects.get(`v1/${s.accountId}/${slotId}`)?.bytes).toEqual(bytes) + + const listed = await post('/backup/list', undefined, bearer(s.accessToken)) + expect(listed.body).toEqual({ + ok: true, + slots: [{ slotId, size: 10_000, uploadedAt: START }], + }) + + const got = await (app as { request: typeof fetch }).request( + '/backup/get', + { + method: 'POST', + headers: { ...bearer(s.accessToken), 'content-type': 'application/json' }, + body: JSON.stringify({ slotId }), + }, + h.env as never + ) + expect(got.status).toBe(200) + expect(got.headers.get('cache-control')).toBe('no-store') + expect(new Uint8Array(await got.arrayBuffer())).toEqual(bytes) + + const deleted = await post( + '/backup/delete', + await accountEnvelope('backup/delete', s.key, { slotId }), + bearer(s.accessToken) + ) + expect(deleted).toMatchObject({ status: 200, body: { ok: true } }) + expect(h.backups.objects.size).toBe(0) + }) + + it('needs the slot, digest, and length headers on put', async () => { + const s = await session() + const slotId = randomId() + for (const headers of [ + { 'x-ww-slot-id': null }, + { 'x-ww-slot-id': 'short' }, + { 'x-ww-sha256': null }, + { 'x-ww-sha256': b64u(randomBytes(32)) }, + ]) { + const refused = await putBearer(s, { slotId, headers }) + expect(refused.status, JSON.stringify(headers)).toBe(400) + expect(refused.body.code).toBe('bad_request') + } + const huge = await putBearer(s, { headers: { 'content-length': String(21 * 1_024 * 1_024) } }) + expect(huge.status).toBe(413) + expect(h.backups.objects.size).toBe(0) + }) + + it('keeps the same limits as the envelope form', async () => { + const s = await session() + const slotId = randomId() + expect((await putBearer(s, { slotId })).status).toBe(200) + const again = await putBearer(s, { slotId }) + expect(again.status).toBe(429) + expect(again.body.code).toBe('rate_limited') + }) + + it('refuses a delete without the envelope, or signed by another account', async () => { + const s = await session() + const slotId = randomId() + await putBearer(s, { slotId }) + + const tokenOnly = await post('/backup/delete', { slotId }, bearer(s.accessToken)) + expect(tokenOnly.status).toBe(400) + + const other = await session() + const mismatched = await post( + '/backup/delete', + await accountEnvelope('backup/delete', other.key, { slotId }), + bearer(s.accessToken) + ) + expect(mismatched.status).toBe(403) + expect(mismatched.body.code).toBe('forbidden') + + const forged = await post( + '/backup/delete', + await accountEnvelope('backup/delete', s.key, { slotId }, other.key), + bearer(s.accessToken) + ) + expect(forged.status).toBe(401) + expect(forged.body.code).toBe('bad_signature') + + const badToken = await post( + '/backup/delete', + await accountEnvelope('backup/delete', s.key, { slotId }), + bearer('not.a.token') + ) + expect(badToken.status).toBe(401) + expect(badToken.body.code).toBe('unauthorized') + expect(h.backups.objects.size).toBe(1) + }) + + it('refuses the envelope forms with 426 once the legacy switch is off', async () => { + const s = await session() + await legacyOff() + const bytes = randomBytes(64) + const envelope = await accountEnvelope('backup/put', s.key, { + slotId: randomId(), + size: bytes.byteLength, + sha256: await sha256B64u(bytes), + }) + const put = await (app as { request: typeof fetch }).request( + '/backup/put', + { + method: 'POST', + headers: { + 'x-ww-payload': envelope.payload, + 'x-ww-signature': envelope.signature, + 'content-length': String(bytes.byteLength), + }, + body: bytes, + duplex: 'half', + } as RequestInit, + h.env as never + ) + expect(put.status).toBe(426) + for (const op of ['backup/list', 'backup/get', 'backup/delete'] as const) { + const refused = await post(`/${op}`, await accountEnvelope(op, s.key, { slotId: randomId() })) + expect(refused.status, op).toBe(426) + } + // Bearer requests carry on, and delete's step-up still uses its envelope. + expect((await putBearer(s)).status).toBe(200) + expect( + ( + await post( + '/backup/delete', + await accountEnvelope('backup/delete', s.key), + bearer(s.accessToken) + ) + ).status + ).toBe(200) + }) +}) diff --git a/src/accounts/contracts.ts b/src/accounts/contracts.ts index b429498..2ef44ae 100644 --- a/src/accounts/contracts.ts +++ b/src/accounts/contracts.ts @@ -1,4 +1,4 @@ -import { bytesToBase64Url } from '../crypto' +import { bytesToBase64Url, sha256Hex } from '../crypto' import { BUDDIES_ID_PATTERN, decodeB64u, @@ -27,6 +27,7 @@ export const ACCOUNTS_OPS = [ 'backup/list', 'backup/get', 'backup/delete', + 'session/open', ] as const export type AccountsOp = (typeof ACCOUNTS_OPS)[number] @@ -64,6 +65,20 @@ export const ACCOUNT_LIMITS = { backupSlots: 8, /** One `backup/put` per slot this often. */ backupPutIntervalMs: MINUTE_MS, + /** An access token lives this long. */ + accessTokenMs: 15 * MINUTE_MS, + /** A session lives this long from opening; refreshing never extends it. */ + sessionMs: 90 * DAY_MS, + /** The refresh token just replaced still works this long (a lost response). */ + refreshGraceMs: MINUTE_MS, + /** Live sessions per account; opening another ends the oldest. */ + sessionsPerAccount: 10, + /** Sessions opened per account key per rolling day. */ + sessionOpensPerDay: 20, + /** The sealed device label, b64u characters. */ + sessionDeviceChars: 512, + /** Ended sessions (revoked or expired) are deleted after this long. */ + endedSessionRetentionMs: 7 * DAY_MS, } as const /** A slot's R2 object key; `v1//` lists an account's slots. */ @@ -184,6 +199,17 @@ export interface BackupDeleteFields { slotId: string | null } +export interface SessionOpenFields { + /** + * The answer to a `POST /attestation/v1/challenge` challenge for purpose + * `account-session`; checked by `src/accounts/attestation.ts`. Null only + * when the request leaves it out (the development bypass). + */ + attestation: Record | null + /** The sealed device label, stored opaque. */ + device: string | null +} + type Fields = Record /** @@ -231,6 +257,14 @@ export const ACCOUNT_FIELD_PARSERS = { const slotId = id16(p.slotId) return slotId ? { slotId } : null }, + 'session/open': (p: Fields): SessionOpenFields | null => { + const attestation = p.attestation ?? null + if (attestation !== null && !isPlainObject(attestation)) return null + if (p.device === undefined || p.device === null) + return { attestation, device: null } + const device = sealed(p.device, ACCOUNT_LIMITS.sessionDeviceChars) + return device ? { attestation, device } : null + }, } satisfies Record object | null> export type AccountFields = NonNullable< @@ -267,13 +301,69 @@ export const parseHandoffClaimRequest = ( return claimPub && hello ? { ...base, claimPub, hello } : null } +const over = (value: unknown, max: number) => + typeof value === 'string' && value.length > max + /** A sealed blob's cap broken, for `too_large` instead of `bad_request`. */ export const handoffBlobTooLarge = (body: unknown): boolean => { if (!isPlainObject(body)) return false - const over = (value: unknown, max: number) => - typeof value === 'string' && value.length > max return ( over(body.hello, ACCOUNT_LIMITS.handoffHelloChars) || over(body.key, ACCOUNT_LIMITS.handoffKeyChars) ) } + +/** The session's sealed device label over its cap (`too_large`). */ +export const sessionDeviceTooLarge = (payload: unknown): boolean => + isPlainObject(payload) && + over(payload.device, ACCOUNT_LIMITS.sessionDeviceChars) + +// --- Sessions ---------------------------------------------------------------- + +/** + * What an `account-session` attestation's `contentHash` and `requestHash` + * must both be (on `POST /attestation/v1/challenge` and in the session's + * `attestation`), so it covers the account key: lowercase hex + * `SHA-256(UTF-8("ww-account/v1/session\n" + accountPub))`. + */ +export const sessionAttestationHash = (accountPub: string): Promise => + sha256Hex(`ww-account/v1/session\n${accountPub}`) + +/** A refresh token as presented, in its parts. */ +export interface RefreshToken { + /** The session it names. */ + sessionId: string + /** `.`, what its MAC covers. */ + body: string + /** The MAC's 32 bytes. */ + mac: Uint8Array + /** The whole token. */ + token: string +} + +/** The bytes of b64u for exactly `size` bytes, in its canonical spelling. */ +const canonicalB64u = (value: string, size: number): Uint8Array | null => { + const bytes = decodeB64u(value) + return bytes != null && + bytes.length === size && + bytesToBase64Url(bytes) === value + ? bytes + : null +} + +/** + * A refresh token, opaque to the app: `..`, the + * session's id (b64u, 16 bytes), then b64u of 32 bytes twice, each in its + * canonical spelling. Null for anything else, including the two-part + * `.` tokens from before the MAC. + */ +export const parseRefreshToken = (value: unknown): RefreshToken | null => { + if (typeof value !== 'string') return null + const parts = value.split('.') + if (parts.length !== 3) return null + const [sessionId, secret, mac] = parts + const macBytes = canonicalB64u(mac, 32) + return canonicalB64u(sessionId, 16) && canonicalB64u(secret, 32) && macBytes + ? { sessionId, body: `${sessionId}.${secret}`, mac: macBytes, token: value } + : null +} diff --git a/src/accounts/databaseErrors.test.ts b/src/accounts/databaseErrors.test.ts new file mode 100644 index 0000000..289ed3a --- /dev/null +++ b/src/accounts/databaseErrors.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' +import type { AppEnv } from '../types' +import { + SigningKey, + b64u, + createHarness, + envelope, + randomId, + type Harness, +} from '../test/buddies' +import { bearer, openTestSession, postJson } from '../test/accounts' +import { failQueries } from '../test/memoryD1' +import { recordSentryEvents, type SentryRecorder } from '../test/sentry' +import { inboxLinkMessage } from './contracts' +import { accountOf, requireAccount, resetAccountStatusCache } from './auth' +import { createAccountsRoutes } from './route' +import { app } from '../app' + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + ctx: DurableObjectState + env: unknown + constructor(ctx: DurableObjectState, env: unknown) { + this.ctx = ctx + this.env = env + } + }, +})) + +/** + * Database errors reach Sentry as the operation that failed, never as + * Drizzle's message, which quotes the query's parameters (account ids, + * session ids, refresh token hashes, inbox ids). + */ + +const START = Date.UTC(2026, 9, 16, 12) +const accounts = createAccountsRoutes() as never +const active = new Hono().post( + '/active', + requireAccount({ requireActive: true }), + (c) => c.json(accountOf(c)) +) as never + +let h: Harness +let recorder: SentryRecorder + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(START) + h = await createHarness() + resetAccountStatusCache() + recorder = recordSentryEvents() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(() => { + recorder.stop() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +/** The one event reported, as Sentry would get it, and its JSON. */ +const reported = async () => { + const events = await recorder.events() + expect(events).toHaveLength(1) + return { event: events[0], json: JSON.stringify(events[0]) } +} + +const expectOperationOnly = async (operation: string, secrets: string[]) => { + const { event, json } = await reported() + expect(event.exception?.values).toEqual([ + expect.objectContaining({ + type: 'DatabaseError', + value: `database query failed: ${operation}`, + }), + ]) + expect(json).not.toContain('Failed query') + for (const secret of secrets) expect(json).not.toContain(secret) +} + +const sessionRow = () => + h.accountsDb!.db.prepare('SELECT * FROM account_sessions').get() as Record< + string, + string + > + +/** A bearer `inbox/register` linking a new inbox to the session's account. */ +const registerInbox = async ( + session: Awaited>, + headers: Record = bearer(session.accessToken) +) => { + const key = await SigningKey.generate() + const inboxId = randomId() + const signature = await session.key.sign( + inboxLinkMessage(session.accountId, inboxId, key.publicKey) + ) + const response = await h.post( + '/inbox/register', + await envelope( + 'inbox/register', + { + inboxId, + ownerPub: key.publicKey, + account: { accountId: session.accountId, signature: b64u(signature) }, + ts: Date.now(), + nonce: randomId(), + }, + key + ), + headers + ) + return { inboxId, response } +} + +describe('database errors in Sentry', () => { + it('session/refresh reports the op, not the token hash or ids', async () => { + const session = await openTestSession(accounts, h.env) + const row = sessionRow() + failQueries(h.accountsDb!, /from "account_sessions"/) + const response = await postJson(accounts, h.env, '/sessions/refresh', { + refreshToken: session.refreshToken, + }) + expect(response).toMatchObject({ + status: 500, + body: { ok: false, error: 'internal', code: 'internal' }, + }) + await expectOperationOnly('session/refresh', [ + row.refresh_hash, + row.id, + row.account_id, + session.refreshToken, + ]) + }) + + it('a bearer inbox/register reports the op, not the inbox or account', async () => { + const session = await openTestSession(accounts, h.env) + failQueries(h.accountsDb!, /^insert into "account_inboxes"/) + const { inboxId, response } = await registerInbox(session) + expect(response.status).toBe(500) + await expectOperationOnly('inbox/register', [inboxId, session.accountId]) + }) + + it('a legacy inbox/register that registers unlinked still reports the op only', async () => { + const session = await openTestSession(accounts, h.env) + failQueries(h.accountsDb!, /from "account_inboxes"/) + const { inboxId, response } = await registerInbox(session, {}) + expect(response).toEqual({ status: 200, body: { ok: true } }) + await expectOperationOnly('inbox/register', [inboxId, session.accountId]) + }) + + it('a route without its own guard reports the route, not the query', async () => { + const session = await openTestSession(accounts, h.env) + Object.assign(h.env, { + ADMIN_API_TOKEN: 'admin-secret-for-tests', + RATE_LIMITER: { limit: async () => ({ success: true }) }, + }) + failQueries(h.accountsDb!, /from "accounts"/) + const response = await app.request( + '/admin/accounts/status', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'cf-connecting-ip': '203.0.113.7', + 'x-ww-admin-token': 'admin-secret-for-tests', + }, + body: JSON.stringify({ + accountId: session.accountId, + status: 'suspended', + reason: 'report', + }), + }, + h.env + ) + expect(response.status).toBe(500) + await expectOperationOnly('POST /admin/accounts/status', [session.accountId]) + // The log line is the same redacted error. + const logged = JSON.stringify( + vi.mocked(console.error).mock.calls.map((args) => String(args[1])) + ) + expect(logged).toContain('database query failed') + expect(logged).not.toContain(session.accountId) + }) + + it('the account status read (requireActive) reports a label, not the account', async () => { + const session = await openTestSession(accounts, h.env) + failQueries(h.accountsDb!, /from "accounts"/) + const response = await postJson( + active, + h.env, + '/active', + {}, + bearer(session.accessToken) + ) + expect(response.status).toBe(500) + await expectOperationOnly('account status', [session.accountId]) + }) +}) diff --git a/src/accounts/handoff.ts b/src/accounts/handoff.ts index 1beae9c..1db2208 100644 --- a/src/accounts/handoff.ts +++ b/src/accounts/handoff.ts @@ -8,18 +8,20 @@ import { import { buddiesErrorResponse } from '../buddies/errorResponse' import { ACCOUNT_LIMITS, + type AccountFields, handoffBlobTooLarge, parseHandoffClaimRequest, parseHandoffTokenRequest, } from './contracts' +import { hasAuthorization, parseJsonOrNull, readPlainJson } from './auth' import { - accountForKey, + accountCall, guarded, + plainFields, quotaName, refuseAtEdge, refuseOverDailyQuota, replyWith, - verifiedBody, verifyEnvelope, } from './signed' @@ -28,8 +30,10 @@ import { * the old phone opens a channel and the new phone claims it with the token * from the QR code; the server relays a sealed hello one way and the sealed * account key the other, through one `AccountHandoff` Durable Object per - * channel. Old-phone ops are signed by the account key and need a registered - * account; new-phone ops are plain JSON authorized by `claimToken`. + * channel. Old-phone ops are bearer requests with plain JSON fields (or, + * while the legacy switch is on, envelopes signed by the account key of a + * registered account); new-phone ops are plain JSON authorized by + * `claimToken`, with or without a session. * * `handoff/open` counts against the register edge tier and 20 opens per * account a rolling day; every other op against the read tier. @@ -38,6 +42,8 @@ import { * channel ids. */ +type OldPhoneOp = 'handoff/open' | 'handoff/status' | 'handoff/send' | 'handoff/cancel' + const channel = (env: Environment, channelId: string) => env.ACCOUNT_HANDOFF.get(env.ACCOUNT_HANDOFF.idFromName(channelId)) @@ -50,76 +56,81 @@ const precheck = async ( return refuseAtEdge(c, tier) } -/** The new phone's JSON body, or null for `bad_request`. */ -const readJson = async (c: AppContext): Promise => { - const text = await readEnvelopeText(c.req.raw) - if (text == null) return null - try { - return JSON.parse(text) - } catch { - // Never surface the parser message: V8 quotes the offending input. - return null - } -} +/** + * The old phone's account and fields for `op`, from the token and the JSON + * body `text`, or from the envelope `text` holds (legacy). + */ +const oldPhone = ( + c: AppContext, + op: K, + text: string | null, + run: (accountId: string, fields: AccountFields) => Promise +): Promise => + accountCall( + c, + { + bearer: async () => { + const body = parseJsonOrNull(text) + if (handoffBlobTooLarge(body)) + return { refusal: buddiesErrorResponse('too_large') } + return plainFields(op, body) + }, + legacy: () => + verifyEnvelope(op, text == null ? null : parseEnvelope(text)), + }, + run + ) const handleOpen = guarded('handoff/open', async (c) => { const refused = await precheck(c, 'inbox/register') if (refused) return refused - const verified = await verifiedBody(c, 'handoff/open') - if ('refusal' in verified) return verified.refusal - const found = await accountForKey(c.env, verified.payload.accountPub) - if ('refusal' in found) return found.refusal - const overQuota = await refuseOverDailyQuota( - c.env, - await quotaName('handoff-open', found.account.id), - ACCOUNT_LIMITS.handoffOpensPerDay, - 'handoff/open', - 'handoffOpensPerDay' - ) - if (overQuota) return overQuota - const { channelId, verifier } = verified.fields - return replyWith( - c, - await channel(c.env, channelId).open({ - channelId, - accountId: found.account.id, - verifier, - }) - ) + const text = await readEnvelopeText(c.req.raw) + return oldPhone(c, 'handoff/open', text, async (accountId, fields) => { + const overQuota = await refuseOverDailyQuota( + c.env, + await quotaName('handoff-open', accountId), + ACCOUNT_LIMITS.handoffOpensPerDay, + 'handoff/open', + 'handoffOpensPerDay' + ) + if (overQuota) return overQuota + const { channelId, verifier } = fields + return replyWith( + c, + await channel(c.env, channelId).open({ channelId, accountId, verifier }) + ) + }) }) const handleStatus = guarded('handoff/status', async (c) => { const refused = await precheck(c, 'inbox/sync') if (refused) return refused - const verified = await verifiedBody(c, 'handoff/status') - if ('refusal' in verified) return verified.refusal - const found = await accountForKey(c.env, verified.payload.accountPub) - if ('refusal' in found) return found.refusal - const { channelId } = verified.fields - return replyWith( - c, - await channel(c.env, channelId).status(channelId, found.account.id) + const text = await readEnvelopeText(c.req.raw) + return oldPhone(c, 'handoff/status', text, async (accountId, { channelId }) => + replyWith(c, await channel(c.env, channelId).status(channelId, accountId)) ) }) const handleSend = guarded('handoff/send', async (c) => { const refused = await precheck(c, 'inbox/sync') if (refused) return refused - const verified = await verifiedBody(c, 'handoff/send') - if ('refusal' in verified) return verified.refusal - const found = await accountForKey(c.env, verified.payload.accountPub) - if ('refusal' in found) return found.refusal - const { channelId, key } = verified.fields - return replyWith( - c, - await channel(c.env, channelId).send(channelId, found.account.id, key) + const text = await readEnvelopeText(c.req.raw) + return oldPhone(c, 'handoff/send', text, async (accountId, fields) => + replyWith( + c, + await channel(c.env, fields.channelId).send( + fields.channelId, + accountId, + fields.key + ) + ) ) }) const handleClaim = guarded('handoff/claim', async (c) => { const refused = await precheck(c, 'inbox/sync') if (refused) return refused - const body = await readJson(c) + const body = await readPlainJson(c) if (handoffBlobTooLarge(body)) return buddiesErrorResponse('too_large') const request = parseHandoffClaimRequest(body) if (!request) return buddiesErrorResponse('bad_request') @@ -129,7 +140,7 @@ const handleClaim = guarded('handoff/claim', async (c) => { const handleReceive = guarded('handoff/receive', async (c) => { const refused = await precheck(c, 'inbox/sync') if (refused) return refused - const request = parseHandoffTokenRequest(await readJson(c)) + const request = parseHandoffTokenRequest(await readPlainJson(c)) if (!request) return buddiesErrorResponse('bad_request') return replyWith(c, await channel(c.env, request.channelId).receive(request)) }) @@ -137,42 +148,31 @@ const handleReceive = guarded('handoff/receive', async (c) => { const handleDone = guarded('handoff/done', async (c) => { const refused = await precheck(c, 'inbox/sync') if (refused) return refused - const request = parseHandoffTokenRequest(await readJson(c)) + const request = parseHandoffTokenRequest(await readPlainJson(c)) if (!request) return buddiesErrorResponse('bad_request') return replyWith(c, await channel(c.env, request.channelId).done(request)) }) /** - * Either phone: a signed envelope (`payload`, `signature`) is the old phone, anything else - * must be the new phone's `channelId` and `claimToken`. + * Either phone. A body with `claimToken` (and no envelope `payload`) is the new + * phone's; otherwise a bearer request, or a signed envelope (legacy), is the + * old phone's. Anything else must be the new phone's `channelId` and + * `claimToken`. */ const handleCancel = guarded('handoff/cancel', async (c) => { const refused = await precheck(c, 'inbox/sync') if (refused) return refused const text = await readEnvelopeText(c.req.raw) - let body: unknown = null - try { - body = text == null ? null : JSON.parse(text) - } catch { - body = null - } - if (isPlainObject(body) && isEnvelopeBody(body)) { - const verified = await verifyEnvelope( - 'handoff/cancel', - text == null ? null : parseEnvelope(text) - ) - if ('refusal' in verified) return verified.refusal - const found = await accountForKey(c.env, verified.payload.accountPub) - if ('refusal' in found) return found.refusal - const { channelId } = verified.fields - return replyWith( - c, - await channel(c.env, channelId).cancelByAccount( - channelId, - found.account.id + const body = parseJsonOrNull(text) + const signed = isPlainObject(body) && isEnvelopeBody(body) + const newPhone = isPlainObject(body) && 'claimToken' in body && !signed + if (!newPhone && (signed || hasAuthorization(c))) + return oldPhone(c, 'handoff/cancel', text, async (accountId, { channelId }) => + replyWith( + c, + await channel(c.env, channelId).cancelByAccount(channelId, accountId) ) ) - } const request = parseHandoffTokenRequest(body) if (!request) return buddiesErrorResponse('bad_request') return replyWith( diff --git a/src/accounts/legacySwitch.ts b/src/accounts/legacySwitch.ts new file mode 100644 index 0000000..ca869a7 --- /dev/null +++ b/src/accounts/legacySwitch.ts @@ -0,0 +1,54 @@ +import type { Environment } from '../types' +import { buddiesErrorResponse } from '../buddies/errorResponse' + +/** + * The legacy switch (witness-work ADR 0027, `docs/accounts-protocol.md`, + * "Updates and the legacy switch"). While it's on, every route converted to + * bearer tokens (accounts, backup, handoff, and later Buddies and Notes + * Import) also accepts the way it authenticated before sessions: the signed + * envelope or the per-request attestation. Off, those requests get 426 + * `upgrade_required`, and the app shows Update Required. + * + * KV `auth:legacy` in `NOTES_KV`: `"off"` (or `"false"`) turns it off, `"on"` + * (or `"true"`) on. Absent, unreadable, or anything else falls back to the + * `AUTH_LEGACY` var the same way, and then to on. Edge-cached for 60 seconds. + */ + +export const AUTH_LEGACY_KEY = 'auth:legacy' +const CACHE_TTL_SECONDS = 60 +const OFF = new Set(['off', 'false']) +const ON = new Set(['on', 'true']) + +export type LegacySwitchEnv = Pick & { + NOTES_KV: Pick +} + +export const isLegacyAuthEnabled = async ( + env: LegacySwitchEnv +): Promise => { + let value: string | null = null + try { + value = await env.NOTES_KV.get(AUTH_LEGACY_KEY, { + cacheTtl: CACHE_TTL_SECONDS, + }) + } catch { + console.warn('accounts: legacy switch KV read failed; using AUTH_LEGACY') + } + const stored = value?.trim().toLowerCase() + if (stored && OFF.has(stored)) return false + if (stored && ON.has(stored)) return true + return !OFF.has(env.AUTH_LEGACY?.trim().toLowerCase() ?? '') +} + +/** 426 `upgrade_required`, without `Retry-After`: only updating helps. */ +export const upgradeRequiredResponse = (): Response => + buddiesErrorResponse('upgrade_required') + +/** + * For a request authenticated the old way: null while the legacy switch is + * on, 426 `upgrade_required` once it's off. + */ +export const refuseLegacyAuth = async ( + env: LegacySwitchEnv +): Promise => + (await isLegacyAuthEnabled(env)) ? null : upgradeRequiredResponse() diff --git a/src/accounts/openapi.ts b/src/accounts/openapi.ts index e241045..cc0009c 100644 --- a/src/accounts/openapi.ts +++ b/src/accounts/openapi.ts @@ -1,18 +1,36 @@ import { z } from 'zod' import { describeRoute } from 'hono-openapi' import { + apiErrorSchema, staleErrorSchema, b64u, buddiesId, errorResponse, fieldTable, + json, jsonBody, jsonResponse, signedBody, signedEnvelopeSchema, } from '../openapi/schemas' +import { + appAttestV2AssertionFields, + playIntegrityFields, + sha256Hex, +} from '../attestation/openapi' +import { ACCOUNT_SESSION_PURPOSE } from '../attestation/purposes' import { ACCOUNT_STATUS_REASONS, ACCOUNT_STATUSES } from './schema' +/** + * OpenAPI descriptions for `/accounts/v1/*` (src/accounts/route.ts) and the + * accounts admin routes. Doc-only: the handlers keep their own parsing. + * + * Sessions and the account are REST resources (`/sessions`, `/account`). + * Handoff and backup stay `POST /accounts/v1/{op}`, like Buddies, on purpose: + * their channel and slot ids travel in bodies (or headers), never URLs, which + * Cloudflare and Sentry record. `ID_IN_BODY` says so on each of them. + */ + const accountId = buddiesId('The account id') const status = z.enum(ACCOUNT_STATUSES) @@ -40,11 +58,72 @@ const signatureErrors = { ), } +// --- Bearer tokens and the legacy switch ----------------------------------- + +const keyPayloadShape = { + accountPub: b64u('Ed25519 public key of the account signing key, 32 bytes'), + ts: z.number().int().meta({ + description: + 'Client time in epoch milliseconds; must be within 5 minutes of the server’s.', + }), + nonce: buddiesId('A fresh random nonce'), +} + +/** A bearer token, or (while the legacy switch is on) none: the envelope. */ +const bearerOrLegacy: Record[] = [{ bearerAuth: [] }, {}] + +const LEGACY_FORM = (op: string) => + `**Legacy** (no \`Authorization\` header; app versions before sessions): a signed envelope instead, whose \`signature\` is the account key’s signature over \`ww-account/v1\\n${op}\\n\` followed by the decoded \`payload\` bytes. It works while the legacy switch (KV \`auth:legacy\`) is on and is 426 \`upgrade_required\` once it’s off. A request with an \`Authorization\` header is always a bearer request.` + +/** Why handoff and backup ops aren't resource paths. */ +const ID_IN_BODY = + 'Like Buddies ops, this is `POST /accounts/v1/{op}` rather than a resource path, so its ids travel in the body (or headers), never the URL, which Cloudflare and Sentry record.' + +/** A bearer request's plain JSON fields, or the legacy envelope. */ +const bearerOrSignedBody = (op: string, fields: z.ZodRawShape) => + jsonBody( + z.union([ + z.object(fields).meta({ title: 'Bearer' }), + signedEnvelopeSchema.meta({ title: 'Legacy envelope' }), + ]), + `**Bearer:** the fields as plain JSON.\n\n${LEGACY_FORM(op)}\n\n**Decoded \`payload\`:**\n\n${fieldTable( + z.object({ ...keyPayloadShape, ...fields }) + )}` + ) + +/** No body (bearer), or the legacy envelope. */ +const optionalSignedBody = (op: string) => ({ + required: false, + description: `**Bearer:** no body.\n\n${LEGACY_FORM(op)}\n\n**Decoded \`payload\`:**\n\n${fieldTable( + z.object(keyPayloadShape) + )}`, + content: json(signedEnvelopeSchema), +}) + +const bearerErrors = { + 400: errorResponse('Malformed JSON, envelope, or field.', ['bad_request']), + 401: errorResponse( + 'A bearer request’s token is missing, expired, or invalid (`unauthorized`, with `WWW-Authenticate: Bearer error="invalid_token"`; refresh once and retry). A legacy request’s signature is bad (`bad_signature`) or its `ts` is more than 5 minutes off (`stale`, with `serverTime` and a `Date` header).', + ['unauthorized', 'bad_signature', 'stale'], + staleErrorSchema + ), +} + +const upgradeRequired = { + 426: errorResponse( + 'A legacy request after the legacy switch (KV `auth:legacy`) was turned off. Update the app; there’s no `Retry-After`.', + ['upgrade_required'] + ), +} + export const accountRegisterDocs = describeRoute({ tags: ['Accounts'], - summary: 'Register an account', + summary: 'Register an account (legacy)', + deprecated: true, description: `Returns the account for a signing key, creating it on first use. Self-signed by the account key under the \`ww-account/v1\` context, and idempotent: the same key always gets the same \`accountId\`. +Replaced by opening a session (\`POST /accounts/v1/sessions\`), and kept for app versions before sessions: once the legacy switch (KV \`auth:legacy\`) is off, it’s 426 \`upgrade_required\`. + Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registrations per caller per rolling day, shared in kind with \`inbox/register\`. Answers \`disabled\` on a worker without the accounts database.`, requestBody: keySignedBody('account/register'), responses: { @@ -53,6 +132,7 @@ Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registra z.object({ ok: z.literal(true), accountId, status }) ), ...signatureErrors, + ...upgradeRequired, 429: errorResponse('Over the per-caller rate or daily quota.', [ 'rate_limited', ]), @@ -64,32 +144,57 @@ Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registra const epochMs = (description: string) => z.number().int().meta({ description: `${description}, epoch milliseconds.` }) -export const accountInfoDocs = describeRoute({ +const accountInfoSchema = z.object({ + ok: z.literal(true), + accountId, + accountPub: b64u('The account signing key’s public key'), + status, + createdAt: epochMs('When the account was registered'), + inboxIds: z.array(buddiesId('An inbox linked to the account')), + statusChanges: z.array( + z.object({ status, createdAt: epochMs('When it changed') }) + ), +}) + +export const accountDocs = describeRoute({ tags: ['Accounts'], summary: 'Read your account', - description: `What the accounts database holds for the signing key’s own account, for the app’s developer tools: its id, status, creation time, linked inboxes, and status changes (without their reasons). Self-signed by the account key under the \`ww-account/v1\` context, so only the key’s holder can read it. Read-only: it never creates an account. + description: `What the accounts database holds for the access token’s own account, for Settings → Account and the app’s developer tools: its id, public key, status, creation time, linked inboxes, and status changes (without their reasons). Read-only: it never creates an account. Limits: the Buddies read tier (600 a minute per caller IP). Answers \`disabled\` on a worker without the accounts database.`, - requestBody: keySignedBody('account/info'), + security: [{ bearerAuth: [] }], responses: { - 200: jsonResponse( - 'The account for this key.', - z.object({ - ok: z.literal(true), - accountId, - accountPub: b64u('The account signing key’s public key'), - status, - createdAt: epochMs('When the account was registered'), - inboxIds: z.array(buddiesId('An inbox linked to the account')), - statusChanges: z.array( - z.object({ status, createdAt: epochMs('When it changed') }) - ), - }) + 200: jsonResponse('The token’s account.', accountInfoSchema), + 401: errorResponse( + 'Missing, expired, or invalid token (`WWW-Authenticate: Bearer error="invalid_token"`; refresh once and retry).', + ['unauthorized'] ), - ...signatureErrors, - 404: errorResponse('No account has this key; register first.', [ - 'unknown_account', - ]), + 404: errorResponse('The token’s account is gone.', ['unknown_account']), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + 500: errorResponse('Unexpected failure.', ['internal']), + 503: errorResponse('Accounts are off on this worker.', ['disabled']), + }, +}) + +export const accountInfoDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Read your account (legacy)', + description: `\`GET /accounts/v1/account\`’s answer, for app versions before it. A bearer request with no body reads the token’s account. + +${LEGACY_FORM('account/info')} An unregistered key is 404 \`unknown_account\`. + +Limits: the Buddies read tier (600 a minute per caller IP). Answers \`disabled\` on a worker without the accounts database.`, + deprecated: true, + security: bearerOrLegacy, + requestBody: optionalSignedBody('account/info'), + responses: { + 200: jsonResponse('The account.', accountInfoSchema), + ...bearerErrors, + 404: errorResponse( + 'No account has this key (legacy), or the token’s account is gone.', + ['unknown_account'] + ), + ...upgradeRequired, 429: errorResponse('Over the per-caller rate.', ['rate_limited']), 500: errorResponse('Unexpected failure.', ['internal']), 503: errorResponse('Accounts are off on this worker.', ['disabled']), @@ -182,7 +287,7 @@ const keySignedWith = (op: string, fields: z.ZodRawShape) => nonce: buddiesId('A fresh random nonce'), ...fields, }), - `A signed envelope; \`s\` is the account key’s signature over \`ww-account/v1\\n${op}\\n\` followed by \`p\`’s bytes.` + `A signed envelope; \`signature\` is the account key’s signature over \`ww-account/v1\\n${op}\\n\` followed by the decoded \`payload\` bytes.` ) const okOnly = jsonResponse('Done.', z.object({ ok: z.literal(true) })) @@ -196,12 +301,13 @@ const notFound = errorResponse( ['not_found'] ) const oldPhoneErrors = (extra: string) => ({ - ...signatureErrors, + ...bearerErrors, 403: errorResponse('Another account opened this channel.', ['forbidden']), 404: errorResponse( - `${extra} Also \`unknown_account\` when no account has the signing key.`, + `${extra} Also \`unknown_account\` when no account has the signing key (legacy).`, ['not_found', 'unknown_account'] ), + ...upgradeRequired, }) const tokenErrors = { 400: errorResponse('Malformed JSON or field.', ['bad_request']), @@ -219,8 +325,11 @@ export const handoffOpenDocs = describeRoute({ summary: 'Open a handoff channel (old phone)', description: `The phone with the account opens a channel for Set Up Another Device and shows its QR code. The server stores the account, the claim token’s verifier, and nothing else until a new phone claims it. The channel and everything in it is deleted 10 minutes after opening. -Limits: the Buddies register tier (60 a minute per caller IP) and 20 opens per account a rolling day.`, - requestBody: keySignedWith('handoff/open', { +Limits: the Buddies register tier (60 a minute per caller IP) and 20 opens per account a rolling day. + +${ID_IN_BODY}`, + security: bearerOrLegacy, + requestBody: bearerOrSignedBody('handoff/open', { channelId, verifier: b64u('`SHA-256(claimToken bytes)`, 32 bytes'), }), @@ -229,10 +338,11 @@ Limits: the Buddies register tier (60 a minute per caller IP) and 20 opens per a 'Opened.', z.object({ ok: z.literal(true), expiresAt }) ), - ...signatureErrors, - 404: errorResponse('No account has this key; register first.', [ + ...bearerErrors, + 404: errorResponse('No account has this key; register first (legacy).', [ 'unknown_account', ]), + ...upgradeRequired, 409: errorResponse('The channel already exists.', ['conflict']), 429: errorResponse('Over the per-caller rate or the daily opens.', [ 'rate_limited', @@ -247,7 +357,9 @@ export const handoffClaimDocs = describeRoute({ summary: 'Claim a handoff channel (new phone)', description: `The new phone answers the QR code with its temporary X25519 public key and its sealed hello. The first valid claim wins; repeating it with the same body gets \`ok\` again (a lost response), and any other claim is \`conflict\`. Plain JSON, authorized by \`claimToken\`. -${readTier}`, +${readTier} + +${ID_IN_BODY}`, requestBody: tokenBody({ claimPub: b64u('The new phone’s temporary X25519 public key, 32 bytes'), hello: b64u( @@ -275,8 +387,11 @@ export const handoffStatusDocs = describeRoute({ summary: 'Read a handoff channel (old phone)', description: `What the old phone polls (every 1.5 seconds) while it shows the code: the status and, once claimed, the new phone’s public key and sealed hello. Only the opening account can read it. -${readTier}`, - requestBody: keySignedWith('handoff/status', { channelId }), +${readTier} + +${ID_IN_BODY}`, + security: bearerOrLegacy, + requestBody: bearerOrSignedBody('handoff/status', { channelId }), responses: { 200: jsonResponse( 'The channel.', @@ -304,8 +419,11 @@ export const handoffSendDocs = describeRoute({ summary: 'Send the account key (old phone)', description: `After the User approves the new phone, the old phone sends the account key sealed with \`keyKey\`. Only while \`claimed\`; the status becomes \`sent\`. -${readTier}`, - requestBody: keySignedWith('handoff/send', { +${readTier} + +${ID_IN_BODY}`, + security: bearerOrLegacy, + requestBody: bearerOrSignedBody('handoff/send', { channelId, key: b64u( 'The account key, sealed with `keyKey`, at most 1,024 characters' @@ -327,7 +445,9 @@ export const handoffReceiveDocs = describeRoute({ summary: 'Poll for the account key (new phone)', description: `The new phone polls (every 1.5 seconds) until the status is \`sent\`, which carries the sealed key, or \`cancelled\`. -${readTier}`, +${readTier} + +${ID_IN_BODY}`, requestBody: tokenBody(), responses: { 200: jsonResponse( @@ -350,7 +470,9 @@ export const handoffDoneDocs = describeRoute({ summary: 'Finish a handoff (new phone)', description: `The new phone has the key: the server deletes the blobs at once and keeps only the status (\`done\`) until the channel expires. Repeating it is \`ok\`. -${readTier}`, +${readTier} + +${ID_IN_BODY}`, requestBody: tokenBody(), responses: { 200: okOnly, @@ -367,21 +489,29 @@ ${readTier}`, export const handoffCancelDocs = describeRoute({ tags: ['Accounts'], summary: 'Cancel a handoff (either phone)', - description: `Either phone ends the channel: the server deletes every blob at once and keeps only the status (\`cancelled\`) until the channel expires. Idempotent; a \`done\` channel stays \`done\`. The old phone sends a signed envelope (op \`handoff/cancel\`, payload \`channelId\` plus the key payload) and must be the opening account; the new phone sends plain JSON with \`claimToken\`. + description: `Either phone ends the channel: the server deletes every blob at once and keeps only the status (\`cancelled\`) until the channel expires. Idempotent; a \`done\` channel stays \`done\`. The new phone sends plain JSON with \`claimToken\` (with or without a session of its own). The old phone sends \`{ channelId }\` as a bearer request and must be the opening account; before sessions it sent a signed envelope (op \`handoff/cancel\`, payload \`channelId\` plus the key payload), which works while the legacy switch is on. -${readTier}`, +${readTier} + +${ID_IN_BODY}`, + security: bearerOrLegacy, requestBody: jsonBody( - z.union([signedEnvelopeSchema, z.object({ channelId, claimToken })]), - 'The old phone’s signed envelope, or the new phone’s `channelId` and `claimToken`.' + z.union([ + z.object({ channelId, claimToken }).meta({ title: 'New phone' }), + z.object({ channelId }).meta({ title: 'Old phone (bearer)' }), + signedEnvelopeSchema.meta({ title: 'Old phone (legacy envelope)' }), + ]), + 'A body with `claimToken` is the new phone’s. Otherwise a bearer request, or a signed envelope, is the old phone’s.' ), responses: { 200: okOnly, 400: errorResponse('Malformed envelope, JSON, or field.', ['bad_request']), 401: errorResponse( - 'Bad signature, stale `ts`, or a wrong `claimToken` (the fifth cancels the channel).', - ['bad_signature', 'stale'], + 'The old phone’s token is missing, expired, or invalid (`unauthorized`), its envelope’s signature is bad or `ts` stale (legacy), or the new phone’s `claimToken` is wrong (`bad_signature`; the fifth cancels the channel).', + ['unauthorized', 'bad_signature', 'stale'], staleErrorSchema ), + ...upgradeRequired, 403: errorResponse('Another account opened this channel.', ['forbidden']), 404: errorResponse( 'No such channel, it expired, or no account has the signing key.', @@ -432,7 +562,9 @@ const keyPayloadFields = (op: string, fields: z.ZodRawShape) => export const backupPutDocs = describeRoute({ tags: ['Accounts'], summary: 'Upload a backup', - description: `Replaces this device’s slot with an end-to-end encrypted backup (\`0x01 ‖ nonce[24] ‖ XChaCha20-Poly1305\`). The body is the raw blob, at most 20 MiB; the signed envelope (op \`backup/put\`) rides in the \`x-ww-payload\` and \`x-ww-signature\` headers. + description: `Replaces this device’s slot with an end-to-end encrypted backup (\`0x01 ‖ nonce[24] ‖ XChaCha20-Poly1305\`). A bearer request; the body is the raw blob, at most 20 MiB, with the slot in \`x-ww-slot-id\`, \`b64u(SHA-256(body))\` in \`x-ww-sha256\`, and its length in \`Content-Length\`. + +**Legacy** (no \`Authorization\` header): the signed envelope (op \`backup/put\`) rides in the \`x-ww-payload\` and \`x-ww-signature\` headers instead, while the legacy switch is on (older builds send \`x-ww-p\`/\`x-ww-s\`). **Decoded \`x-ww-payload\`:** @@ -445,21 +577,46 @@ ${keyPayloadFields('backup/put', { sha256: b64u('`SHA-256(body)`, 32 bytes'), })} -Limits: 20 MiB a blob, 8 slots an account, one upload a slot a minute, and the Buddies photo-upload tier (60 a minute per caller IP). ${backupNotes}`, +Limits: 20 MiB a blob, 8 slots an account, one upload a slot a minute, and the Buddies photo-upload tier (60 a minute per caller IP). ${backupNotes} + +${ID_IN_BODY}`, + security: bearerOrLegacy, parameters: [ + { + name: 'x-ww-slot-id', + in: 'header' as const, + required: false, + schema: { type: 'string' as const }, + description: 'Bearer: the device’s slot id (22-character base64url).', + }, + { + name: 'x-ww-sha256', + in: 'header' as const, + required: false, + schema: { type: 'string' as const }, + description: 'Bearer: `SHA-256(body)`, 32 bytes, base64url.', + }, + { + name: 'Content-Length', + in: 'header' as const, + required: false, + schema: { type: 'integer' as const }, + description: + 'The body’s length; required on a bearer request (it is the size), at most 20 MiB.', + }, { name: 'x-ww-payload', in: 'header' as const, - required: true, + required: false, schema: { type: 'string' as const }, - description: 'The request’s fields as UTF-8 JSON, base64url.', + description: 'Legacy: the request’s fields as UTF-8 JSON, base64url.', }, { name: 'x-ww-signature', in: 'header' as const, - required: true, + required: false, schema: { type: 'string' as const }, - description: 'The account key’s Ed25519 signature, base64url.', + description: 'Legacy: the account key’s Ed25519 signature, base64url.', }, ], requestBody: { required: true, ...binary('The encrypted backup bytes.') }, @@ -475,14 +632,15 @@ Limits: 20 MiB a blob, 8 slots an account, one upload a slot a minute, and the B 'Missing or malformed headers or payload, or a body whose length isn’t `size` or whose SHA-256 isn’t `sha256`.', ['bad_request'] ), - 401: signatureErrors[401], - 404: errorResponse('No account has this key; register first.', [ + 401: bearerErrors[401], + 404: errorResponse('No account has this key; register first (legacy).', [ 'unknown_account', ]), 413: errorResponse( '`size`, `Content-Length`, or the streamed body is over 20 MiB.', ['too_large'] ), + ...upgradeRequired, 429: errorResponse( 'The account has 8 other slots (`limit`; delete one first), the slot was written less than a minute ago, or over the per-caller rate (`rate_limited`, with `Retry-After`).', ['limit', 'rate_limited'] @@ -495,10 +653,15 @@ Limits: 20 MiB a blob, 8 slots an account, one upload a slot a minute, and the B export const backupListDocs = describeRoute({ tags: ['Accounts'], summary: 'List backups', - description: `Every slot of the signing key’s account, for a restore. + description: `Every slot of the caller’s account, for a restore. A bearer request with no body. + +${LEGACY_FORM('backup/list')} -${readTier} ${backupNotes}`, - requestBody: keySignedWith('backup/list', {}), +${readTier} ${backupNotes} + +${ID_IN_BODY}`, + security: bearerOrLegacy, + requestBody: optionalSignedBody('backup/list'), responses: { 200: jsonResponse( 'The account’s slots.', @@ -513,10 +676,11 @@ ${readTier} ${backupNotes}`, ), }) ), - ...signatureErrors, - 404: errorResponse('No account has this key; register first.', [ + ...bearerErrors, + 404: errorResponse('No account has this key; register first (legacy).', [ 'unknown_account', ]), + ...upgradeRequired, 429: errorResponse('Over the per-caller rate.', ['rate_limited']), ...internal, ...backupOff, @@ -526,10 +690,13 @@ ${readTier} ${backupNotes}`, export const backupGetDocs = describeRoute({ tags: ['Accounts'], summary: 'Download a backup', - description: `Streams one slot’s bytes exactly as uploaded. + description: `Streams one slot’s bytes exactly as uploaded. A bearer request. -${readTier}`, - requestBody: keySignedWith('backup/get', { slotId }), +${readTier} + +${ID_IN_BODY}`, + security: bearerOrLegacy, + requestBody: bearerOrSignedBody('backup/get', { slotId }), responses: { 200: { ...binary('The encrypted backup bytes.'), @@ -541,11 +708,12 @@ ${readTier}`, 'Content-Length': { schema: { type: 'integer' as const } }, }, }, - ...signatureErrors, + ...bearerErrors, 404: errorResponse( - 'The account has no such slot (`not_found`), or no account has the key.', + 'The account has no such slot (`not_found`), or no account has the key (legacy).', ['not_found', 'unknown_account'] ), + ...upgradeRequired, 429: errorResponse('Over the per-caller rate.', ['rate_limited']), ...internal, ...backupOff, @@ -557,18 +725,239 @@ export const backupDeleteDocs = describeRoute({ summary: 'Delete backups', description: `Deletes one slot, or every slot of the account when \`slotId\` is left out (before Delete Buddies replaces the account key). Gone from storage at once; idempotent. -${readTier}`, +A bearer request **and** a signed envelope: \`signature\` is the account key’s signature over \`ww-account/v1\\nbackup/delete\\n\` followed by the decoded \`payload\` bytes, and the key must be the token’s own account (\`forbidden\` otherwise), so a leaked token alone can’t delete backups. **Legacy** (no \`Authorization\` header): the envelope alone, while the legacy switch is on. + +${readTier} + +${ID_IN_BODY}`, + security: bearerOrLegacy, requestBody: keySignedWith('backup/delete', { slotId: slotId.optional(), }), responses: { 200: okOnly, - ...signatureErrors, + ...bearerErrors, + 403: errorResponse( + 'The envelope is signed by another account than the token’s.', + ['forbidden'] + ), 404: errorResponse('No account has this key; register first.', [ 'unknown_account', ]), + ...upgradeRequired, 429: errorResponse('Over the per-caller rate.', ['rate_limited']), ...internal, ...backupOff, }, }) + +// --- Sessions ---------------------------------------------------------------- + +const sessionsOff = { + 503: errorResponse( + 'Sessions are off on this worker (no accounts database or token secret).', + ['disabled'] + ), +} + +const sessionHash = sha256Hex( + '`SHA-256(UTF-8("ww-account/v1/session\\n" + accountPub))`; `contentHash` and `requestHash` are both this' +) +const sessionInstallId = z + .string() + .regex(/^[A-Za-z0-9_-]{8,64}$/) + .meta({ + description: + 'The install id (`uuid`) the challenge was issued for; its App Attest key is pinned to it.', + }) +const sessionAttestationBinding = { + uuid: sessionInstallId, + contentHash: sessionHash, + requestHash: sessionHash, +} +const appAttestSessionAttestation = z + .object({ + ...appAttestV2AssertionFields(ACCOUNT_SESSION_PURPOSE), + ...sessionAttestationBinding, + }) + .meta({ title: 'App Attest v2 (iOS)' }) +const playIntegritySessionAttestation = z + .object({ + ...playIntegrityFields(ACCOUNT_SESSION_PURPOSE), + ...sessionAttestationBinding, + }) + .meta({ title: 'Play Integrity (Android)' }) +const sessionOpenPayload = z.object({ + ...keyPayloadShape, + attestation: z + .union([appAttestSessionAttestation, playIntegritySessionAttestation]) + .optional() + .meta({ + description: + 'The challenge request’s fields plus `challenge` and the assertion or integrity token (tables below). Required except with the development bypass.', + }), + device: b64u('The sealed device label, at most 512 characters').optional(), +}) + +/** `attestation_failed` and the attestation `bad_request`s carry these. */ +const sessionAttestationErrorSchema = apiErrorSchema + .extend({ + reason: z.string().optional().meta({ + description: + 'The attestation routes’ stable reason: `device_integrity_failed`, `app_not_recognized`, `integrity_token_invalid`, `integrity_unavailable`, a challenge reason (`challenge_not_found`, `challenge_expired`, `operation_conflict`), an App Attest key reason (`identity_not_bound`, `key_not_active`, `assertion_invalid`, `counter_not_increasing`), or `invalid_request` / `unsupported_protocol` on a 400.', + }), + action: z.string().optional().meta({ + description: + 'What to do next, as on the attestation routes: `none`, `retry`, `start_new_operation`, `bind`, …', + }), + }) + .meta({ + id: 'SessionAttestationError', + description: + 'The accounts error envelope (`error` repeats `code`) plus the attestation’s `reason` and `action`.', + }) + +/** Refresh tokens: `..`, b64u of 16, 32, 32 bytes. */ +const refreshTokenSchema = (description: string) => + z + .string() + .regex(/^[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\.[A-Za-z0-9_-]{43}$/) + .meta({ + description: `${description}. Opaque to the app: the session’s id, a secret, and the server’s MAC over both, \`..\` (base64url, 16, 32, and 32 bytes); store and send it as is`, + }) + +const tokenPair = { + accessToken: z.string().meta({ + description: + 'A JWT (HS256) for `Authorization: Bearer`, opaque to the app. Keep it in memory only.', + }), + accessExpiresAt: epochMs('When the access token expires (15 minutes)'), + refreshToken: refreshTokenSchema( + 'The session’s refresh token. Keep it in the platform keystore, not synced; each refresh replaces it' + ), + sessionExpiresAt: epochMs( + 'When the session ends (90 days after it opened; refreshing never extends it)' + ), +} + +const sessionDevBypassHeader = { + name: 'x-ww-dev-bypass', + in: 'header' as const, + required: false, + schema: { type: 'string' as const }, + description: + 'Development worker only: the `NOTES_IMPORT_DEV_BYPASS_TOKEN` secret, in place of `attestation` (platform `dev`). Production never sets the token, so the header is ignored there.', +} + +export const sessionOpenDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Open a session', + description: `The one place an account proves it’s the app. First get a challenge from \`POST /attestation/v1/challenge\` with \`purpose: "account-session"\` and both hashes set to the hash of \`accountPub\`: an App Attest v2 \`assert\` challenge on iOS (the install’s key, bound through \`POST /attestation/v1/register\`), or a Play Integrity v1 challenge on Android (\`attestationProvider: "play-integrity"\`). Then send this envelope, self-signed by the account key (op \`session/open\`) and carrying the answer: the signature covers the attestation and the attestation covers \`accountPub\`. + +Creates the account when the key has none (it replaces \`account/register\`). A suspended account still gets a session (\`status: suspended\`). Opening an eleventh live session for an account ends its oldest. Answers 201 with \`Location: /accounts/v1/sessions/current\`, the session the new access token addresses. + +\`device\` is the device’s label, sealed (Buddies \`seal\` format) under \`HKDF-SHA256(accountKey, "", "ww-account/v1/device", 32)\` with AAD \`"ww-account/v1/device\\n" + accountPub\`; the server stores it and can’t read it. + +Limits: the Buddies register tier (60 a minute per caller IP) and 20 opens per account key a rolling day.`, + parameters: [sessionDevBypassHeader], + requestBody: jsonBody( + signedEnvelopeSchema, + `A signed envelope; \`signature\` is the account key’s signature over \`ww-account/v1\\nsession/open\\n\` followed by the decoded \`payload\` bytes. + +**Decoded \`payload\`:** + +${fieldTable(sessionOpenPayload)} + +**\`attestation\`, App Attest v2 (iOS):** + +${fieldTable(appAttestSessionAttestation)} + +**\`attestation\`, Play Integrity (Android):** + +${fieldTable(playIntegritySessionAttestation)}` + ), + responses: { + 201: { + ...jsonResponse( + 'The session.', + z.object({ + ok: z.literal(true), + accountId, + status, + sessionId: buddiesId('The session'), + ...tokenPair, + }) + ), + headers: { + Location: { + description: '`/accounts/v1/sessions/current`.', + schema: { type: 'string' as const }, + }, + }, + }, + 400: errorResponse( + 'Malformed envelope, payload, or attestation, or an attestation for another key or purpose.', + ['bad_request'], + sessionAttestationErrorSchema + ), + 401: signatureErrors[401], + 403: errorResponse( + 'The attestation was refused. There’s no fallback: no attestation, no session.', + ['attestation_failed'], + sessionAttestationErrorSchema + ), + 413: errorResponse('`device` is over 512 characters.', ['too_large']), + 429: errorResponse( + 'Over the per-caller rate, the account key’s 20 opens a day, or the install’s open challenges (`too_many_challenges`, `Retry-After: 300`).', + ['rate_limited'], + sessionAttestationErrorSchema + ), + ...internal, + ...sessionsOff, + }, +}) + +export const sessionRefreshDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Refresh a session', + description: `Plain JSON, no envelope: a new access token and a new refresh token, which replaces this one. Sending the refresh token just replaced again within 60 seconds (a lost response, or two refreshes at once) gets a new access token and the same new refresh token; sending it later, or any older one, ends the session. An ended or expired session is \`unauthorized\`: open a new one, attesting again, without asking the User. + +A \`POST\` on the \`sessions\` collection rather than the session itself, since the refresh token, not an id in the URL, names the session. + +${readTier}`, + requestBody: jsonBody( + z.object({ + refreshToken: refreshTokenSchema('The session’s current refresh token'), + }) + ), + responses: { + 200: jsonResponse( + 'New tokens.', + z.object({ ok: z.literal(true), ...tokenPair }) + ), + 400: errorResponse('Malformed JSON, or no `refreshToken`.', ['bad_request']), + 401: errorResponse( + 'Malformed, not issued by this worker (its MAC doesn’t verify), unknown, replaced, ended, or expired: open a new session. A replaced token presented after its minute, or any older one, also ends the session; one this worker didn’t issue never does.', + ['unauthorized'] + ), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + ...internal, + ...sessionsOff, + }, +}) + +export const sessionCloseDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Close the current session', + description: `Ends the access token’s own session (\`current\`; there’s no other session id in a URL): its refresh token stops working at once, and the access token within 15 minutes. Idempotent. No body. The app calls it on sign out, before replacing the account key, and before Delete Buddies. + +${readTier}`, + security: [{ bearerAuth: [] }], + responses: { + 200: okOnly, + 401: errorResponse('Missing, expired, or invalid token.', ['unauthorized']), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + ...internal, + ...sessionsOff, + }, +}) diff --git a/src/accounts/route.ts b/src/accounts/route.ts index 139d2e8..9dd31a8 100644 --- a/src/accounts/route.ts +++ b/src/accounts/route.ts @@ -1,22 +1,28 @@ import { Hono } from 'hono' -import type { AppContext, Environment } from '../types' -import { Sentry } from '../sentry' -import { bytesToBase64Url } from '../crypto' -import { - buddiesErrorResponse, - buddiesInternalError, -} from '../buddies/errorResponse' +import type { AppContext, AppEnv } from '../types' +import { buddiesErrorResponse } from '../buddies/errorResponse' import { buddiesCallerKey } from '../buddies/limits' -import { accountsDb, findAccountInfo, registerAccount } from './store' import { + accountsDb, + findAccountInfo, + newAccountId, + registerAccount, + type AccountInfo, +} from './store' +import { + guarded, quotaName, refuseAtEdge, refuseOverDailyQuota, verifiedBody, } from './signed' +import { authenticateAccount, bearerOrLegacy } from './auth' +import { refuseLegacyAuth } from './legacySwitch' import { handoffHandlers } from './handoff' import { backupHandlers } from './backup' +import { sessionHandlers } from './session' import { + accountDocs, accountInfoDocs, accountRegisterDocs, backupDeleteDocs, @@ -30,89 +36,115 @@ import { handoffReceiveDocs, handoffSendDocs, handoffStatusDocs, + sessionCloseDocs, + sessionOpenDocs, + sessionRefreshDocs, } from './openapi' -const randomAccountId = (): string => - bytesToBase64Url(crypto.getRandomValues(new Uint8Array(16))) - /** * `POST /accounts/v1/account/register`: the account for a signing key, created * on first use. Self-signed, like `inbox/register`, and idempotent: the same * key always gets the same `accountId`, so a replay learns nothing new. It * counts against the register edge tier and the caller's daily quota. * + * Opening a session (`POST /accounts/v1/sessions`) replaces it; it stays for + * app versions before sessions, so it's refused with 426 `upgrade_required` + * once the legacy switch is off. + * * Privacy: nothing here logs or reports the body, the key, or the account id. */ -const handleRegister = async (c: AppContext): Promise => { - try { - if (!c.env.ACCOUNTS_DB) return buddiesErrorResponse('disabled') - const limited = await refuseAtEdge(c, 'inbox/register') - if (limited) return limited +const handleRegister = guarded('account/register', async (c) => { + if (!c.env.ACCOUNTS_DB) return buddiesErrorResponse('disabled') + const upgrade = await refuseLegacyAuth(c.env) + if (upgrade) return upgrade + const limited = await refuseAtEdge(c, 'inbox/register') + if (limited) return limited + + const verified = await verifiedBody(c, 'account/register') + if ('refusal' in verified) return verified.refusal + const { payload } = verified - const verified = await verifiedBody(c, 'account/register') - if ('refusal' in verified) return verified.refusal - const { payload } = verified + // The caller's rolling-day registration quota, shared in kind with + // Buddies' `inbox/register` (a DO per caller, under its own name). + const callerKey = buddiesCallerKey(c.req.header('CF-Connecting-IP')) + const refused = await refuseOverDailyQuota( + c.env, + await quotaName('register-caller', callerKey), + undefined, + 'account/register', + 'registrationsPerDay' + ) + if (refused) return refused - // The caller's rolling-day registration quota, shared in kind with - // Buddies' `inbox/register` (a DO per caller, under its own name). - const callerKey = buddiesCallerKey(c.req.header('CF-Connecting-IP')) - const refused = await refuseOverDailyQuota( - c.env, - await quotaName('register-caller', callerKey), - undefined, - 'account/register', - 'registrationsPerDay' - ) - if (refused) return refused + const account = await registerAccount( + accountsDb(c.env.ACCOUNTS_DB), + payload.accountPub, + newAccountId + ) + return c.json({ ok: true, accountId: account.id, status: account.status }) +}) - const account = await registerAccount( - accountsDb(c.env.ACCOUNTS_DB), - payload.accountPub, - randomAccountId - ) - return c.json({ ok: true, accountId: account.id, status: account.status }) - } catch (error) { - console.error('accounts: request failed', { op: 'account/register' }) - Sentry.captureException(error) - return buddiesInternalError() - } +const infoResponse = (c: AppContext, info: AccountInfo | null): Response => { + if (!info) return buddiesErrorResponse('unknown_account') + const { id, ...rest } = info + return c.json({ ok: true, accountId: id, ...rest }) } /** - * `POST /accounts/v1/account/info`: what the database holds for the signing - * key's own account, for the app's developer tools. Read-only and self-signed, - * so only the account key's holder can read it; it never creates an account - * (an unknown key is 404 `unknown_account`). Counts against the read edge tier. + * `GET /accounts/v1/account`: what the database holds for the bearer token's + * own account, for Settings → Account and the developer tools. Read-only: it + * never creates an account. Counts against the read edge tier. * - * Privacy: nothing here logs or reports the body, the key, or the account id. + * Privacy: nothing here logs or reports the token or the account id. */ -const handleInfo = async (c: AppContext): Promise => { - try { - if (!c.env.ACCOUNTS_DB) return buddiesErrorResponse('disabled') - const limited = await refuseAtEdge(c, 'inbox/sync') - if (limited) return limited +const handleAccount = guarded('account', async (c) => { + const d1 = c.env.ACCOUNTS_DB + if (!d1) return buddiesErrorResponse('disabled') + const limited = await refuseAtEdge(c, 'inbox/sync') + if (limited) return limited + const auth = await authenticateAccount(c) + if (!auth.ok) return auth.response + const { accountId } = auth.account + return infoResponse(c, await findAccountInfo(accountsDb(d1), { accountId })) +}) - const verified = await verifiedBody(c, 'account/info') - if ('refusal' in verified) return verified.refusal - const { payload } = verified - - const info = await findAccountInfo( - accountsDb(c.env.ACCOUNTS_DB), - payload.accountPub - ) - if (!info) return buddiesErrorResponse('unknown_account') - const { id, ...rest } = info - return c.json({ ok: true, accountId: id, ...rest }) - } catch (error) { - console.error('accounts: request failed', { op: 'account/info' }) - Sentry.captureException(error) - return buddiesInternalError() - } -} +/** + * `POST /accounts/v1/account/info`: `GET /account`'s answer for app versions + * before it. A bearer request with no body names the token's account; while + * the legacy switch is on, an envelope self-signed by the account key names + * the key's (an unknown key is 404 `unknown_account`). Read-only. Counts + * against the read edge tier. + * + * Privacy: nothing here logs or reports the body, the key, or the account id. + */ +const handleInfo = guarded('account/info', async (c) => { + const d1 = c.env.ACCOUNTS_DB + if (!d1) return buddiesErrorResponse('disabled') + const limited = await refuseAtEdge(c, 'inbox/sync') + if (limited) return limited + const db = accountsDb(d1) + return bearerOrLegacy(c, { + bearer: async ({ accountId }) => + infoResponse(c, await findAccountInfo(db, { accountId })), + legacy: async () => { + const verified = await verifiedBody(c, 'account/info') + if ('refusal' in verified) return verified.refusal + const { accountPub } = verified.payload + return infoResponse(c, await findAccountInfo(db, { accountPub })) + }, + }) +}) /** Mounted at `/accounts/v1` by the Worker entry point. */ export const createAccountsRoutes = () => { - const routes = new Hono<{ Bindings: Environment }>() + const routes = new Hono() + // Sessions are resources. Backup and handoff stay `POST /{op}` (ids in + // bodies, never URLs, as in Buddies); see their openapi.ts entries. + routes.post('/sessions', sessionOpenDocs, sessionHandlers.open) + routes.post('/sessions/refresh', sessionRefreshDocs, sessionHandlers.refresh) + routes.delete('/sessions/current', sessionCloseDocs, sessionHandlers.close) + routes.get('/account', accountDocs, handleAccount) + // App versions before sessions (and `GET /account`). routes.post('/account/register', accountRegisterDocs, handleRegister) routes.post('/account/info', accountInfoDocs, handleInfo) routes.post('/handoff/open', handoffOpenDocs, handoffHandlers.open) diff --git a/src/accounts/schema.ts b/src/accounts/schema.ts index 123f5e2..4f9c4b4 100644 --- a/src/accounts/schema.ts +++ b/src/accounts/schema.ts @@ -50,6 +50,43 @@ export const accountStatusEvents = sqliteTable( (t) => [index('account_status_events_account_idx').on(t.accountId)] ) +export const SESSION_PLATFORMS = ['ios', 'android', 'dev'] as const +export type SessionPlatform = (typeof SESSION_PLATFORMS)[number] + +/** + * Attested sessions (witness-work ADR 0027): one row per session opened + * (`POST /accounts/v1/sessions`). Only the account, the platform, the sealed + * device label, the SHA-256 of the current and previous refresh token, and + * times. Never the install, its App Attest key, `accountPub`, or an IP. + */ +export const accountSessions = sqliteTable( + 'account_sessions', + { + /** b64u of 16 random bytes; the access token's `sid`. */ + id: text('id').primaryKey(), + accountId: text('account_id') + .notNull() + .references(() => accounts.id, { onDelete: 'cascade' }), + /** What attested the session; `dev` is the development bypass. */ + platform: text('platform', { enum: SESSION_PLATFORMS }).notNull(), + /** The device label, sealed under a key only the account's devices hold. */ + device: text('device'), + /** b64u SHA-256 of the current refresh token's bytes. */ + refreshHash: text('refresh_hash').notNull().unique(), + /** The one it replaced, accepted for a minute after `rotatedAt`. */ + previousRefreshHash: text('previous_refresh_hash'), + rotatedAt: integer('rotated_at', { mode: 'timestamp_ms' }), + createdAt: createdAt(), + /** 90 days after it opened; refreshing never moves it. */ + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + revokedAt: integer('revoked_at', { mode: 'timestamp_ms' }), + }, + (t) => [ + index('account_sessions_account_idx').on(t.accountId), + index('account_sessions_previous_refresh_idx').on(t.previousRefreshHash), + ] +) + /** * Which account owns each Buddies inbox. Written only after the inbox accepted * a registration signed by its owner key that also carried the account key's diff --git a/src/accounts/session.test.ts b/src/accounts/session.test.ts new file mode 100644 index 0000000..749d704 --- /dev/null +++ b/src/accounts/session.test.ts @@ -0,0 +1,1153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SigningKey, + TEST_DEV_BYPASS, + TEST_TOKEN_SECRET, + createHarness, + randomBytes, + b64u, + type Harness, +} from '../test/buddies' +import { Hono } from 'hono' +import { + accountEnvelope, + bearer, + openTestSession, + postJson, + requestJson, +} from '../test/accounts' +import { createNamespace } from '../test/durableObjects' +import { createTestServiceAccount } from '../test/googleServiceAccount' +import { randomToken } from '../crypto' +import { AppAttestIdentity } from '../appAttest/identityDO' +import { AppAttestError, appAttestLifecycle } from '../appAttest' +import { PlayIntegrityChallenges } from '../playIntegrity/challengeDO' +import { computePlayIntegrityRequestBinding } from '../playIntegrity/protocol' +import { resetGoogleAuthState } from '../googleAuth' +import type { AppEnv, Environment } from '../types' +import { handleAttestationChallengeRequest } from '../attestation/route' +import { sessionAttestationHash } from './contracts' +import { accountsDb } from './store' +import { pruneEndedSessions } from './sessionStore' +import { resetAccountStatusCache } from './auth' +import { createAccountsRoutes } from './route' + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + ctx: DurableObjectState + env: unknown + constructor(ctx: DurableObjectState, env: unknown) { + this.ctx = ctx + this.env = env + } + }, +})) + +const START = Date.UTC(2026, 9, 15, 12) +const SECOND = 1_000 +const MINUTE = 60 * SECOND +const DAY = 24 * 60 * MINUTE +// The accounts routes at the root, plus the shared challenge route sessions +// attest through. +const app = new Hono() + .post('/attestation/v1/challenge', handleAttestationChallengeRequest) + .route('/', createAccountsRoutes()) as never + +let h: Harness + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(START) + h = await createHarness() + resetAccountStatusCache() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +const post = (path: string, body: unknown, headers?: Record) => + postJson(app, h.env, path, body, headers) +const open = (key?: SigningKey, fields?: Record) => + openTestSession(app, h.env, key, fields) +const refresh = (refreshToken: unknown) => + post('/sessions/refresh', { refreshToken }) +const close = (headers?: Record) => + requestJson(app, h.env, 'DELETE', '/sessions/current', undefined, headers) +const info = (accessToken: string) => + requestJson(app, h.env, 'GET', '/account', undefined, bearer(accessToken)) +const requestChallenge = (body: unknown) => + post('/attestation/v1/challenge', body) +const advance = (ms: number) => vi.setSystemTime(Date.now() + ms) + +/** `..`, as the worker issues them. */ +const REFRESH_TOKEN = + /^[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\.[A-Za-z0-9_-]{43}$/ + +/** A well-formed refresh token for `sessionId` with a random secret and MAC. */ +const madeUp = (sessionId: string) => + `${sessionId}.${b64u(randomBytes(32))}.${b64u(randomBytes(32))}` + +const sessionRows = () => + h.accountsDb!.db.prepare('SELECT * FROM account_sessions').all() as Record< + string, + unknown + >[] + +describe('POST /sessions with the dev bypass', () => { + it('creates the account and opens a session', async () => { + const session = await open() + expect(session).toEqual({ + key: session.key, + ok: true, + accountId: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), + status: 'active', + sessionId: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), + accessToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/), + accessExpiresAt: START + 15 * MINUTE, + refreshToken: expect.stringMatching(REFRESH_TOKEN), + sessionExpiresAt: START + 90 * DAY, + }) + expect(session.refreshToken.split('.')[0]).toBe(session.sessionId) + + // Only the account, platform, label, token hashes, and times. + const [row] = sessionRows() + expect(Object.keys(row).sort()).toEqual( + [ + 'account_id', + 'created_at', + 'device', + 'expires_at', + 'id', + 'platform', + 'previous_refresh_hash', + 'refresh_hash', + 'revoked_at', + 'rotated_at', + ].sort() + ) + expect(row).toMatchObject({ + id: session.sessionId, + account_id: session.accountId, + platform: 'dev', + device: null, + previous_refresh_hash: null, + created_at: START, + expires_at: START + 90 * DAY, + revoked_at: null, + }) + expect(row.refresh_hash).not.toBe(session.refreshToken) + expect(JSON.stringify(row)).not.toContain(session.key.publicKey) + }) + + it('opens sessions for the account the key already has', async () => { + const key = await SigningKey.generate() + const registered = await post( + '/account/register', + await accountEnvelope('account/register', key) + ) + const first = await open(key) + const second = await open(key) + expect(first.accountId).toBe(registered.body.accountId) + expect(second.accountId).toBe(registered.body.accountId) + expect(second.sessionId).not.toBe(first.sessionId) + }) + + it('reads the token’s account back with GET /account', async () => { + const session = await open() + const read = await info(session.accessToken) + expect(read.status).toBe(200) + expect(read.body).toEqual({ + ok: true, + accountId: session.accountId, + accountPub: session.key.publicKey, + status: 'active', + createdAt: START, + inboxIds: [], + statusChanges: [], + }) + }) + + it('stores the sealed device label opaque, up to 512 characters', async () => { + const label = 'A'.repeat(512) + await open(undefined, { device: label }) + expect(sessionRows()[0].device).toBe(label) + + const key = await SigningKey.generate() + const over = await post( + '/sessions', + await accountEnvelope('session/open', key, { device: 'A'.repeat(513) }), + { 'x-ww-dev-bypass': TEST_DEV_BYPASS } + ) + expect(over.status).toBe(413) + expect(over.body.code).toBe('too_large') + const notB64u = await post( + '/sessions', + await accountEnvelope('session/open', key, { device: 'not base64url!' }), + { 'x-ww-dev-bypass': TEST_DEV_BYPASS } + ) + expect(notB64u.status).toBe(400) + }) + + it('checks the envelope like account/register', async () => { + const key = await SigningKey.generate() + const other = await SigningKey.generate() + const forged = await post( + '/sessions', + await accountEnvelope('session/open', key, {}, other), + { 'x-ww-dev-bypass': TEST_DEV_BYPASS } + ) + expect(forged.status).toBe(401) + expect(forged.body.code).toBe('bad_signature') + const wrongOp = await post( + '/sessions', + await accountEnvelope('account/register', key), + { 'x-ww-dev-bypass': TEST_DEV_BYPASS } + ) + expect(wrongOp.status).toBe(401) + const stale = await post( + '/sessions', + await accountEnvelope('session/open', key, { ts: Date.now() - 6 * MINUTE }), + { 'x-ww-dev-bypass': TEST_DEV_BYPASS } + ) + expect(stale.status).toBe(401) + expect(stale.body.code).toBe('stale') + }) + + it('never takes the bypass on production, or a wrong bypass token', async () => { + const key = await SigningKey.generate() + const envelope = await accountEnvelope('session/open', key) + const wrong = await post('/sessions', envelope, { + 'x-ww-dev-bypass': 'not-the-token', + }) + expect(wrong.status).toBe(400) + expect(wrong.body).toMatchObject({ code: 'bad_request', reason: 'invalid_request' }) + + // Production never has the token, so the header is ignored there; with + // the token set, the worker fails loudly instead (src/devBypass.ts). + h.env.APP_ATTEST_ENVIRONMENT = 'production' + const misconfigured = await post( + '/sessions', + await accountEnvelope('session/open', key), + { 'x-ww-dev-bypass': TEST_DEV_BYPASS } + ) + expect(misconfigured.status).toBe(500) + delete h.env.NOTES_IMPORT_DEV_BYPASS_TOKEN + const prod = await post( + '/sessions', + await accountEnvelope('session/open', key), + { 'x-ww-dev-bypass': TEST_DEV_BYPASS } + ) + expect(prod.status).toBe(400) + expect(prod.body.code).toBe('bad_request') + expect(sessionRows()).toEqual([]) + }) + + it('counts 20 opens per account key a rolling day', async () => { + const key = await SigningKey.generate() + for (let i = 0; i < 20; i++) await open(key) + const refused = await post( + '/sessions', + await accountEnvelope('session/open', key), + { 'x-ww-dev-bypass': TEST_DEV_BYPASS } + ) + expect(refused.status).toBe(429) + expect(refused.body.code).toBe('rate_limited') + expect(Number(refused.headers.get('retry-after'))).toBeGreaterThan(0) + // Another account isn't affected. + await open() + }) + + it('ends the oldest session when an eleventh opens', async () => { + const key = await SigningKey.generate() + const sessions = [] + for (let i = 0; i < 11; i++) { + sessions.push(await open(key)) + advance(SECOND) + } + const live = sessionRows().filter((row) => row.revoked_at == null) + expect(live).toHaveLength(10) + expect((await refresh(sessions[0].refreshToken)).status).toBe(401) + expect((await refresh(sessions[1].refreshToken)).status).toBe(200) + expect((await refresh(sessions[10].refreshToken)).status).toBe(200) + }) +}) + +describe('POST /sessions/refresh', () => { + it('replaces both tokens every time', async () => { + const session = await open() + advance(MINUTE) + const first = await refresh(session.refreshToken) + expect(first.status).toBe(200) + expect(first.body).toEqual({ + ok: true, + accessToken: expect.any(String), + accessExpiresAt: Date.now() + 15 * MINUTE, + refreshToken: expect.stringMatching(REFRESH_TOKEN), + sessionExpiresAt: session.sessionExpiresAt, + }) + expect(first.body.refreshToken.split('.')[0]).toBe(session.sessionId) + expect(first.body.refreshToken).not.toBe(session.refreshToken) + expect(first.body.accessToken).not.toBe(session.accessToken) + expect((await info(first.body.accessToken)).status).toBe(200) + + advance(5 * MINUTE) + const second = await refresh(first.body.refreshToken) + expect(second.status).toBe(200) + expect(second.body.refreshToken).not.toBe(first.body.refreshToken) + }) + + it('accepts the replaced token for 60 seconds after a lost response', async () => { + const session = await open() + const rotated = await refresh(session.refreshToken) + const row = sessionRows()[0] + advance(59 * SECOND) + const retried = await refresh(session.refreshToken) + expect(retried.status).toBe(200) + // The same token the lost response carried, so none is left dangling; + // the retry writes nothing. + expect(retried.body.refreshToken).toBe(rotated.body.refreshToken) + expect(retried.body.accessToken).not.toBe(rotated.body.accessToken) + expect(sessionRows()[0]).toEqual(row) + const next = await refresh(retried.body.refreshToken) + expect(next.status).toBe(200) + expect((await refresh(next.body.refreshToken)).status).toBe(200) + }) + + it('counts the grace from the first rotation, so retries can’t stretch it', async () => { + const session = await open() + await refresh(session.refreshToken) + advance(40 * SECOND) + expect((await refresh(session.refreshToken)).status).toBe(200) + advance(40 * SECOND) + expect((await refresh(session.refreshToken)).status).toBe(401) + }) + + it('ends the session when a replaced token comes back after 60 seconds', async () => { + const session = await open() + const rotated = await refresh(session.refreshToken) + advance(61 * SECOND) + const reused = await refresh(session.refreshToken) + expect(reused.status).toBe(401) + expect(reused.body.code).toBe('unauthorized') + // The current token died with the session. + expect((await refresh(rotated.body.refreshToken)).status).toBe(401) + expect(sessionRows()[0].revoked_at).toBe(Date.now()) + }) + + it('refuses unknown and malformed tokens', async () => { + const unknown = await refresh(madeUp(b64u(randomBytes(16)))) + expect(unknown.status).toBe(401) + expect(unknown.body).toEqual({ + ok: false, + error: 'unauthorized', + code: 'unauthorized', + }) + expect(unknown.headers.get('www-authenticate')).toBe( + 'Bearer error="invalid_token"' + ) + expect((await refresh('too-short')).status).toBe(401) + expect((await refresh(42)).status).toBe(400) + expect((await post('/sessions/refresh', 'not json')).status).toBe(400) + }) + + it('lasts 90 days from open, however often it refreshes', async () => { + const session = await open() + let token = session.refreshToken + for (let day = 0; day < 89; day += 10) { + advance(10 * DAY) + const next = await refresh(token) + if (Date.now() >= session.sessionExpiresAt) { + expect(next.status).toBe(401) + return + } + expect(next.status).toBe(200) + expect(next.body.sessionExpiresAt).toBe(START + 90 * DAY) + expect(next.body.accessExpiresAt).toBeLessThanOrEqual(START + 90 * DAY) + token = next.body.refreshToken + } + vi.setSystemTime(START + 90 * DAY - 5 * MINUTE) + const last = await refresh(token) + expect(last.status).toBe(200) + // The access token stops with the session. + expect(last.body.accessExpiresAt).toBe(START + 90 * DAY) + vi.setSystemTime(START + 90 * DAY) + expect((await refresh(last.body.refreshToken)).status).toBe(401) + }) +}) + +/** Every statement the accounts database prepares from here on. */ +const recordQueries = () => { + const d1 = h.accountsDb! + const prepare = d1.prepare.bind(d1) + const queries: string[] = [] + vi.spyOn(d1, 'prepare').mockImplementation((query: string) => { + queries.push(query) + return prepare(query) + }) + return queries +} + +/** + * Holds the first statement matching `pattern` when it runs, until + * `release()`: `waiting` resolves once it's held. Interleaves concurrent + * refreshes at a chosen point. + */ +const holdFirst = (pattern: RegExp) => { + const d1 = h.accountsDb! + const prepare = d1.prepare.bind(d1) + let held = false + let release!: () => void + const gate = new Promise((resolve) => (release = resolve)) + let reached!: () => void + const waiting = new Promise((resolve) => (reached = resolve)) + vi.spyOn(d1, 'prepare').mockImplementation((query: string) => { + const statement = prepare(query) + if (held || !pattern.test(query)) return statement + held = true + return { + bind: (...params: unknown[]) => { + const bound = statement.bind(...params) + return { + run: async () => { + reached() + await gate + return bound.run() + }, + } + }, + } as unknown as typeof statement + }) + return { waiting, release } +} + +const B64U_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' +/** Flips the last character's lowest bit, a padding bit for 16 and 32 bytes. */ +const setPaddingBit = (value: string): string => + value.slice(0, -1) + B64U_ALPHABET[B64U_ALPHABET.indexOf(value.at(-1)!) ^ 1] + +describe('refresh token reuse', () => { + it('ends the session when any older token comes back, at any time', async () => { + const session = await open() + const first = await refresh(session.refreshToken) + advance(10 * SECOND) + const second = await refresh(first.body.refreshToken) + expect(second.status).toBe(200) + // Still inside the first rotation's minute, but two tokens old. + advance(5 * SECOND) + expect(await refresh(session.refreshToken)).toMatchObject({ + status: 401, + body: { code: 'unauthorized' }, + }) + expect(sessionRows()[0].revoked_at).toBe(Date.now()) + expect((await refresh(second.body.refreshToken)).status).toBe(401) + }) + + it('stores only hashes of whole tokens', async () => { + const session = await open() + const next = await refresh(session.refreshToken) + const stored = JSON.stringify(sessionRows()) + for (const token of [session.refreshToken, next.body.refreshToken]) { + expect(stored).not.toContain(token) + expect(stored).not.toContain(token.split('.')[1]) + expect(stored).not.toContain(token.split('.')[2]) + } + }) + + it('refuses malformed tokens without reading or writing the database', async () => { + const session = await open() + const [sessionId, secret, mac] = session.refreshToken.split('.') + const queries = recordQueries() + for (const token of [ + '', + secret, + b64u(randomBytes(32)), + // The two-part tokens from before the MAC. + `${sessionId}.${secret}`, + `${sessionId}.${secret}.${mac}.x`, + `${sessionId}.${secret}.`, + `${sessionId}${secret}${mac}`, + `${sessionId}..${mac}`, + `.${secret}.${mac}`, + `${sessionId}.${secret}.${mac}=`, + `${sessionId}.${secret}.${mac.slice(0, -1)}`, + `${sessionId}.${secret.slice(0, -1)}.${mac}`, + `${sessionId}.${secret}.${b64u(randomBytes(33))}`, + `${sessionId}.${b64u(randomBytes(33))}.${mac}`, + `${b64u(randomBytes(15))}.${secret}.${mac}`, + `${session.refreshToken} `, + 'x'.repeat(5_000), + // Non-canonical: the same bytes, spelled with a padding bit set. + `${setPaddingBit(sessionId)}.${secret}.${mac}`, + `${sessionId}.${setPaddingBit(secret)}.${mac}`, + `${sessionId}.${secret}.${setPaddingBit(mac)}`, + `${sessionId}.${secret.replace(/./, '+')}.${mac}`, + `${sessionId}.${secret}.${mac.replace(/./, '/')}`, + ]) { + expect((await refresh(token)).status, token).toBe(401) + } + expect(queries, JSON.stringify(queries)).toEqual([]) + expect(sessionRows()[0].revoked_at).toBeNull() + expect((await refresh(session.refreshToken)).status).toBe(200) + }) + + it('answers an unknown or ended session with 401 and writes nothing', async () => { + const session = await open() + expect((await close(bearer(session.accessToken))).status).toBe(200) + const row = sessionRows()[0] + const queries = recordQueries() + // A well-formed token for an unknown session needs a real MAC to get + // as far as the database. + const unknownSession = await issuedFor( + TEST_TOKEN_SECRET, + `${b64u(randomBytes(16))}.${b64u(randomBytes(32))}` + ) + expect((await refresh(unknownSession)).status).toBe(401) + expect((await refresh(session.refreshToken)).status).toBe(401) + expect(queries).toHaveLength(2) + expect(queries.every((query) => query.startsWith('select'))).toBe(true) + expect(sessionRows()[0]).toEqual(row) + }) + + it('keeps the minute’s retry working across a token secret rotation', async () => { + const session = await open() + const rotated = await refresh(session.refreshToken) + const oldSecret = h.env.ACCOUNT_TOKEN_SECRET! + h.env.ACCOUNT_TOKEN_SECRET = 'rotated-account-token-secret-0123456789' + h.env.ACCOUNT_TOKEN_SECRET_PREVIOUS = oldSecret + advance(10 * SECOND) + const retried = await refresh(session.refreshToken) + expect(retried.status).toBe(200) + expect(retried.body.refreshToken).toBe(rotated.body.refreshToken) + + // Without the previous slot, tokens signed under the old secret are + // refused, the current one included, but none ends the session. + delete h.env.ACCOUNT_TOKEN_SECRET_PREVIOUS + const row = sessionRows()[0] + expect((await refresh(session.refreshToken)).status).toBe(401) + expect((await refresh(rotated.body.refreshToken)).status).toBe(401) + expect(sessionRows()[0]).toEqual(row) + }) + + it('refreshes a token signed under the previous secret, signing its replacement under the new one', async () => { + const session = await open() + const oldSecret = h.env.ACCOUNT_TOKEN_SECRET! + h.env.ACCOUNT_TOKEN_SECRET = 'rotated-account-token-secret-0123456789' + h.env.ACCOUNT_TOKEN_SECRET_PREVIOUS = oldSecret + advance(MINUTE) + const rotated = await refresh(session.refreshToken) + expect(rotated.status).toBe(200) + expect(rotated.body.refreshToken).toBe( + await issuedFor( + 'rotated-account-token-secret-0123456789', + rotated.body.refreshToken.split('.').slice(0, 2).join('.') + ) + ) + + delete h.env.ACCOUNT_TOKEN_SECRET_PREVIOUS + advance(5 * MINUTE) + expect((await refresh(rotated.body.refreshToken)).status).toBe(200) + }) + + it('still ends the session when a token signed under the previous secret comes back', async () => { + const session = await open() + const first = await refresh(session.refreshToken) + h.env.ACCOUNT_TOKEN_SECRET_PREVIOUS = h.env.ACCOUNT_TOKEN_SECRET + h.env.ACCOUNT_TOKEN_SECRET = 'rotated-account-token-secret-0123456789' + advance(5 * MINUTE) + const second = await refresh(first.body.refreshToken) + expect(second.status).toBe(200) + // Both older tokens carry the old secret's MAC, so they're its own. + expect((await refresh(session.refreshToken)).status).toBe(401) + expect(sessionRows()[0].revoked_at).toBe(Date.now()) + expect((await refresh(second.body.refreshToken)).status).toBe(401) + }) +}) + +const encoder = new TextEncoder() + +const hmacKey = (raw: Uint8Array) => + crypto.subtle.importKey( + 'raw', + raw, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) + +/** An HMAC key derived like the worker's: SHA-256 of `label\nsecret`. */ +const derivedKey = async (label: string, secret: string) => + hmacKey( + new Uint8Array( + await crypto.subtle.digest( + 'SHA-256', + encoder.encode(`${label}\n${secret}`) + ) + ) + ) + +/** `body` with a MAC under `key`. */ +const withMac = async (key: CryptoKey, body: string) => { + const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(body)) + return `${body}.${b64u(new Uint8Array(mac))}` +} + +/** `body` with the MAC the worker signs under `secret`. */ +const issuedFor = async (secret: string, body: string) => + withMac(await derivedKey('ww-account/v1/refresh-mac', secret), body) + +describe('forged refresh tokens', () => { + it('never end a session: a made-up token for its id is refused before any read', async () => { + const session = await open() + const row = sessionRows()[0] + const queries = recordQueries() + for (let attempt = 0; attempt < 5; attempt++) { + expect(await refresh(madeUp(session.sessionId))).toMatchObject({ + status: 401, + body: { code: 'unauthorized' }, + }) + } + expect(queries).toEqual([]) + expect(sessionRows()[0]).toEqual(row) + expect((await refresh(session.refreshToken)).status).toBe(200) + }) + + it('refuses a real MAC moved to another secret or another session', async () => { + const victim = await open() + const attacker = await open() + const [attackerId, attackerSecret, attackerMac] = + attacker.refreshToken.split('.') + const rows = sessionRows() + const queries = recordQueries() + for (const token of [ + `${attackerId}.${b64u(randomBytes(32))}.${attackerMac}`, + `${victim.sessionId}.${attackerSecret}.${attackerMac}`, + `${victim.sessionId}.${victim.refreshToken.split('.')[1]}.${attackerMac}`, + ]) { + expect((await refresh(token)).status, token).toBe(401) + } + expect(queries).toEqual([]) + expect(sessionRows()).toEqual(rows) + expect((await refresh(victim.refreshToken)).status).toBe(200) + expect((await refresh(attacker.refreshToken)).status).toBe(200) + }) + + it('takes only a MAC under the refresh MAC key: the access-token key, the rotation key, or another secret never end a session', async () => { + const session = await open() + const body = () => `${session.sessionId}.${b64u(randomBytes(32))}` + for (const key of [ + await hmacKey(encoder.encode(TEST_TOKEN_SECRET)), + await derivedKey('ww-account/v1/refresh-rotation', TEST_TOKEN_SECRET), + await derivedKey('ww-account/v1/token-kid', TEST_TOKEN_SECRET), + await derivedKey( + 'ww-account/v1/refresh-mac', + 'some-other-account-token-secret-0123456789' + ), + ]) { + expect((await refresh(await withMac(key, body()))).status).toBe(401) + } + expect(sessionRows()[0].revoked_at).toBeNull() + + // The same made-up secret under the worker's own MAC key is a token it + // issued as far as it can tell, so it's reuse and ends the session: the + // MAC is all that kept the ones above from doing so. + const minted = await issuedFor(TEST_TOKEN_SECRET, body()) + expect((await refresh(minted)).status).toBe(401) + expect(sessionRows()[0].revoked_at).toBe(Date.now()) + expect((await refresh(session.refreshToken)).status).toBe(401) + }) + + it('refuses the two-part tokens from before the MAC without ending the session', async () => { + const session = await open() + const [sessionId, secret] = session.refreshToken.split('.') + const queries = recordQueries() + expect((await refresh(`${sessionId}.${secret}`)).status).toBe(401) + expect((await refresh(`${sessionId}.${b64u(randomBytes(32))}`)).status).toBe( + 401 + ) + expect(queries).toEqual([]) + expect(sessionRows()[0].revoked_at).toBeNull() + expect((await refresh(session.refreshToken)).status).toBe(200) + }) +}) + +describe('concurrent refreshes', () => { + it('rotates once when two refreshes send the same token, and both get the new one', async () => { + const session = await open() + // The first reads the row, then waits to rotate while the second + // reads the same row and rotates first. + const hold = holdFirst(/^update "account_sessions"/) + const first = refresh(session.refreshToken) + await hold.waiting + const second = await refresh(session.refreshToken) + hold.release() + const firstReply = await first + expect(second.status).toBe(200) + expect(firstReply.status).toBe(200) + expect(firstReply.body.refreshToken).toBe(second.body.refreshToken) + + // Whichever the device kept, the session goes on. + const [row] = sessionRows() + expect(row.revoked_at).toBeNull() + advance(10 * MINUTE) + expect((await refresh(firstReply.body.refreshToken)).status).toBe(200) + }) + + it('gives two retries of the replaced token the same token, without writing', async () => { + const session = await open() + const rotated = await refresh(session.refreshToken) + const row = sessionRows()[0] + const replies = await Promise.all([ + refresh(session.refreshToken), + refresh(session.refreshToken), + ]) + for (const reply of replies) { + expect(reply.status).toBe(200) + expect(reply.body.refreshToken).toBe(rotated.body.refreshToken) + } + expect(sessionRows()[0]).toEqual(row) + }) + + it('answers a rotation that lost its race with 401 if the session moved on, without ending it', async () => { + const session = await open() + const hold = holdFirst(/^update "account_sessions"/) + const first = refresh(session.refreshToken) + await hold.waiting + const second = await refresh(session.refreshToken) + // The device moves on before the first refresh looks again. + const third = await refresh(second.body.refreshToken) + expect(third.status).toBe(200) + hold.release() + expect((await first).status).toBe(401) + expect(sessionRows()[0].revoked_at).toBeNull() + expect((await refresh(third.body.refreshToken)).status).toBe(200) + }) +}) + +describe('DELETE /sessions/current', () => { + it('ends the session; repeating it is ok', async () => { + const session = await open() + const closed = await close(bearer(session.accessToken)) + expect(closed.status).toBe(200) + expect(closed.body).toEqual({ ok: true }) + expect((await refresh(session.refreshToken)).status).toBe(401) + const again = await close(bearer(session.accessToken)) + expect(again.status).toBe(200) + }) + + it('needs a valid bearer token', async () => { + const missing = await close() + expect(missing.status).toBe(401) + expect(missing.headers.get('www-authenticate')).toBe( + 'Bearer error="invalid_token"' + ) + expect( + (await close(bearer('nope'))).status + ).toBe(401) + const session = await open() + advance(15 * MINUTE) + expect( + (await close(bearer(session.accessToken))).status + ).toBe(401) + }) + + it('only ends the token’s own session', async () => { + const key = await SigningKey.generate() + const mine = await open(key) + const other = await open(key) + await close(bearer(mine.accessToken)) + expect((await refresh(other.refreshToken)).status).toBe(200) + }) +}) + +describe('without sessions configured', () => { + it('answers disabled without a token secret or the accounts database', async () => { + for (const options of [{ sessions: false }, { accountsDb: false }]) { + h = await createHarness(options) + const key = await SigningKey.generate() + const responses = [ + await post('/sessions', await accountEnvelope('session/open', key), { + 'x-ww-dev-bypass': TEST_DEV_BYPASS, + }), + await refresh(b64u(randomBytes(32))), + await close(bearer('x.y.z')), + ] + for (const response of responses) { + expect(response.status).toBe(503) + expect(response.body.code).toBe('disabled') + } + } + }) + + it('never lets a bearer token through without a secret', async () => { + const session = await open() + delete h.env.ACCOUNT_TOKEN_SECRET + const read = await info(session.accessToken) + expect(read.status).toBe(503) + expect(read.body.code).toBe('disabled') + }) +}) + +describe('pruning ended sessions', () => { + it('deletes sessions a week after they were revoked or expired', async () => { + const key = await SigningKey.generate() + const closed = await open(key) + await open(key) + await close(bearer(closed.accessToken)) + const db = accountsDb(h.env.ACCOUNTS_DB!) + + vi.setSystemTime(START + 7 * DAY - 1) + expect(await pruneEndedSessions(db)).toBe(0) + vi.setSystemTime(START + 7 * DAY) + expect(await pruneEndedSessions(db)).toBe(1) + expect(sessionRows()).toHaveLength(1) + + vi.setSystemTime(START + 97 * DAY) + expect(await pruneEndedSessions(db)).toBe(1) + expect(sessionRows()).toEqual([]) + }) +}) + +// --- Attestation --------------------------------------------------------------- + +const UUID = 'install-uuid-0001' +const KEY_ID = Buffer.from(randomBytes(32)).toString('base64') +const RECOVERY_TOKEN = b64u(randomBytes(32)) + +/** The real AppAttestIdentity with fake cryptography: `count:N` asserts. */ +const withAppAttest = async () => { + const clientData: string[] = [] + const identities = createNamespace( + (state) => + new AppAttestIdentity(state as never, h.env, { + now: () => Date.now(), + randomChallenge: () => randomToken(32), + verifyAttestation: async (args) => ({ + spki: `spki:${args.keyId}`, + environment: 'development', + bundleId: args.bundleIds[0], + }), + verifyAssertion: async (args) => { + clientData.push(args.clientData) + const match = /^count:(\d+)$/.exec(args.assertion) + if (!match) throw new AppAttestError('synthetic assertion invalid') + return { signCount: Number(match[1]) } + }, + }) + ) + h.env.APP_ATTEST_IDENTITY = + identities.namespace as unknown as Environment['APP_ATTEST_IDENTITY'] + const lifecycle = appAttestLifecycle(h.env) + const bind = { + protocolVersion: 2 as const, + operation: 'bind' as const, + operationId: 'bind-operation-1', + uuid: UUID, + keyId: KEY_ID, + } + const { challenge } = (await lifecycle.issueChallenge(bind)) as { + challenge: string + } + await lifecycle.register({ + ...bind, + challenge, + attestation: 'attestation', + recoveryToken: RECOVERY_TOKEN, + }) + return { clientData } +} + +const appAttestFields = (hash: string, operationId = 'session-operation-1') => ({ + protocolVersion: 2, + operation: 'assert', + operationId, + uuid: UUID, + keyId: KEY_ID, + purpose: 'account-session', + contentHash: hash, + requestHash: hash, +}) + +const openWith = async (key: SigningKey, attestation: unknown) => + post('/sessions', await accountEnvelope('session/open', key, { attestation })) + +describe('session attestation (App Attest)', () => { + it('binds the assertion to the envelope’s account key', async () => { + const { clientData } = await withAppAttest() + const key = await SigningKey.generate() + const hash = await sessionAttestationHash(key.publicKey) + const fields = appAttestFields(hash) + + const issued = await requestChallenge(fields) + expect(issued.status).toBe(200) + expect(issued.body).toEqual({ + protocolVersion: 2, + operation: 'assert', + operationId: fields.operationId, + challenge: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + expiresAt: Date.now() + 5 * MINUTE, + }) + const { challenge } = issued.body + const opened = await openWith(key, { + ...fields, + challenge, + assertion: 'count:1', + }) + expect(opened.status).toBe(201) + expect(opened.body.status).toBe('active') + expect(sessionRows()[0].platform).toBe('ios') + expect(clientData).toEqual([ + [ + 'witnesswork.app-attest', + '2', + 'assert', + 'account-session', + fields.operationId, + challenge, + UUID, + '', + hash, + hash, + ].join('|'), + ]) + + // The challenge is spent: a replay of the same request is refused. + const replayed = await openWith(key, { + ...fields, + challenge, + assertion: 'count:1', + }) + expect(replayed.status).toBe(403) + expect(replayed.body).toMatchObject({ + code: 'attestation_failed', + reason: 'challenge_not_found', + }) + expect(sessionRows()).toHaveLength(1) + }) + + it('refuses an attestation for another key', async () => { + await withAppAttest() + const key = await SigningKey.generate() + const thief = await SigningKey.generate() + const hash = await sessionAttestationHash(key.publicKey) + const fields = appAttestFields(hash) + const { challenge } = (await requestChallenge(fields)).body + const attestation = { ...fields, challenge, assertion: 'count:1' } + + // Moved as is: the hashes name another key. + const moved = await openWith(thief, attestation) + expect(moved.status).toBe(400) + expect(moved.body).toMatchObject({ code: 'bad_request', reason: 'invalid_request' }) + + // Relabeled for the thief's key: the challenge was issued for the other. + const thiefHash = await sessionAttestationHash(thief.publicKey) + const relabeled = await openWith(thief, { + ...attestation, + contentHash: thiefHash, + requestHash: thiefHash, + }) + expect(relabeled.status).toBe(403) + expect(relabeled.body).toMatchObject({ + code: 'attestation_failed', + reason: 'operation_conflict', + }) + expect(sessionRows()).toEqual([]) + + // The rightful key still opens with it. + expect((await openWith(key, attestation)).status).toBe(201) + }) + + it('refuses a bad assertion with a stable reason', async () => { + await withAppAttest() + const key = await SigningKey.generate() + const fields = appAttestFields(await sessionAttestationHash(key.publicKey)) + const { challenge } = (await requestChallenge(fields)).body + const refused = await openWith(key, { + ...fields, + challenge, + assertion: 'not-an-assertion', + }) + expect(refused.status).toBe(403) + expect(refused.body).toMatchObject({ + ok: false, + code: 'attestation_failed', + reason: 'assertion_invalid', + }) + }) + + it('needs the install’s key bound first', async () => { + await withAppAttest() + const key = await SigningKey.generate() + const fields = { + ...appAttestFields(await sessionAttestationHash(key.publicKey)), + uuid: 'another-install-0002', + } + const { challenge } = (await requestChallenge(fields)).body + const refused = await openWith(key, { ...fields, challenge, assertion: 'count:1' }) + expect(refused.status).toBe(403) + expect(refused.body).toMatchObject({ + code: 'attestation_failed', + reason: 'identity_not_bound', + action: 'bind', + }) + }) + + it('issues account-session challenges through /attestation/v1/challenge', async () => { + await withAppAttest() + const fields = appAttestFields( + await sessionAttestationHash((await SigningKey.generate()).publicKey) + ) + const issued = await requestChallenge(fields) + expect(issued.status).toBe(200) + expect(issued.body.operationId).toBe(fields.operationId) + }) + + it('refuses an attestation for another purpose or with mismatched hashes', async () => { + await withAppAttest() + const key = await SigningKey.generate() + const hash = await sessionAttestationHash(key.publicKey) + for (const [index, change] of [ + { purpose: 'notes-import-kickoff' }, + { requestHash: 'b'.repeat(64) }, + { contentHash: 'b'.repeat(64) }, + ].entries()) { + const fields = { + ...appAttestFields(hash, `session-operation-${index + 10}`), + ...change, + } + const issued = await requestChallenge(fields) + expect(issued.status, JSON.stringify(change)).toBe(200) + const refused = await openWith(key, { + ...fields, + challenge: issued.body.challenge, + assertion: 'count:1', + }) + expect(refused.status, JSON.stringify(change)).toBe(400) + expect(refused.body).toMatchObject({ + code: 'bad_request', + reason: 'invalid_request', + }) + } + for (const attestation of [[], 'nope']) { + const refused = await openWith(key, attestation) + expect(refused.status).toBe(400) + expect(refused.body.code).toBe('bad_request') + } + // App Attest v1 never opens a session. + const v1 = await openWith(key, { + ...appAttestFields(hash), + protocolVersion: 1, + challenge: 'C'.repeat(43), + assertion: 'count:1', + }) + expect(v1.status).toBe(400) + expect(v1.body).toMatchObject({ + code: 'bad_request', + reason: 'unsupported_protocol', + }) + expect(sessionRows()).toEqual([]) + }) +}) + +describe('session attestation (Play Integrity)', () => { + const PACKAGE = 'com.leviwilkerson.jwtime' + + const withPlayIntegrity = async (device = ['MEETS_DEVICE_INTEGRITY']) => { + resetGoogleAuthState() + const account = await createTestServiceAccount() + const challenges = createNamespace( + (state) => new PlayIntegrityChallenges(state, {} as Environment) + ) + Object.assign(h.env, { + ANDROID_PACKAGE_NAME: PACKAGE, + PLAY_INTEGRITY_CLOUD_PROJECT_NUMBER: '123456789012', + PLAY_INTEGRITY_SERVICE_ACCOUNT_JSON: account.json, + PLAY_INTEGRITY_CHALLENGES: challenges.namespace, + }) + const binding = { value: '' } + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url === 'https://oauth2.googleapis.com/token') + return Response.json({ access_token: 'ya29.test', expires_in: 3600 }) + if (url.endsWith(':decodeIntegrityToken')) + return Response.json({ + tokenPayloadExternal: { + requestDetails: { + requestPackageName: PACKAGE, + requestHash: binding.value, + timestampMillis: String(Date.now()), + }, + appIntegrity: { + appRecognitionVerdict: 'PLAY_RECOGNIZED', + packageName: PACKAGE, + }, + deviceIntegrity: { deviceRecognitionVerdict: device }, + }, + }) + throw new Error(`unexpected fetch ${url}`) + }) + ) + return binding + } + + const attest = async (binding: { value: string }, key: SigningKey) => { + const hash = await sessionAttestationHash(key.publicKey) + const fields = { + attestationProvider: 'play-integrity', + protocolVersion: 1, + operation: 'assert', + operationId: 'play-session-operation-1', + uuid: UUID, + purpose: 'account-session', + contentHash: hash, + requestHash: hash, + } + const issued = await requestChallenge(fields) + expect(issued.status).toBe(200) + expect(issued.body).toMatchObject({ + attestationProvider: 'play-integrity', + challenge: expect.any(String), + }) + const { challenge } = issued.body + binding.value = await computePlayIntegrityRequestBinding({ + ...(fields as never), + challenge, + }) + return { ...fields, challenge, integrityToken: 'token.abc' } + } + + it('opens an Android session for a bound integrity token', async () => { + const binding = await withPlayIntegrity() + const key = await SigningKey.generate() + const opened = await openWith(key, await attest(binding, key)) + expect(opened.status).toBe(201) + expect(sessionRows()[0].platform).toBe('android') + expect((await info(opened.body.accessToken)).status).toBe(200) + }) + + it('refuses a device that fails integrity, with its reason', async () => { + const binding = await withPlayIntegrity(['MEETS_BASIC_INTEGRITY']) + const key = await SigningKey.generate() + const refused = await openWith(key, await attest(binding, key)) + expect(refused.status).toBe(403) + expect(refused.body).toMatchObject({ + code: 'attestation_failed', + reason: 'device_integrity_failed', + }) + expect(sessionRows()).toEqual([]) + }) + + it('refuses a token bound to another key', async () => { + const binding = await withPlayIntegrity() + const key = await SigningKey.generate() + const attestation = await attest(binding, key) + const moved = await openWith(await SigningKey.generate(), attestation) + expect(moved.status).toBe(400) + expect(moved.body.reason).toBe('invalid_request') + }) +}) diff --git a/src/accounts/session.ts b/src/accounts/session.ts new file mode 100644 index 0000000..115e0af --- /dev/null +++ b/src/accounts/session.ts @@ -0,0 +1,176 @@ +import type { AppContext } from '../types' +import { isPlainObject } from '../buddies/contracts' +import { buddiesErrorResponse } from '../buddies/errorResponse' +import { ACCOUNT_LIMITS, parseRefreshToken } from './contracts' +import { authenticateAccount, readPlainJson } from './auth' +import { isSessionDevBypass, verifySessionAttestation } from './attestation' +import { + guarded, + quotaName, + refuseAtEdge, + refuseOverDailyQuota, + verifiedBody, +} from './signed' +import { accountsDb, newAccountId, registerAccount } from './store' +import { closeSession, createSession, refreshSession } from './sessionStore' +import { + accessTokenKeys, + refreshKeys, + signAccessToken, + type AccessTokenKeys, +} from './tokens' +import type { SessionPlatform } from './schema' + +/** + * Attested sessions (witness-work ADR 0027, `docs/accounts-protocol.md`, + * "Sessions"): an account proves it's the app once, when it opens a session + * (`POST /accounts/v1/sessions`, attested through `/attestation/v1/challenge` + * under purpose `account-session`), and then calls with 15-minute access + * tokens it renews with `POST /accounts/v1/sessions/refresh`. Every route + * answers `disabled` without the accounts database or a token secret, so the + * worker never hands out a session it can't verify. + * + * Limits: opening counts against the Buddies register edge tier and 20 per + * account key a rolling day; refreshing and closing against the read tier. + * + * Privacy: nothing here logs or reports bodies, keys, tokens, account ids, or + * session ids. + */ + +/** The session a bearer token addresses: its own. */ +export const CURRENT_SESSION_PATH = '/accounts/v1/sessions/current' + +type Ready = { db: D1Database; keys: AccessTokenKeys } | { refusal: Response } + +const ready = async (c: AppContext): Promise => { + const db = c.env.ACCOUNTS_DB + const keys = db ? await accessTokenKeys(c.env) : null + return db && keys + ? { db, keys } + : { refusal: buddiesErrorResponse('disabled') } +} + +/** The tokens opening and refreshing a session hand back. */ +const issueTokens = async ( + keys: AccessTokenKeys, + session: { + id: string + accountId: string + platform: SessionPlatform + expiresAt: number + }, + refreshToken: string +) => { + const access = await signAccessToken(keys, { + accountId: session.accountId, + sessionId: session.id, + platform: session.platform, + sessionExpiresAt: session.expiresAt, + }) + return { ...access, refreshToken, sessionExpiresAt: session.expiresAt } +} + +/** + * `POST /sessions`: self-signed by the account key (op `session/open`) and + * attested for it (or the development bypass). Creates the account when the + * key has none, opens a session, and ends the account's oldest past ten. + * 201, with `Location` naming the session the new token addresses. + */ +const handleOpen = guarded('session/open', async (c) => { + const setup = await ready(c) + if ('refusal' in setup) return setup.refusal + const limited = await refuseAtEdge(c, 'inbox/register') + if (limited) return limited + + const verified = await verifiedBody(c, 'session/open') + if ('refusal' in verified) return verified.refusal + const { accountPub } = verified.payload + const { attestation, device } = verified.fields + + // Before the attestation, so failed attempts spend the quota too. Keyed on + // the key, which names one account, so a new account has one as well. + const overQuota = await refuseOverDailyQuota( + c.env, + await quotaName('session-open', accountPub), + ACCOUNT_LIMITS.sessionOpensPerDay, + 'session/open', + 'sessionOpensPerDay' + ) + if (overQuota) return overQuota + + let platform: SessionPlatform = 'dev' + if (!isSessionDevBypass(c)) { + const attested = await verifySessionAttestation(c, attestation, accountPub) + if (!attested.ok) return attested.response + platform = attested.platform + } + + const db = accountsDb(setup.db) + const account = await registerAccount(db, accountPub, newAccountId) + const { session, refreshToken } = await createSession( + db, + setup.keys.current, + { accountId: account.id, platform, device } + ) + return c.json( + { + ok: true, + accountId: account.id, + status: account.status, + sessionId: session.id, + ...(await issueTokens(setup.keys, session, refreshToken)), + }, + 201, + { Location: CURRENT_SESSION_PATH } + ) +}) + +/** + * `POST /sessions/refresh`: plain JSON `{ refreshToken }`, replaced every + * time. A malformed token, or one whose MAC doesn't verify, is 401 before + * any database read. + */ +const handleRefresh = guarded('session/refresh', async (c) => { + const setup = await ready(c) + if ('refusal' in setup) return setup.refusal + const limited = await refuseAtEdge(c, 'inbox/sync') + if (limited) return limited + + const body = await readPlainJson(c) + if (!isPlainObject(body) || typeof body.refreshToken !== 'string') + return buddiesErrorResponse('bad_request') + const token = parseRefreshToken(body.refreshToken) + if (!token) return buddiesErrorResponse('unauthorized') + const issued = await refreshSession( + accountsDb(setup.db), + refreshKeys(setup.keys), + token + ) + if (!issued) return buddiesErrorResponse('unauthorized') + return c.json({ + ok: true, + ...(await issueTokens(setup.keys, issued.session, issued.refreshToken)), + }) +}) + +/** `DELETE /sessions/current`: Bearer, no body. Ends the token's session; idempotent. */ +const handleClose = guarded('session/close', async (c) => { + const setup = await ready(c) + if ('refusal' in setup) return setup.refusal + const limited = await refuseAtEdge(c, 'inbox/sync') + if (limited) return limited + const auth = await authenticateAccount(c) + if (!auth.ok) return auth.response + await closeSession( + accountsDb(setup.db), + auth.account.accountId, + auth.account.sessionId + ) + return c.json({ ok: true }) +}) + +export const sessionHandlers = { + open: handleOpen, + refresh: handleRefresh, + close: handleClose, +} diff --git a/src/accounts/sessionStore.ts b/src/accounts/sessionStore.ts new file mode 100644 index 0000000..2da8b5a --- /dev/null +++ b/src/accounts/sessionStore.ts @@ -0,0 +1,362 @@ +import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' +import { bytesToBase64Url, sha256Bytes, timingSafeEqual } from '../crypto' +import { ACCOUNT_LIMITS, type RefreshToken } from './contracts' +import { accountSessions, type SessionPlatform } from './schema' +import type { AccountsDb } from './store' +import type { RefreshKeys } from './tokens' + +/** + * Session rows in the accounts database (`account_sessions`). A session is + * live until it's revoked or reaches `expiresAt`, 90 days after it opened. + * + * Refresh tokens are `..` (opaque to the app), so a + * token names its session even after it was replaced. Only the SHA-256 of a + * whole token is stored: the current one, and the one it replaced, which + * still works for a minute after the rotation so a lost response doesn't end + * the session. Presenting it later, or any older token of a live session, + * ends the session. + * + * The MAC (HMAC-SHA256 over `.` under `macKey`, from + * `ACCOUNT_TOKEN_SECRET` in src/accounts/tokens.ts) proves this worker + * issued the token. The session id is no secret (every access token carries + * it), so without the MAC anyone could end a session by sending its id with + * a made-up secret. A token whose MAC doesn't verify under the current or + * previous secret is refused before the database is read, and never ends a + * session. + * + * The token that replaces another is derived from it (HMAC under + * `rotationKey`, another key from the secret), so every refresh presenting + * the same token gets the same next one: a retry after a lost response, or + * two refreshes at once from one device, never leave the device holding a + * token the session no longer knows. + */ + +const { + sessionMs, + refreshGraceMs, + sessionsPerAccount, + endedSessionRetentionMs, +} = ACCOUNT_LIMITS + +export interface SessionRecord { + id: string + accountId: string + platform: SessionPlatform + /** Epoch milliseconds. */ + expiresAt: number +} + +/** A live session and the refresh token that now refreshes it. */ +export interface IssuedSession { + session: SessionRecord + refreshToken: string +} + +const randomBytes = (size: number): Uint8Array => + crypto.getRandomValues(new Uint8Array(size)) + +const encoder = new TextEncoder() + +/** b64u SHA-256 of the whole token's UTF-8. */ +const refreshHash = async (token: string): Promise => + bytesToBase64Url(await sha256Bytes(encoder.encode(token))) + +const withHash = async (token: string) => ({ + token, + hash: await refreshHash(token), +}) + +/** `body` (`.`) with its MAC under `keys`, and its hash. */ +const issueRefreshToken = async (keys: RefreshKeys, body: string) => { + const mac = await crypto.subtle.sign( + 'HMAC', + keys.macKey, + encoder.encode(body) + ) + return withHash(`${body}.${bytesToBase64Url(new Uint8Array(mac))}`) +} + +/** + * Whether `presented` carries a MAC from one of `keys`. `subtle.verify` + * compares in constant time. + */ +const isIssued = async ( + keys: readonly RefreshKeys[], + presented: RefreshToken +): Promise => { + const body = encoder.encode(presented.body) + for (const { macKey } of keys) { + if (await crypto.subtle.verify('HMAC', macKey, presented.mac, body)) + return true + } + return false +} + +/** A session's first refresh token: a random secret. */ +const firstRefreshToken = (keys: RefreshKeys, sessionId: string) => + issueRefreshToken(keys, `${sessionId}.${bytesToBase64Url(randomBytes(32))}`) + +/** The token that replaces `presented`, under one secret's `keys`. */ +const nextRefreshToken = async (keys: RefreshKeys, presented: RefreshToken) => { + const secret = await crypto.subtle.sign( + 'HMAC', + keys.rotationKey, + encoder.encode(`ww-account/v1/refresh-next\n${presented.token}`) + ) + return issueRefreshToken( + keys, + `${presented.sessionId}.${bytesToBase64Url(new Uint8Array(secret))}` + ) +} + +/** Rows a D1 write changed (Drizzle hands back D1's result for writes). */ +const changes = (result: unknown): number => + (result as { meta?: { changes?: number } }).meta?.changes ?? 0 + +const sessionColumns = { + id: accountSessions.id, + accountId: accountSessions.accountId, + platform: accountSessions.platform, + refreshHash: accountSessions.refreshHash, + previousRefreshHash: accountSessions.previousRefreshHash, + expiresAt: accountSessions.expiresAt, + revokedAt: accountSessions.revokedAt, + rotatedAt: accountSessions.rotatedAt, +} + +type SessionRow = { + id: string + accountId: string + platform: SessionPlatform + refreshHash: string + previousRefreshHash: string | null + expiresAt: Date + revokedAt: Date | null + rotatedAt: Date | null +} + +const isLive = (row: SessionRow, now: number): boolean => + row.revokedAt == null && row.expiresAt.getTime() > now + +const record = (row: SessionRow): SessionRecord => ({ + id: row.id, + accountId: row.accountId, + platform: row.platform, + expiresAt: row.expiresAt.getTime(), +}) + +/** + * Opens a session, then ends the account's oldest live sessions past + * `sessionsPerAccount`, in one batch. `keys` are the current secret's. + */ +export const createSession = async ( + db: AccountsDb, + keys: RefreshKeys, + input: { + accountId: string + platform: SessionPlatform + device: string | null + }, + now: number = Date.now() +): Promise => { + const id = bytesToBase64Url(randomBytes(16)) + const refresh = await firstRefreshToken(keys, id) + const expiresAt = now + sessionMs + // Every live session but the newest `sessionsPerAccount` (this one among + // them). `LIMIT -1` is SQLite's "no limit", which Drizzle can't spell. + const overflow = sql`SELECT ${accountSessions.id} FROM ${accountSessions} + WHERE ${accountSessions.accountId} = ${input.accountId} + AND ${accountSessions.revokedAt} IS NULL + AND ${accountSessions.expiresAt} > ${now} + ORDER BY ${accountSessions.createdAt} DESC, ${accountSessions.id} DESC + LIMIT -1 OFFSET ${sessionsPerAccount}` + await db.batch([ + db.insert(accountSessions).values({ + id, + accountId: input.accountId, + platform: input.platform, + device: input.device, + refreshHash: refresh.hash, + createdAt: new Date(now), + expiresAt: new Date(expiresAt), + }), + db + .update(accountSessions) + .set({ revokedAt: new Date(now) }) + .where(sql`${accountSessions.id} IN (${overflow})`), + ]) + return { + session: { + id, + accountId: input.accountId, + platform: input.platform, + expiresAt, + }, + refreshToken: refresh.token, + } +} + +const revoke = (db: AccountsDb, sessionId: string, now: number) => + db + .update(accountSessions) + .set({ revokedAt: new Date(now) }) + .where( + and(eq(accountSessions.id, sessionId), isNull(accountSessions.revokedAt)) + ) + +const findSession = async ( + db: AccountsDb, + sessionId: string +): Promise => + (await db + .select(sessionColumns) + .from(accountSessions) + .where(eq(accountSessions.id, sessionId)) + .get()) as SessionRow | undefined + +/** What a refresh comes to, for one read of the session's row. */ +type RefreshStep = + | { done: IssuedSession | null } + /** The row changed under this refresh: read it again. */ + | { changed: true } + +/** + * One refresh of a token this worker issued, against the row as read: the + * current token rotates (a compare-and-swap on the hash read, so only one + * refresh can rotate it), the replaced one within its minute gets the token + * it was replaced by again (no write), and any other token ends the session + * unless `raced` (this refresh saw its token current a moment ago). + */ +const refreshStep = async ( + db: AccountsDb, + keys: readonly RefreshKeys[], + presented: RefreshToken, + hash: string, + row: SessionRow | undefined, + now: number, + raced: boolean +): Promise => { + if (!row || !isLive(row, now)) return { done: null } + + if (timingSafeEqual(hash, row.refreshHash)) { + const next = await nextRefreshToken(keys[0], presented) + const rotated = await db + .update(accountSessions) + .set({ + refreshHash: next.hash, + previousRefreshHash: hash, + rotatedAt: new Date(now), + }) + .where( + and( + eq(accountSessions.id, row.id), + eq(accountSessions.refreshHash, hash), + isNull(accountSessions.revokedAt) + ) + ) + return changes(rotated) === 1 + ? { done: { session: record(row), refreshToken: next.token } } + : { changed: true } + } + + const replaced = + row.previousRefreshHash != null && + timingSafeEqual(hash, row.previousRefreshHash) + const inGrace = + row.rotatedAt != null && now - row.rotatedAt.getTime() <= refreshGraceMs + if (replaced && inGrace) { + // The current token is this one's replacement, derived and signed under + // the secret that rotated it: the current one, or the previous one just + // after the secret rotated. + for (const secretKeys of keys) { + const next = await nextRefreshToken(secretKeys, presented) + if (timingSafeEqual(next.hash, row.refreshHash)) + return { done: { session: record(row), refreshToken: next.token } } + } + return { done: null } + } + + // Issued by this worker, but replaced over a minute ago, or older still: + // someone kept a copy. + if (!raced) await revoke(db, row.id, now) + return { done: null } +} + +/** + * Replaces the session's refresh token. Null means 401 `unauthorized`: a + * token whose MAC doesn't verify (refused before any read, so it never ends + * a session), an unknown session, an ended or expired one, or a token the + * session replaced that isn't the one just replaced within its minute, which + * also ends the session. + * + * Within that minute the replaced token gets the same new token it was + * replaced by; the minute counts from the rotation, so retries can't stretch + * it. A refresh whose rotation lost a race to another with the same token + * reads the row once more and finds its token just replaced, so both answer + * with the same token; if the session moved on again in between, it's 401 + * and the session lives, since that token was current a moment ago. + * `keys` are the current secret's first, then the previous one's if set. + */ +export const refreshSession = async ( + db: AccountsDb, + keys: readonly RefreshKeys[], + presented: RefreshToken, + now: number = Date.now() +): Promise => { + if (!(await isIssued(keys, presented))) return null + const hash = await refreshHash(presented.token) + for (const raced of [false, true]) { + const row = await findSession(db, presented.sessionId) + const step = await refreshStep( + db, + keys, + presented, + hash, + row, + now, + raced + ) + if ('done' in step) return step.done + } + return null +} + +/** Ends the account's session; ending an ended one is a no-op. */ +export const closeSession = async ( + db: AccountsDb, + accountId: string, + sessionId: string, + now: number = Date.now() +): Promise => { + await db + .update(accountSessions) + .set({ revokedAt: new Date(now) }) + .where( + and( + eq(accountSessions.id, sessionId), + eq(accountSessions.accountId, accountId), + isNull(accountSessions.revokedAt) + ) + ) +} + +/** + * Deletes sessions that ended (revoked or expired) more than + * `endedSessionRetentionMs` ago. Run hourly by the cron. + */ +export const pruneEndedSessions = async ( + db: AccountsDb, + now: number = Date.now() +): Promise => { + const cutoff = new Date(now - endedSessionRetentionMs) + return changes( + await db + .delete(accountSessions) + .where( + or( + lte(accountSessions.expiresAt, cutoff), + lte(accountSessions.revokedAt, cutoff) + ) + ) + ) +} diff --git a/src/accounts/signed.ts b/src/accounts/signed.ts index 34191bc..7ebab73 100644 --- a/src/accounts/signed.ts +++ b/src/accounts/signed.ts @@ -1,9 +1,10 @@ import type { AppContext, Environment } from '../types' -import { Sentry } from '../sentry' +import { captureFailure } from '../sentry' import { bytesToBase64Url, sha256Bytes } from '../crypto' import { BUDDIES_LIMITS, decodeSignature, + isPlainObject, type BuddiesResult, type BuddiesRouteOp, } from '../buddies/contracts' @@ -34,8 +35,10 @@ import { accountSignedMessage, handoffBlobTooLarge, parseAccountKeyPayload, + sessionDeviceTooLarge, } from './contracts' import { accountsDb, findAccountByPub, type AccountRecord } from './store' +import { bearerOrLegacy, type AccountAuth } from './auth' /** A self-signed account op whose envelope, signature, and `ts` check out. */ export interface VerifiedCall { @@ -49,7 +52,8 @@ export type Verified = /** * Checks a parsed envelope for `op`: the key payload and the op's own fields - * (`bad_request`, or `too_large` for `handoff/send`'s oversized key), the signature + * (`bad_request`, or `too_large` for `handoff/send`'s oversized key and + * `session/open`'s oversized device label), the signature * by the named key (`bad_signature`), and the timestamp (`stale`). */ export const verifyEnvelope = async ( @@ -58,6 +62,8 @@ export const verifyEnvelope = async ( ): Promise> => { if (op === 'handoff/send' && envelope && handoffBlobTooLarge(envelope.payload)) return { refusal: buddiesErrorResponse('too_large') } + if (op === 'session/open' && envelope && sessionDeviceTooLarge(envelope.payload)) + return { refusal: buddiesErrorResponse('too_large') } const payload = envelope ? parseAccountKeyPayload(envelope.payload) : null const fields = envelope ? (ACCOUNT_FIELD_PARSERS[op](envelope.payload) as AccountFields | null) @@ -114,6 +120,56 @@ export const accountForKey = async ( : { refusal: buddiesErrorResponse('unknown_account') } } +/** A bearer request's fields for `op`, or its refusal. */ +export type BearerFields = + | { fields: AccountFields } + | { refusal: Response } + +/** How a converted op reads its fields, each way it can authenticate. */ +export interface AccountCallRead { + bearer: (account: AccountAuth) => Promise> + legacy: () => Promise> +} + +/** + * Runs an account op converted to bearer tokens (`bearerOrLegacy`). A bearer + * request is the token's account, with the fields `read.bearer` takes from + * the request (or its refusal). A legacy request, while the legacy switch is + * on, is the envelope `read.legacy` verifies, whose key must belong to a + * registered account (`unknown_account`), exactly as before sessions. + */ +export const accountCall = ( + c: AppContext, + read: AccountCallRead, + run: (accountId: string, fields: AccountFields) => Promise +): Promise => + bearerOrLegacy(c, { + bearer: async (account) => { + const bearer = await read.bearer(account) + return 'refusal' in bearer + ? bearer.refusal + : run(account.accountId, bearer.fields) + }, + legacy: async () => { + const verified = await read.legacy() + if ('refusal' in verified) return verified.refusal + const found = await accountForKey(c.env, verified.payload.accountPub) + if ('refusal' in found) return found.refusal + return run(found.account.id, verified.fields) + }, + }) + +/** A bearer request's fields for `op` from its JSON body, or `bad_request`. */ +export const plainFields = ( + op: K, + body: unknown +): BearerFields => { + const fields = isPlainObject(body) + ? (ACCOUNT_FIELD_PARSERS[op](body) as AccountFields | null) + : null + return fields ? { fields } : { refusal: buddiesErrorResponse('bad_request') } +} + /** * Counts the request against the Buddies edge tier `as` (named by a Buddies * op of that tier). Null admits. @@ -159,7 +215,7 @@ export const refuseOverDailyQuota = async ( }) } catch (error) { console.warn('accounts: quota unavailable; allowing', { op }) - Sentry.captureException(error) + captureFailure(error, op) return null } } @@ -178,7 +234,9 @@ export const replyWith = ( /** * Runs an accounts handler, turning a throw into a bare 500. * - * Privacy: the log names the op only, never the body, keys, or ids. + * Privacy: the log names the op only, never the body, keys, or ids, and a + * database error reaches Sentry as the op alone (`captureFailure`), since its + * message quotes the query's parameters. */ export const guarded = (op: string, handler: (c: AppContext) => Promise) => @@ -187,7 +245,7 @@ export const guarded = return await handler(c) } catch (error) { console.error('accounts: request failed', { op }) - Sentry.captureException(error) + captureFailure(error, op) return buddiesInternalError() } } diff --git a/src/accounts/store.ts b/src/accounts/store.ts index 5d3e565..c711715 100644 --- a/src/accounts/store.ts +++ b/src/accounts/store.ts @@ -1,5 +1,6 @@ import { asc, eq } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/d1' +import { bytesToBase64Url } from '../crypto' import { accountInboxes, accountStatusEvents, @@ -11,6 +12,10 @@ import { export const accountsDb = (d1: D1Database) => drizzle(d1) export type AccountsDb = ReturnType +/** A fresh account id: b64u of 16 random bytes. */ +export const newAccountId = (): string => + bytesToBase64Url(crypto.getRandomValues(new Uint8Array(16))) + export interface AccountRecord { id: string accountPub: string @@ -79,23 +84,21 @@ export const findInboxAccount = async ( /** * Links the inbox to the account unless it's already linked. Returns the * account the inbox belongs to afterwards, which differs from `accountId` only - * when another account linked it first. + * when another account linked it first, with its status as read after the + * link was written. */ export const linkInbox = async ( db: AccountsDb, inboxId: string, accountId: string -): Promise => { +): Promise => { await db .insert(accountInboxes) .values({ inboxId, accountId }) .onConflictDoNothing({ target: accountInboxes.inboxId }) - const row = await db - .select({ accountId: accountInboxes.accountId }) - .from(accountInboxes) - .where(eq(accountInboxes.inboxId, inboxId)) - .get() - return row?.accountId ?? accountId + const owner = await findInboxAccount(db, inboxId) + if (!owner) throw new Error('accounts: linked inbox is missing') + return owner } export const accountInboxIds = async ( @@ -124,8 +127,9 @@ export const setAccountStatus = async ( } /** - * What `account/info` shows the account's own devices: its row, the inboxes - * it owns, and when its status changed. Status reasons stay with moderation. + * What `GET /account` (and the legacy `account/info`) shows the account's own + * devices: its row, the inboxes it owns, and when its status changed. Status + * reasons stay with moderation. */ export interface AccountInfo extends AccountRecord { createdAt: number @@ -135,12 +139,16 @@ export interface AccountInfo extends AccountRecord { export const findAccountInfo = async ( db: AccountsDb, - accountPub: string + by: { accountPub: string } | { accountId: string } ): Promise => { const account = await db .select({ ...accountColumns, createdAt: accounts.createdAt }) .from(accounts) - .where(eq(accounts.accountPub, accountPub)) + .where( + 'accountId' in by + ? eq(accounts.id, by.accountId) + : eq(accounts.accountPub, by.accountPub) + ) .get() if (!account) return null const [inboxIds, statusChanges] = await Promise.all([ diff --git a/src/accounts/supporter.ts b/src/accounts/supporter.ts new file mode 100644 index 0000000..94d70fd --- /dev/null +++ b/src/accounts/supporter.ts @@ -0,0 +1,36 @@ +import type { Environment } from '../types' +import { isSupporter } from '../revenuecat' + +/** + * Supporter status for an account (witness-work ADR 0027 §4). The one seam + * every account-scoped Supporter check goes through: Notes Import asks it at + * kickoff and again when a run settles. + * + * Until the RevenueCat identity is linked to the account, the app names its + * RevenueCat app user id on each request (`revenueCatAppUserId`), and that id + * is checked with RevenueCat exactly as the pre-session routes check theirs; + * without one the account is not a Supporter. The linking change replaces + * this body with the account's own linked id (or the status RevenueCat + * webhooks keep on it) and drops the request field. + * + * Rejects as `isSupporter` does (`RevenueCatError` included), so callers keep + * their own fail-closed handling. + */ +export interface AccountSupporterLookup { + /** The session's account. Unused until the RevenueCat link exists. */ + accountId: string + /** The RevenueCat app user id the request named, or null. */ + revenueCatAppUserId: string | null +} + +export const isAccountSupporter = async ( + env: Pick, + account: AccountSupporterLookup, + checkSupporter: typeof isSupporter = isSupporter +): Promise => { + if (!account.revenueCatAppUserId) return false + return checkSupporter({ + apiKey: env.REVENUECAT_API_KEY, + appUserId: account.revenueCatAppUserId, + }) +} diff --git a/src/accounts/tokens.test.ts b/src/accounts/tokens.test.ts new file mode 100644 index 0000000..c695a1e --- /dev/null +++ b/src/accounts/tokens.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from 'vitest' +import { bytesToBase64Url } from '../crypto' +import { + ACCESS_TOKEN_AUDIENCE, + accessTokenKeys, + refreshKeys, + signAccessToken, + verifyAccessToken, + type TokenEnv, +} from './tokens' + +const SECRET = 'a-token-secret-of-at-least-32-characters!' +const OTHER_SECRET = 'another-token-secret-also-32-characters!!' +const NOW = Date.UTC(2026, 9, 15, 12) +const SESSION = { + accountId: 'AAAAAAAAAAAAAAAAAAAAAA', + sessionId: 'BBBBBBBBBBBBBBBBBBBBBB', + platform: 'ios' as const, +} +const encoder = new TextEncoder() + +const env = (overrides: Partial = {}): TokenEnv => ({ + ACCOUNT_TOKEN_SECRET: SECRET, + APP_ATTEST_ENVIRONMENT: 'production', + ...overrides, +}) + +const keysFor = async (overrides: Partial = {}) => { + const keys = await accessTokenKeys(env(overrides)) + if (!keys) throw new Error('expected keys') + return keys +} + +const segment = (value: unknown) => + bytesToBase64Url(encoder.encode(JSON.stringify(value))) + +/** A token with any header and claims, signed (HS256) by `secret`. */ +const forge = async ( + header: Record, + claims: Record, + secret = SECRET +) => { + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) + const input = `${segment(header)}.${segment(claims)}` + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(input)) + return `${input}.${bytesToBase64Url(new Uint8Array(signature))}` +} + +/** The claims a real token carries, to vary one at a time. */ +const realParts = async (keys: Awaited>) => { + const { accessToken } = await signAccessToken(keys, SESSION, NOW) + const [header, payload] = accessToken.split('.') + const decode = (part: string) => + JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) + return { header: decode(header), claims: decode(payload) } +} + +describe('access tokens', () => { + it('round-trips the account, session, and platform for 15 minutes', async () => { + const keys = await keysFor() + const { accessToken, accessExpiresAt } = await signAccessToken( + keys, + SESSION, + NOW + ) + expect(accessExpiresAt).toBe(NOW + 15 * 60_000) + expect(await verifyAccessToken(keys, accessToken, NOW)).toEqual({ + ...SESSION, + expiresAt: accessExpiresAt, + }) + const { header, claims } = await realParts(keys) + expect(header).toEqual({ alg: 'HS256', typ: 'JWT', kid: keys.current.kid }) + expect(claims).toEqual({ + iss: 'ww-api/production', + aud: ACCESS_TOKEN_AUDIENCE, + sub: SESSION.accountId, + sid: SESSION.sessionId, + platform: 'ios', + iat: NOW / 1_000, + exp: NOW / 1_000 + 900, + }) + }) + + it('expires at exp, and never outlives its session', async () => { + const keys = await keysFor() + const { accessToken } = await signAccessToken(keys, SESSION, NOW) + expect( + await verifyAccessToken(keys, accessToken, NOW + 15 * 60_000 - 1) + ).not.toBeNull() + expect(await verifyAccessToken(keys, accessToken, NOW + 15 * 60_000)).toBeNull() + + const short = await signAccessToken( + keys, + { ...SESSION, sessionExpiresAt: NOW + 60_000 }, + NOW + ) + expect(short.accessExpiresAt).toBe(NOW + 60_000) + expect(await verifyAccessToken(keys, short.accessToken, NOW + 60_000)).toBeNull() + }) + + it('refuses another environment’s issuer, even under the same secret', async () => { + const dev = await keysFor({ APP_ATTEST_ENVIRONMENT: 'development' }) + const prod = await keysFor() + const { accessToken } = await signAccessToken(dev, SESSION, NOW) + expect(await verifyAccessToken(dev, accessToken, NOW)).not.toBeNull() + expect(await verifyAccessToken(prod, accessToken, NOW)).toBeNull() + }) + + it('refuses a dev-platform token on production', async () => { + const prod = await keysFor() + const dev = await keysFor({ APP_ATTEST_ENVIRONMENT: 'development' }) + const devSession = { ...SESSION, platform: 'dev' as const } + expect( + await verifyAccessToken(prod, (await signAccessToken(prod, devSession, NOW)).accessToken, NOW) + ).toBeNull() + expect( + await verifyAccessToken(dev, (await signAccessToken(dev, devSession, NOW)).accessToken, NOW) + ).toMatchObject({ platform: 'dev' }) + }) + + it('refuses a wrong audience, kid, algorithm, or claim shape', async () => { + const keys = await keysFor() + const { header, claims } = await realParts(keys) + const variants: [Record, Record][] = [ + [header, { ...claims, aud: 'ww-buddies' }], + [header, { ...claims, aud: [ACCESS_TOKEN_AUDIENCE] }], + [{ ...header, kid: 'not-a-known-kid' }, claims], + [{ ...header, kid: undefined }, claims], + [{ ...header, alg: 'HS512' }, claims], + [{ ...header, typ: 'JWS' }, claims], + [header, { ...claims, sub: 'short' }], + [header, { ...claims, sid: 42 }], + [header, { ...claims, platform: 'windows' }], + // The draft's short name never verifies. + [header, { ...claims, platform: undefined, plat: 'ios' }], + [header, { ...claims, exp: claims.iat + 901 }], + [header, { ...claims, iat: claims.iat + 120, exp: claims.iat + 600 }], + [header, { ...claims, exp: String(claims.exp) }], + ] + for (const [h, c] of variants) + expect(await verifyAccessToken(keys, await forge(h, c), NOW)).toBeNull() + // The unmodified forgery passes, so each refusal is the one change. + expect(await verifyAccessToken(keys, await forge(header, claims), NOW)).not.toBeNull() + }) + + it('refuses tampering and unsigned tokens', async () => { + const keys = await keysFor() + const { accessToken } = await signAccessToken(keys, SESSION, NOW) + const [header, payload, signature] = accessToken.split('.') + const other = segment({ + ...JSON.parse(Buffer.from(payload, 'base64url').toString()), + sub: 'CCCCCCCCCCCCCCCCCCCCCC', + }) + const flipped = `${signature.slice(0, 10)}${signature[10] === 'A' ? 'B' : 'A'}${signature.slice(11)}` + for (const token of [ + `${header}.${other}.${signature}`, + `${header}.${payload}.${flipped}`, + `${header}.${payload}.`, + `${segment({ alg: 'none', typ: 'JWT', kid: keys.current.kid })}.${payload}.`, + `${header}.${payload}`, + `${accessToken}.extra`, + 'not a token', + '', + `${header}.${payload}.${signature}${'A'.repeat(2_048)}`, + ]) + expect(await verifyAccessToken(keys, token, NOW)).toBeNull() + expect( + await verifyAccessToken(keys, await forge(JSON.parse(Buffer.from(header, 'base64url').toString()), JSON.parse(Buffer.from(payload, 'base64url').toString()), OTHER_SECRET), NOW) + ).toBeNull() + }) + + it('keeps the previous secret’s tokens valid through a rotation', async () => { + const before = await keysFor() + const { accessToken } = await signAccessToken(before, SESSION, NOW) + const rotated = await keysFor({ + ACCOUNT_TOKEN_SECRET: OTHER_SECRET, + ACCOUNT_TOKEN_SECRET_PREVIOUS: SECRET, + }) + expect(rotated.current.kid).not.toBe(before.current.kid) + expect(rotated.previous?.kid).toBe(before.current.kid) + expect(await verifyAccessToken(rotated, accessToken, NOW)).not.toBeNull() + // New tokens use the new secret. + const fresh = await signAccessToken(rotated, SESSION, NOW) + expect(await verifyAccessToken(before, fresh.accessToken, NOW)).toBeNull() + // Once the previous secret is gone, its tokens fail (the app refreshes). + const done = await keysFor({ ACCOUNT_TOKEN_SECRET: OTHER_SECRET }) + expect(await verifyAccessToken(done, accessToken, NOW)).toBeNull() + }) + + it('is off without a usable secret or a named environment', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + expect(await accessTokenKeys(env({ ACCOUNT_TOKEN_SECRET: undefined }))).toBeNull() + expect(await accessTokenKeys(env({ ACCOUNT_TOKEN_SECRET: ' ' }))).toBeNull() + expect(await accessTokenKeys(env({ ACCOUNT_TOKEN_SECRET: 'short' }))).toBeNull() + expect( + await accessTokenKeys(env({ APP_ATTEST_ENVIRONMENT: undefined as never })) + ).toBeNull() + const keys = await keysFor({ ACCOUNT_TOKEN_SECRET_PREVIOUS: 'short' }) + expect(keys.previous).toBeNull() + warn.mockRestore() + }) +}) + +describe('refresh token keys', () => { + const signWith = async (key: CryptoKey) => + bytesToBase64Url( + new Uint8Array( + await crypto.subtle.sign('HMAC', key, encoder.encode('message')) + ) + ) + + it('yields a MAC key and a rotation key per secret, the current first', async () => { + const rotated = await keysFor({ + ACCOUNT_TOKEN_SECRET: OTHER_SECRET, + ACCOUNT_TOKEN_SECRET_PREVIOUS: SECRET, + }) + const [current, previous] = refreshKeys(rotated) + expect(refreshKeys(rotated)).toHaveLength(2) + expect(refreshKeys(await keysFor())).toHaveLength(1) + const [own] = refreshKeys(await keysFor()) + expect(await signWith(previous.macKey)).toBe(await signWith(own.macKey)) + expect(await signWith(current.macKey)).not.toBe(await signWith(own.macKey)) + + // Each kept apart from the others and from the access-token key. + const macs = await Promise.all( + [own.macKey, own.rotationKey, rotated.previous!.key].map(signWith) + ) + expect(new Set(macs).size).toBe(3) + }) +}) diff --git a/src/accounts/tokens.ts b/src/accounts/tokens.ts new file mode 100644 index 0000000..8fda45c --- /dev/null +++ b/src/accounts/tokens.ts @@ -0,0 +1,289 @@ +import type { Environment } from '../types' +import { bytesToBase64Url, sha256Bytes } from '../crypto' +import { BUDDIES_ID_PATTERN, decodeB64u, isPlainObject } from '../buddies/contracts' +import { ACCOUNT_LIMITS, isAccountId } from './contracts' +import { SESSION_PLATFORMS, type SessionPlatform } from './schema' + +/** + * Access tokens (witness-work ADR 0027, `docs/accounts-protocol.md`, "The + * access token"): JWTs signed with HMAC-SHA256 under the Worker secret + * `ACCOUNT_TOKEN_SECRET`, 15 minutes each. Nothing is stored; a token is + * valid while its signature, issuer, audience, and expiry check out. + * + * Claims: the registered `iss`, `aud`, `sub` (the account id), `iat`, and + * `exp`, plus `sid` (the session id, as in OpenID Connect) and `platform` + * (`ios`, `android`, or `dev`). + * + * Rotation: the header's `kid` is derived from the secret that signed it, so + * moving the old secret to `ACCOUNT_TOKEN_SECRET_PREVIOUS` keeps its tokens + * valid until they expire. Without it, they fail once and the app refreshes. + * Refresh tokens issued under the old secret refresh only while it's there, + * since their MAC is checked under it; once it's gone, their sessions open + * anew. + * + * Each secret also yields the two refresh token keys + * (src/accounts/sessionStore.ts): the one each token's MAC is signed under + * (`macKey`), and the one the next token is derived under (`rotationKey`), + * each kept apart from the signing key and the other by its own derivation. + * + * The issuer names the environment (`APP_ATTEST_ENVIRONMENT`) and each + * environment has its own secret, so a development token never passes on + * production. + * + * Privacy: tokens carry the account id and session id; never log them. + */ + +export const ACCESS_TOKEN_AUDIENCE = 'ww-account' +/** `openssl rand -base64 32` is 44 characters; anything shorter is refused. */ +export const MIN_TOKEN_SECRET_CHARS = 32 +/** Clock skew allowed on `iat`, seconds. */ +const IAT_SKEW_SECONDS = 60 +/** Far beyond any token this worker mints; longer input isn't parsed. */ +const MAX_TOKEN_CHARS = 2_048 +const ACCESS_TOKEN_SECONDS = ACCOUNT_LIMITS.accessTokenMs / 1_000 + +export type TokenEnv = Pick< + Environment, + | 'ACCOUNT_TOKEN_SECRET' + | 'ACCOUNT_TOKEN_SECRET_PREVIOUS' + | 'APP_ATTEST_ENVIRONMENT' +> + +/** Who a verified access token says is calling. */ +export interface AccessTokenClaims { + accountId: string + sessionId: string + platform: SessionPlatform + /** Epoch milliseconds. */ + expiresAt: number +} + +/** + * The HMAC-SHA256 keys one secret yields for refresh tokens, each the + * SHA-256 of its own label and the secret. + */ +export interface RefreshKeys { + /** Signs every refresh token's MAC. */ + macKey: CryptoKey + /** Derives the token that replaces a refresh token. */ + rotationKey: CryptoKey +} + +interface TokenKey extends RefreshKeys { + kid: string + key: CryptoKey +} + +export interface AccessTokenKeys { + issuer: string + current: TokenKey + previous: TokenKey | null +} + +/** `ww-api/production` or `ww-api/development`; null when unset. */ +export const accessTokenIssuer = ( + env: Pick +): string | null => + env.APP_ATTEST_ENVIRONMENT === 'production' || + env.APP_ATTEST_ENVIRONMENT === 'development' + ? `ww-api/${env.APP_ATTEST_ENVIRONMENT}` + : null + +const encoder = new TextEncoder() +const keyCache = new Map>() + +const tokenKey = (secret: string): Promise => { + let cached = keyCache.get(secret) + if (!cached) { + cached = (async () => { + const digest = await sha256Bytes( + encoder.encode(`ww-account/v1/token-kid\n${secret}`) + ) + const hmac = (bytes: Uint8Array) => + crypto.subtle.importKey( + 'raw', + bytes, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'] + ) + const derived = (label: string) => + sha256Bytes(encoder.encode(`${label}\n${secret}`)).then(hmac) + const [key, macKey, rotationKey] = await Promise.all([ + hmac(encoder.encode(secret)), + derived('ww-account/v1/refresh-mac'), + derived('ww-account/v1/refresh-rotation'), + ]) + return { + kid: bytesToBase64Url(digest).slice(0, 16), + key, + macKey, + rotationKey, + } + })() + cached.catch(() => keyCache.delete(secret)) + keyCache.set(secret, cached) + } + return cached +} + +let warnedShortSecret = false + +const usableSecret = (value: string | undefined): string | null => { + const secret = value?.trim() + if (!secret) return null + if (secret.length >= MIN_TOKEN_SECRET_CHARS) return secret + if (!warnedShortSecret) { + warnedShortSecret = true + console.warn( + `accounts: a token secret is shorter than ${MIN_TOKEN_SECRET_CHARS} characters; ignoring it` + ) + } + return null +} + +/** + * The keys this worker signs and verifies with, or null when sessions are + * off: no usable `ACCOUNT_TOKEN_SECRET`, or no `APP_ATTEST_ENVIRONMENT` to + * name the issuer. Never mints an unsigned token. + */ +export const accessTokenKeys = async ( + env: TokenEnv +): Promise => { + const issuer = accessTokenIssuer(env) + const secret = usableSecret(env.ACCOUNT_TOKEN_SECRET) + if (!issuer || !secret) return null + const previousSecret = usableSecret(env.ACCOUNT_TOKEN_SECRET_PREVIOUS) + const [current, previous] = await Promise.all([ + tokenKey(secret), + previousSecret && previousSecret !== secret + ? tokenKey(previousSecret) + : null, + ]) + return { issuer, current, previous } +} + +/** Each secret's refresh token keys: the current secret's first. */ +export const refreshKeys = (keys: AccessTokenKeys): RefreshKeys[] => + (keys.previous ? [keys.current, keys.previous] : [keys.current]).map( + ({ macKey, rotationKey }) => ({ macKey, rotationKey }) + ) + +const encodeJson = (value: unknown): string => + bytesToBase64Url(encoder.encode(JSON.stringify(value))) + +const decodeJson = (segment: string): Record | null => { + const bytes = decodeB64u(segment) + if (!bytes) return null + try { + const value: unknown = JSON.parse( + new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes) + ) + return isPlainObject(value) ? value : null + } catch { + return null + } +} + +/** + * A 15-minute access token for this session, cut short to the session's own + * `sessionExpiresAt` (epoch ms) when that comes first. + */ +export const signAccessToken = async ( + keys: AccessTokenKeys, + session: { + accountId: string + sessionId: string + platform: SessionPlatform + sessionExpiresAt?: number + }, + now: number = Date.now() +): Promise<{ accessToken: string; accessExpiresAt: number }> => { + const iat = Math.floor(now / 1_000) + const exp = Math.min( + iat + ACCESS_TOKEN_SECONDS, + session.sessionExpiresAt == null + ? Infinity + : Math.floor(session.sessionExpiresAt / 1_000) + ) + const header = encodeJson({ alg: 'HS256', typ: 'JWT', kid: keys.current.kid }) + const payload = encodeJson({ + iss: keys.issuer, + aud: ACCESS_TOKEN_AUDIENCE, + sub: session.accountId, + sid: session.sessionId, + platform: session.platform, + iat, + exp, + }) + const signature = await crypto.subtle.sign( + 'HMAC', + keys.current.key, + encoder.encode(`${header}.${payload}`) + ) + return { + accessToken: `${header}.${payload}.${bytesToBase64Url(new Uint8Array(signature))}`, + accessExpiresAt: exp * 1_000, + } +} + +const isPlatform = (value: unknown): value is SessionPlatform => + SESSION_PLATFORMS.includes(value as SessionPlatform) + +const isSeconds = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value > 0 + +/** + * The claims of a token this worker signed and that hasn't expired, or null. + * Checks, in order: three base64url segments, an `HS256` header whose `kid` + * names a configured key, the signature, then `iss`, `aud`, `iat`, `exp`, and + * the shapes of `sub`, `sid`, and `platform`. A `dev` token never passes on + * production. + */ +export const verifyAccessToken = async ( + keys: AccessTokenKeys, + token: string, + now: number = Date.now() +): Promise => { + if (token.length > MAX_TOKEN_CHARS) return null + const parts = token.split('.') + if (parts.length !== 3) return null + const [headerPart, payloadPart, signaturePart] = parts + + const header = decodeJson(headerPart) + if ( + !header || + header.alg !== 'HS256' || + (header.typ !== undefined && header.typ !== 'JWT') || + typeof header.kid !== 'string' + ) + return null + const key = [keys.current, keys.previous].find( + (candidate) => candidate?.kid === header.kid + )?.key + if (!key) return null + + const signature = decodeB64u(signaturePart) + if (!signature || signature.length !== 32) return null + const valid = await crypto.subtle.verify( + 'HMAC', + key, + signature, + encoder.encode(`${headerPart}.${payloadPart}`) + ) + if (!valid) return null + + const claims = decodeJson(payloadPart) + if (!claims) return null + const { iss, aud, sub, sid, platform, iat, exp } = claims + const nowSeconds = now / 1_000 + if (iss !== keys.issuer || aud !== ACCESS_TOKEN_AUDIENCE) return null + if (!isSeconds(iat) || !isSeconds(exp)) return null + if (iat > nowSeconds + IAT_SKEW_SECONDS) return null + if (exp <= nowSeconds || exp - iat > ACCESS_TOKEN_SECONDS) return null + if (!isAccountId(sub)) return null + if (typeof sid !== 'string' || !BUDDIES_ID_PATTERN.test(sid)) return null + if (!isPlatform(platform)) return null + if (platform === 'dev' && keys.issuer === 'ww-api/production') return null + return { accountId: sub, sessionId: sid, platform, expiresAt: exp * 1_000 } +} diff --git a/src/app.ts b/src/app.ts index 80e304b..ec1220c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,7 +3,8 @@ * with Sentry and the cron handler, and re-exports the Durable Objects. */ import { Hono } from "hono"; -import type { Environment, AppContext, HealthCheckResponse } from "./types"; +import { routePath } from "hono/route"; +import type { AppEnv, AppContext, HealthCheckResponse } from "./types"; import { HERE_API, HTTP_STATUS } from "./config"; import { apiError, dateHeader } from "./errors"; import { rateLimit } from "./rateLimit"; @@ -52,7 +53,8 @@ import { verifyDocs as attestationVerifyDocs, } from "./attestation/openapi"; import { handleAppStoreRatingsRequest } from "./appStoreRatings/route"; -import { Sentry } from "./sentry"; +import { handleAppConfigRequest } from "./appConfig"; +import { Sentry, reportableError } from "./sentry"; import { createBuddiesRoutes } from "./buddies/route"; import { createAccountsRoutes } from "./accounts/route"; import { createAccountsAdminRoutes } from "./accounts/admin"; @@ -62,6 +64,7 @@ import { createRoutePlanningRoutes } from "./routePlanning/route"; import { mountApiDocs } from "./openapi/route"; import { aasaDocs, + appConfigDocs, appStoreRatingsDocs, autocompleteDocs, buddyInvitePageDocs, @@ -76,7 +79,7 @@ import { notesImportAdminResetDocs, } from "./openapi/coreDocs"; -export const app = new Hono<{ Bindings: Environment }>(); +export const app = new Hono(); async function handleGeocodeRequest(context: AppContext) { return proxyRequestToHereApi(context, HERE_API.GEOCODE_URL); @@ -112,9 +115,17 @@ function handleNotFound() { return apiError(HTTP_STATUS.NOT_FOUND, "not_found"); } -function handleApplicationError(error: Error) { - Sentry.captureException(error); - console.error("Application error:", error); +/** + * A route that threw. A database error is reported and logged as the route + * alone (`reportableError`), since its message quotes the query's parameters. + */ +function handleApplicationError(error: Error, context: AppContext) { + const reported = reportableError( + error, + `${context.req.method} ${routePath(context)}` + ); + Sentry.captureException(reported); + console.error("Application error:", reported); return apiError(HTTP_STATUS.INTERNAL_SERVER_ERROR, "server_error"); } @@ -126,10 +137,13 @@ app.use("/notes-import/*", rateLimit("notes-import")); app.use("/attestation/*", rateLimit("attestation")); app.use("/admin/*", rateLimit("admin")); app.use("/route-planning/*", rateLimit("route-planning")); +app.use("/app/v1/*", rateLimit("app")); app.get("/geocode", geocodeDocs, handleGeocodeRequest); app.get("/autocomplete", autocompleteDocs, handleAutocompleteRequest); app.get("/health", healthDocs, handleHealthCheckRequest); +// The app's minimum version (Update Required), read at launch and foreground. +app.get("/app/v1/config", appConfigDocs, handleAppConfigRequest); // Paywall social proof (App Store ratings), edge-cached; refreshed by cron. app.get( "/app-store/ratings", @@ -239,9 +253,12 @@ app.get("/c/:payload", legacyContactLinkDocs, handleLegacyContactLinkRequest); // Buddies relay: POST /buddies/v1/{op}, ids only in bodies. Every op is // rate-limited per caller inside the handler (src/buddies/limits.ts). app.route("/buddies/v1", createBuddiesRoutes()); -// Anonymous accounts (witness-work ADR 0024, 0026): POST /accounts/v1/{op}, -// signed like Buddies ops (handoff's new-phone ops carry a claim token -// instead) and rate-limited per caller inside the handler. +// Anonymous accounts (witness-work ADR 0024, 0026, 0027): sessions +// (`/accounts/v1/sessions`, attested once through /attestation/v1) hand out +// bearer tokens for `GET /accounts/v1/account` and the `POST +// /accounts/v1/{op}` handoff and backup ops; ops from before sessions still +// take signed envelopes while the legacy switch is on (handoff's new-phone ops +// carry a claim token instead). Rate-limited per caller inside the handler. app.route("/accounts/v1", createAccountsRoutes()); // Buddy invite no-app fallback; the invite secret stays in the URL fragment. // `/b` is the short path in invites already sent. diff --git a/src/appConfig.test.ts b/src/appConfig.test.ts new file mode 100644 index 0000000..ff326fb --- /dev/null +++ b/src/appConfig.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Environment } from './types' +import { makeMemoryKv } from './test/memoryKv' +import { resolveAppMinVersion } from './appConfig' + +vi.mock('cloudflare:workers', () => ({ DurableObject: class {} })) + +import { app } from './app' + +const env = async (kv?: string, APP_MIN_VERSION?: string) => { + const NOTES_KV = makeMemoryKv() + if (kv != null) await NOTES_KV.put('app:min-version', kv) + return { + NOTES_KV, + APP_MIN_VERSION, + RATE_LIMITER: { limit: async () => ({ success: true }) }, + } as unknown as Environment +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('GET /app/v1/config', () => { + it('serves the minimum version uncached, without authentication', async () => { + const response = await app.fetch( + new Request('https://ww-proxy.leviwilkerson.com/app/v1/config'), + await env('{"minVersion":"1.42.0"}') + ) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(await response.json()).toEqual({ ok: true, minAppVersion: '1.42.0' }) + }) + + it('leaves minAppVersion out without a floor', async () => { + const response = await app.fetch( + new Request('https://ww-proxy.leviwilkerson.com/app/v1/config'), + await env() + ) + expect(await response.json()).toEqual({ ok: true }) + }) + + it('counts against its own per-IP bucket', async () => { + const limiter = { keys: [] as string[] } + const environment = { + ...(await env()), + RATE_LIMITER: { + limit: async ({ key }: { key: string }) => { + limiter.keys.push(key) + return { success: false } + }, + }, + } as unknown as Environment + const response = await app.fetch( + new Request('https://ww-proxy.leviwilkerson.com/app/v1/config', { + headers: { 'cf-connecting-ip': '198.51.100.9' }, + }), + environment + ) + expect(response.status).toBe(429) + expect(limiter.keys).toEqual(['app:198.51.100.9']) + }) +}) + +describe('resolveAppMinVersion', () => { + it('reads KV, then APP_MIN_VERSION, ignoring invalid values', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + expect(await resolveAppMinVersion(await env('{"minVersion":"2.0.1"}', '1.0.0'))).toBe('2.0.1') + expect(await resolveAppMinVersion(await env(undefined, ' 1.5.0 '))).toBe('1.5.0') + expect(await resolveAppMinVersion(await env('{"minVersion":"v2"}', '1.5.0'))).toBe('1.5.0') + expect(await resolveAppMinVersion(await env('', '1.5.0'))).toBe('1.5.0') + expect(await resolveAppMinVersion(await env(undefined, 'latest'))).toBeNull() + expect(await resolveAppMinVersion(await env())).toBeNull() + expect(warn).toHaveBeenCalledWith('app: min-version KV value is invalid') + expect(warn).toHaveBeenCalledWith('APP_MIN_VERSION is invalid') + }) + + it('falls back to the var when KV fails', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const failing = { + NOTES_KV: { + get: async () => { + throw new Error('kv down') + }, + }, + APP_MIN_VERSION: '1.2.3', + } + expect(await resolveAppMinVersion(failing)).toBe('1.2.3') + }) +}) diff --git a/src/appConfig.ts b/src/appConfig.ts new file mode 100644 index 0000000..c664d4d --- /dev/null +++ b/src/appConfig.ts @@ -0,0 +1,54 @@ +import type { AppContext, Environment } from './types' +import { parseMinAppVersion } from './notesImport/config' + +/** + * `GET /app/v1/config` (witness-work ADR 0027, `docs/accounts-protocol.md`, + * "Updates and the legacy switch"): what every app version from sessions on + * reads at launch and on returning to the foreground. Below `minAppVersion` + * the app shows a blocking Update Required screen with a store link. It's the + * app-wide successor to Notes Import's own `notes-import:min-version`. + * + * No authentication; the worker never rejects a request for its version. + */ + +export const APP_MIN_VERSION_KEY = 'app:min-version' +const CACHE_TTL_SECONDS = 60 +const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/ + +/** + * The app's minimum version (`major.minor.patch`), or null without a floor: + * KV `app:min-version` (`{"minVersion":"1.42.0"}`), else the + * `APP_MIN_VERSION` var. Invalid values are logged and ignored; the KV read + * is edge-cached for 60 seconds. + */ +export const resolveAppMinVersion = async ( + env: Pick & { + NOTES_KV: Pick + } +): Promise => { + let raw: string | null = null + try { + raw = await env.NOTES_KV.get(APP_MIN_VERSION_KEY, { + cacheTtl: CACHE_TTL_SECONDS, + }) + } catch { + console.warn('app: min-version KV read failed; using APP_MIN_VERSION') + } + if (raw != null && raw.trim() !== '') { + const version = parseMinAppVersion(raw) + if (version) return version + console.warn('app: min-version KV value is invalid') + } + const fallback = env.APP_MIN_VERSION?.trim() + if (!fallback) return null + if (SEMVER_PATTERN.test(fallback)) return fallback + console.warn('APP_MIN_VERSION is invalid') + return null +} + +export const handleAppConfigRequest = async (c: AppContext) => { + const minAppVersion = await resolveAppMinVersion(c.env) + return c.json(minAppVersion ? { ok: true, minAppVersion } : { ok: true }, 200, { + 'Cache-Control': 'no-store', + }) +} diff --git a/src/attestation/http.ts b/src/attestation/http.ts index b9eee8f..84b4dab 100644 --- a/src/attestation/http.ts +++ b/src/attestation/http.ts @@ -326,6 +326,54 @@ export const verifyAppAttestV2Record = async ( } } +/** Which provider attested a request: App Attest (iOS) or Play Integrity. */ +export type AttestedPlatform = 'ios' | 'android' + +/** + * `requireAttestation`'s check for a route with its own error format (the + * accounts envelope, say): resolves with the platform that attested, or + * throws `AppAttestError` / `PlayIntegrityError` with its stable `reason`. + * A request that doesn't parse for `purpose` is `invalid_request`, and + * anything but App Attest v2 or Play Integrity `unsupported_protocol`. The + * route checks its hashes first, as for `requireAttestation`. + */ +export const verifyAttestation = async ( + ctx: AppContext, + record: Record, + purpose: AttestationPurpose +): Promise => { + if (isPlayIntegrityRequest(record)) { + const request = parsePlayIntegrityAssertionRequest(record, purpose) + if (!request) { + throw new PlayIntegrityError( + 'invalid_request', + 'Invalid Play Integrity request' + ) + } + await verifyPlayIntegrityAssertion( + ctx.env, + request, + playIntegrityDependencies + ) + return 'android' + } + if (appAttestProtocolVersion(record) !== 2) { + throw new AppAttestError( + 'This request requires App Attest v2 or Play Integrity', + { reason: 'unsupported_protocol', status: HTTP_STATUS.BAD_REQUEST } + ) + } + const request = parseV2AssertionFinalRequest(record, purpose) + if (!request) { + throw new AppAttestError('Invalid App Attest v2 assertion', { + reason: 'invalid_request', + status: HTTP_STATUS.BAD_REQUEST, + }) + } + await verifyV2Assertion(ctx, request) + return 'ios' +} + /** * The attestation check for a protected request: App Attest v2 (iOS) or Play * Integrity (Android), signed for `purpose`; App Attest v1 is refused. Null diff --git a/src/attestation/openapi.ts b/src/attestation/openapi.ts index e1929f4..4d6ffbb 100644 --- a/src/attestation/openapi.ts +++ b/src/attestation/openapi.ts @@ -74,7 +74,7 @@ export const purpose = z .enum(ATTESTATION_PURPOSES) .meta({ description: - 'Which protected request the challenge is for (signed into the assertion). It must match the route that consumes it: `attestation-verify` (or `notes-import-verify`) for verify, `notes-import-kickoff`, `notes-import-transcribe`.', + 'Which protected request the challenge is for (signed into the assertion). It must match the route that consumes it: `attestation-verify` (or `notes-import-verify`) for verify, `notes-import-kickoff`, `notes-import-transcribe`, and `account-session` for `POST /accounts/v1/sessions` (both hashes `hex(SHA-256(UTF-8("ww-account/v1/session\\n" + accountPub)))`).', }) export const expiresAt = z.number().int().meta({ diff --git a/src/attestation/purposes.ts b/src/attestation/purposes.ts index ebdb1d1..5e90b4c 100644 --- a/src/attestation/purposes.ts +++ b/src/attestation/purposes.ts @@ -18,6 +18,9 @@ export const ATTESTATION_PURPOSES = [ // The verify probe's purpose from when it lived at /notes-import/verify; // the deployed apps still sign it, so verify accepts it on both paths. 'notes-import-verify', + // Opening an account session, POST /accounts/v1/sessions + // (src/accounts/attestation.ts): both hashes cover the account key. + 'account-session', ] as const export type AttestationPurpose = (typeof ATTESTATION_PURPOSES)[number] @@ -26,6 +29,7 @@ export const ATTESTATION_VERIFY_PURPOSE = 'attestation-verify' export const NOTES_IMPORT_KICKOFF_PURPOSE = 'notes-import-kickoff' export const NOTES_IMPORT_TRANSCRIBE_PURPOSE = 'notes-import-transcribe' export const NOTES_IMPORT_VERIFY_PURPOSE = 'notes-import-verify' +export const ACCOUNT_SESSION_PURPOSE = 'account-session' const PURPOSES: ReadonlySet = new Set(ATTESTATION_PURPOSES) diff --git a/src/buddies/contracts.ts b/src/buddies/contracts.ts index b1965af..8ccdf2c 100644 --- a/src/buddies/contracts.ts +++ b/src/buddies/contracts.ts @@ -54,6 +54,23 @@ export const LEGACY_BUDDIES_ENVELOPE_HEADERS = { } as const /** Request headers that carry the live upgrade's `payload` and `signature`. */ export const BUDDIES_LIVE_HEADERS = BUDDIES_ENVELOPE_HEADERS +/** + * The account a bearer `inbox/live` upgrade's token named, set by the Worker + * on the request it hands the inbox DO. Never read from a client: the Worker + * deletes any copy a client sends before forwarding. + */ +export const BUDDIES_LIVE_ACCOUNT_HEADER = 'x-buddies-session-account' + +/** + * Names the part of the app that sent a request. The iOS Notification + * Service Extension sends `BUDDIES_NOTIFICATION_SERVICE_CLIENT` on its + * owner-signed `inbox/sync`: it can't hold the session's rotating refresh + * token, so it signs with the owner key even after the legacy switch is off, + * and that one legacy request stays served for an inbox with a confirmed + * account link. Ignored on every other request. + */ +export const BUDDIES_CLIENT_HEADER = 'x-buddies-client' +export const BUDDIES_NOTIFICATION_SERVICE_CLIENT = 'notification-service' /** * Photo blobs (encrypted note photos, too big for an event). `blob/put` is @@ -132,7 +149,14 @@ export const BUDDIES_ERROR_STATUS = { limit: 429, rate_limited: 429, disabled: 503, - /** `inbox/live` without a WebSocket upgrade. */ + /** + * `inbox/live` without a WebSocket upgrade, and any request authenticated + * the way it was before sessions (no `Authorization` header) once the + * legacy switch (`auth:legacy`) is off, but the Notification Service + * Extension's `inbox/sync` for an inbox with a confirmed account link + * (`BUDDIES_CLIENT_HEADER`). Never carries `Retry-After`: updating the app + * is the fix. + */ upgrade_required: 426, /** A `blob/put` body (declared or streamed) over `blobBytes`. */ too_large: 413, @@ -144,8 +168,26 @@ export const BUDDIES_ERROR_STATUS = { suspended: 403, /** `inbox/register` named an account the accounts database doesn't have. */ unknown_account: 404, - /** A handoff channel another account opened. */ + /** + * 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. + */ forbidden: 403, + /** + * A bearer Buddies owner request for an inbox with no confirmed account + * link: registered before accounts, or its linking registration failed + * part-way. The app links it with `inbox/register` and retries. + */ + not_linked: 409, + /** + * A missing, expired, or invalid access token, or an ended session's + * refresh token. Sent with `WWW-Authenticate: Bearer error="invalid_token"`. + */ + unauthorized: 401, + /** Opening a session refused the attestation (`reason` says why). */ + attestation_failed: 403, } as const export type BuddiesErrorCode = keyof typeof BUDDIES_ERROR_STATUS @@ -398,6 +440,9 @@ export interface SignedFields { nonce: string } +/** A payload without its signed `ts` and `nonce`: a bearer request's fields. */ +export type Unsigned = Omit + export interface InboxFields extends SignedFields { inboxId: string } @@ -578,14 +623,10 @@ const timestamp = (value: unknown): number | null => { return isSafeInteger(ms) ? ms : null } -const inboxFields = (p: Fields): InboxFields | null => { - const ts = timestamp(p.ts) - return ts != null && isId(p.nonce) && isId(p.inboxId) - ? { ts, nonce: p.nonce, inboxId: p.inboxId } - : null -} +const inboxFields = (p: Fields): Unsigned | null => + isId(p.inboxId) ? { inboxId: p.inboxId } : null -const slotFields = (p: Fields): SlotPayload | null => { +const slotFields = (p: Fields): Unsigned | null => { const base = inboxFields(p) return base && isId(p.slotId) ? { ...base, slotId: p.slotId } : null } @@ -599,6 +640,13 @@ const withBlob = ( return base && b ? { ...base, blob: b } : null } +/** An op's fields without `ts` and `nonce` (unsigned ops have none anyway). */ +export type BuddiesFields = Unsigned + +type FieldParsers = { + [K in BuddiesRouteOp]: (payload: Fields, now: number) => BuddiesFields | null +} + type PayloadParsers = { [K in BuddiesRouteOp]: ( payload: Fields, @@ -607,11 +655,14 @@ type PayloadParsers = { } /** - * Validates a decoded payload for one op and returns only the contract fields - * (unknown fields, including the reserved `attest`, are dropped). Keys and the - * claim verifier come back canonically encoded. Null means `bad_request`. + * Validates an op's fields and returns only the contract's (unknown fields, + * including the reserved `attest`, `ts`, and `nonce`, are dropped). Keys and + * the claim verifier come back canonically encoded. Null means + * `bad_request`. A bearer request's plain JSON body (or `x-buddies-payload`) + * goes through these alone; a signed payload through + * `BUDDIES_PAYLOAD_PARSERS`, which also takes its `ts` and `nonce`. */ -export const BUDDIES_PAYLOAD_PARSERS: PayloadParsers = { +export const BUDDIES_FIELD_PARSERS: FieldParsers = { 'inbox/register': (p) => { const base = inboxFields(p) const ownerPub = fixedBytes(p.ownerPub, 32) @@ -720,6 +771,29 @@ export const BUDDIES_PAYLOAD_PARSERS: PayloadParsers = { }, } +/** 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, +]) + +/** + * Validates a decoded envelope payload for one op: its fields + * (`BUDDIES_FIELD_PARSERS`), plus `ts` and `nonce` for every signed op. + * Null means `bad_request`. + */ +export const BUDDIES_PAYLOAD_PARSERS = Object.fromEntries( + (Object.keys(BUDDIES_FIELD_PARSERS) as BuddiesRouteOp[]).map((op) => [ + op, + (p: Fields, now: number) => { + const fields = BUDDIES_FIELD_PARSERS[op](p, now) + if (!fields || UNSIGNED_PAYLOAD_OPS.has(op)) return fields + const ts = timestamp(p.ts) + return ts != null && isId(p.nonce) ? { ...fields, ts, nonce: p.nonce } : null + }, + ]) +) as PayloadParsers + // --- Durable Object call shapes ------------------------------------------- /** The authenticated parts of a signed request, forwarded to the inbox DO. */ @@ -731,6 +805,21 @@ export interface SignedCall { export type Signed = T & SignedCall +/** + * An owner request authorized by an account session instead of the owner + * key (a bearer request): the Worker verified the access token, and the + * inbox DO compares `accountId` with the account the inbox is linked to. + * No `ts` or `nonce`: the token, not a signature, authenticates it. + */ +export type AccountCall = Unsigned & { accountId: string } + +/** An owner request as the inbox DO takes it: signed (legacy) or by account. */ +export type OwnerCall = Signed | AccountCall + +export const isAccountCall = ( + call: Signed | AccountCall +): call is AccountCall => !('signature' in call) + export type SyncSlot = { slotId: string createdAt: number diff --git a/src/buddies/envelope.ts b/src/buddies/envelope.ts index 9402237..d5a80ef 100644 --- a/src/buddies/envelope.ts +++ b/src/buddies/envelope.ts @@ -1,6 +1,7 @@ import { BUDDIES_BLOB_PUT_OP, BUDDIES_ENVELOPE_HEADERS, + BUDDIES_FIELD_PARSERS, BUDDIES_LIMITS, BUDDIES_LIVE_OP, BUDDIES_PAYLOAD_PARSERS, @@ -10,6 +11,7 @@ import { fail, isPlainObject, ok, + type BuddiesFields, type BuddiesPayloads, type BuddiesResult, type BuddiesSigningOp, @@ -192,6 +194,33 @@ export const readBlobPutCall = ( ): BuddiesResult> => readHeaderCall(BUDDIES_BLOB_PUT_OP, headers, now) +/** + * A bearer request's fields for `inbox/live` or `blob/put`: b64u(UTF-8 JSON) + * in `x-buddies-payload`, without `ts` or `nonce`. The token authenticates + * it, so a signature header (either naming) is `bad_request`, as is the short + * `x-buddies-p`, which only shipped legacy builds send. + */ +export const readHeaderFields = < + K extends typeof BUDDIES_LIVE_OP | typeof BUDDIES_BLOB_PUT_OP, +>( + op: K, + headers: Headers, + now: number +): BuddiesResult> => { + if ( + headers.has(BUDDIES_ENVELOPE_HEADERS.signature) || + headers.has(LEGACY_BUDDIES_ENVELOPE_HEADERS.signature) + ) { + return fail('bad_request') + } + const envelope = envelopeFromParts( + headers.get(BUDDIES_ENVELOPE_HEADERS.payload), + undefined + ) + const fields = envelope ? BUDDIES_FIELD_PARSERS[op](envelope.payload, now) : null + return fields ? ok(fields) : fail('bad_request') +} + /** * Reads a `blob/put` body of exactly `expected` bytes (the signed `bytes`, * at most `limit`, `blobBytes` by default) without buffering past it: a diff --git a/src/buddies/errorResponse.ts b/src/buddies/errorResponse.ts index a0ed2fd..808354e 100644 --- a/src/buddies/errorResponse.ts +++ b/src/buddies/errorResponse.ts @@ -15,23 +15,36 @@ const DEFAULT_RETRY_AFTER: Partial> = { rate_limited: EDGE_RETRY_AFTER_SECONDS, } +/** What every `unauthorized` refusal tells the client (RFC 6750). */ +export const BEARER_CHALLENGE = 'Bearer error="invalid_token"' + /** * A relay error in the shared envelope: `{ ok: false, error, code }` with the * protocol's code in both. Every 429 `rate_limited` and 503 `disabled` or * `photos_disabled` sends `Retry-After` (and `retryAfter`). `stale` carries the server clock, as a * `Date` header and `serverTime` (epoch ms, the unit of `ts`), so the client - * can correct its offset and re-sign. + * can correct its offset and re-sign. `unauthorized` sends + * `WWW-Authenticate`. `extras` adds fields beside the envelope (`reason` and + * `action` on `attestation_failed`). */ export const buddiesErrorResponse = ( error: BuddiesErrorCode, - options: { retryAfterSeconds?: number; now?: number } = {} + options: { + retryAfterSeconds?: number + now?: number + extras?: Record + } = {} ): Response => { const now = options.now ?? Date.now() const stale = error === 'stale' return apiError(BUDDIES_ERROR_STATUS[error], error, { retryAfter: options.retryAfterSeconds ?? DEFAULT_RETRY_AFTER[error], - extras: stale ? { serverTime: now } : undefined, - headers: stale ? dateHeader(now) : undefined, + extras: stale ? { ...options.extras, serverTime: now } : options.extras, + headers: stale + ? dateHeader(now) + : error === 'unauthorized' + ? { 'WWW-Authenticate': BEARER_CHALLENGE } + : undefined, }) } diff --git a/src/buddies/inboxDO.ts b/src/buddies/inboxDO.ts index bba9e33..0898be8 100644 --- a/src/buddies/inboxDO.ts +++ b/src/buddies/inboxDO.ts @@ -4,6 +4,7 @@ import { acceptedBundleIds } from '../appAttest/appId' import { bytesToBase64Url, sha256Bytes, timingSafeEqual } from '../crypto' import { BUDDIES_LIMITS as LIMITS, + BUDDIES_LIVE_ACCOUNT_HEADER, BUDDIES_LIVE_CLOSE, BUDDIES_LIVE_OP, INVITE_CLAIMED_KIND, @@ -11,9 +12,11 @@ import { buddyBlobKey, decodeB64u, fail, + isAccountCall, isImmediatePushKind, isPlainObject, ok, + type AccountCall, type BuddiesErrorCode, type BuddiesFailure, type BuddiesResult, @@ -27,6 +30,7 @@ import { type InboxFields, type InviteCreatePayload, type InviteDeletePayload, + type OwnerCall, type PushAddress, type PushJob, type PushTarget, @@ -44,6 +48,7 @@ import { } from './contracts' import { isWebSocketUpgrade, + readHeaderFields, readLiveCall, verifyBuddiesSignature, } from './envelope' @@ -103,7 +108,9 @@ const pushAddressOf = (row: DeviceRow): PushAddress | null => { } type KeyRef = 'owner' | 'writer' -type KeyedCall = InboxFields & { slotId?: string } +type KeyedCall = { inboxId: string; slotId?: string } +/** A writer op, or an owner op either way (`OwnerCall`). */ +type AuthCall = Signed | AccountCall /** Who signed, for `SignerBudget`: the owner, or a writer by its slot id. */ const signerOf = (call: KeyedCall, ref: KeyRef): string => @@ -312,12 +319,23 @@ const TOMBSTONE_SCHEMA = `CREATE TABLE IF NOT EXISTS owner_tombstone ( * The account that owns this inbox, and whether it's suspended (witness-work * ADR 0024). Not in `SCHEMA`: an inbox registered without an account doesn't * have it. A wipe deletes it; the accounts database keeps the link, and the - * Worker passes it back on the next registration. + * Worker passes it back on the next registration. Bearer owner requests + * (witness-work ADR 0027) are authorized against `account_id` once + * `confirmed`: another account is `forbidden`, and an inbox without a + * confirmed link is `not_linked`. + * + * `confirmed` means the accounts database holds the same link. The Worker + * only writes the row with the account that database names (an existing + * link, a new one once written, or a status change), so moderation, which + * finds an account's inboxes there, reaches every inbox its tokens can act + * for. Rows from before the column count as unconfirmed until the app links + * the inbox again. */ const OWNER_ACCOUNT_SCHEMA = `CREATE TABLE IF NOT EXISTS owner_account ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), account_id TEXT NOT NULL, - suspended INTEGER NOT NULL DEFAULT 0 + suspended INTEGER NOT NULL DEFAULT 0, + confirmed INTEGER NOT NULL DEFAULT 0 )` /** @@ -366,10 +384,16 @@ export class BuddyInbox extends DurableObject { } // --- Owner ops ---------------------------------------------------------- + // + // Each takes an `OwnerCall`: signed by the owner key (a legacy request), + // or an `AccountCall` the Worker authenticated with a bearer token, which + // `#authenticate` checks against the inbox's account and which has no + // `ts` or `nonce` to check or record. /** - * `accountId` is the account that owns the inbox, once the Worker has checked - * the link (null for registrations without one). It never clears a + * `accountId` is the account the accounts database already links the inbox + * to (null when it links none), taken as confirmed. A new link is confirmed + * with `confirmAccount` once that database has it. It never clears a * suspension: only `setAccountStatus` does. */ async register( @@ -410,10 +434,11 @@ export class BuddyInbox extends DurableObject { ) } if (accountId) { - this.#sql.exec(OWNER_ACCOUNT_SCHEMA) + this.#ensureOwnerAccount() this.#sql.exec( - `INSERT INTO owner_account (singleton, account_id) VALUES (1, ?) - ON CONFLICT (singleton) DO UPDATE SET account_id = excluded.account_id`, + `INSERT INTO owner_account (singleton, account_id, confirmed) VALUES (1, ?, 1) + ON CONFLICT (singleton) DO UPDATE + SET account_id = excluded.account_id, confirmed = 1`, accountId ) } @@ -424,15 +449,28 @@ export class BuddyInbox extends DurableObject { return result } - async sync(call: Signed): Promise> { - const auth = await this.#authenticate('inbox/sync', call, 'owner') + /** + * `linkedOnly`: a legacy (owner-signed) sync the Worker admitted after the + * legacy switch went off, from the iOS Notification Service Extension. + * Checked as ever, and served only while this inbox has a confirmed account + * link (its owner moved to sessions), else `upgrade_required`. + */ + async sync( + call: OwnerCall, + { linkedOnly = false }: { linkedOnly?: boolean } = {} + ): Promise> { + const auth = await this.#authenticate('inbox/sync', call, 'owner', { + linkedOnly, + }) if (!auth.ok) return auth return this.#commit(call, 'owner', auth.value, (now) => ok(this.#snapshot(call.since, now)) ) } - async deleteInbox(call: Signed): Promise> { + async deleteInbox( + call: OwnerCall + ): Promise> { const auth = await this.#authenticate('inbox/delete', call, 'owner') if (!auth.ok) return auth // Hash before the commit, so no other await sits between it and the wipe. @@ -444,7 +482,7 @@ export class BuddyInbox extends DurableObject { } async registerDevice( - call: Signed + call: OwnerCall ): Promise> { const { push } = call // Only topics this worker can sign for; absent means `IOS_BUNDLE_ID`. @@ -504,7 +542,7 @@ export class BuddyInbox extends DurableObject { } async unregisterDevice( - call: Signed + call: OwnerCall ): Promise> { const auth = await this.#authenticate('device/unregister', call, 'owner') if (!auth.ok) return auth @@ -517,7 +555,7 @@ export class BuddyInbox extends DurableObject { }) } - async addSlot(call: Signed): Promise> { + async addSlot(call: OwnerCall): Promise> { const auth = await this.#authenticate('slot/add', call, 'owner') if (!auth.ok) return auth let added = false @@ -544,7 +582,7 @@ export class BuddyInbox extends DurableObject { return result } - async removeSlot(call: Signed): Promise> { + async removeSlot(call: OwnerCall): Promise> { const auth = await this.#authenticate('slot/remove', call, 'owner') if (!auth.ok) return auth let removed = false @@ -557,7 +595,7 @@ export class BuddyInbox extends DurableObject { } async putRoster( - call: Signed + call: OwnerCall ): Promise> { const auth = await this.#authenticate('roster/put', call, 'owner') if (!auth.ok) return auth @@ -576,7 +614,7 @@ export class BuddyInbox extends DurableObject { } async createInvite( - call: Signed + call: OwnerCall ): Promise> { const auth = await this.#authenticate('invite/create', call, 'owner') if (!auth.ok) return auth @@ -666,7 +704,7 @@ export class BuddyInbox extends DurableObject { } async deleteInvite( - call: Signed + call: OwnerCall ): Promise> { const auth = await this.#authenticate('invite/delete', call, 'owner') if (!auth.ok) return auth @@ -795,7 +833,7 @@ export class BuddyInbox extends DurableObject { * uploads. */ async putBlob( - call: Signed, + call: OwnerCall, body: Uint8Array ): Promise> { const bucket = this.env.BUDDY_BLOBS @@ -914,7 +952,7 @@ export class BuddyInbox extends DurableObject { * in case one outlived its row. R2 failures are retried from the alarm. */ async deleteBlobs( - call: Signed + call: OwnerCall ): Promise> { const auth = await this.#authenticate('blob/delete', call, 'owner') if (!auth.ok) return auth @@ -939,13 +977,15 @@ export class BuddyInbox extends DurableObject { /** * `GET inbox/live`, forwarded by the Worker. Verifies the owner's signed - * headers (stale and replay checks included), accepts the socket with the - * Hibernation API, and greets it with the current `seq`. Opening a socket is + * headers (stale and replay checks included), or, for a bearer upgrade, + * the account the Worker verified (`BUDDIES_LIVE_ACCOUNT_HEADER`, which it + * sets only from a valid token), then accepts the socket with the + * Hibernation API and greets it with the current `seq`. Opening a socket is * not owner activity for the 180-day retention. */ async fetch(request: Request): Promise { if (!isWebSocketUpgrade(request)) return liveError(fail('upgrade_required')) - const call = readLiveCall(request.headers, Date.now()) + const call = this.#liveCall(request.headers, Date.now()) if (!call.ok) return liveError(call) const auth = await this.#authenticate(BUDDIES_LIVE_OP, call.value, 'owner') if (!auth.ok) return liveError(auth) @@ -974,6 +1014,17 @@ export class BuddyInbox extends DurableObject { return new Response(null, { status: 101, webSocket: client }) } + /** The upgrade's owner call: by account (bearer) or signed (legacy). */ + #liveCall( + headers: Headers, + now: number + ): BuddiesResult> { + const accountId = headers.get(BUDDIES_LIVE_ACCOUNT_HEADER) + if (accountId == null) return readLiveCall(headers, now) + const fields = readHeaderFields(BUDDIES_LIVE_OP, headers, now) + return fields.ok ? ok({ ...fields.value, accountId }) : fields + } + /** Sockets only listen; anything but the auto-answered `ping` is ignored. */ webSocketMessage(): void {} @@ -1077,20 +1128,49 @@ export class BuddyInbox extends DurableObject { }) } + /** + * The accounts database linked this inbox to `accountId`, after this inbox + * accepted the owner-signed registration: from now on that account's + * bearer requests act as the owner. It's the database's winner, so when + * two accounts claimed the inbox at once, the inbox names the same one. + * `suspended` is the account's status as read after the link was written, + * so a suspension that listed the account's inboxes just before still + * applies. Never clears a suspension. Idempotent; a no-op for an inbox + * that isn't registered. + */ + async confirmAccount(accountId: string, suspended: boolean): Promise { + if (!this.#ready() || !this.#meta()) return + this.ctx.storage.transactionSync(() => { + this.#ensureOwnerAccount() + this.#sql.exec( + `INSERT INTO owner_account (singleton, account_id, suspended, confirmed) VALUES (1, ?, ?, 1) + ON CONFLICT (singleton) DO UPDATE + SET account_id = excluded.account_id, confirmed = 1, + suspended = MAX(suspended, excluded.suspended)`, + accountId, + suspended ? 1 : 0 + ) + }) + if (suspended) this.#closeLiveSockets(BUDDIES_LIVE_CLOSE.suspended) + } + /** * The accounts admin suspended or reinstated this inbox's account. Owner ops * other than leaving answer `suspended` until it's reinstated, and live - * sockets close. An inbox that isn't registered keeps nothing: the Worker - * checks the accounts database before it registers again. + * sockets close. The admin found this inbox among the account's in the + * accounts database, so the link is confirmed too. An inbox that isn't + * registered keeps nothing: the Worker checks the accounts database before + * it registers again. */ async setAccountStatus(accountId: string, suspended: boolean): Promise { if (!this.#ready() || !this.#meta()) return this.ctx.storage.transactionSync(() => { - this.#sql.exec(OWNER_ACCOUNT_SCHEMA) + this.#ensureOwnerAccount() this.#sql.exec( - `INSERT INTO owner_account (singleton, account_id, suspended) VALUES (1, ?, ?) + `INSERT INTO owner_account (singleton, account_id, suspended, confirmed) VALUES (1, ?, ?, 1) ON CONFLICT (singleton) DO UPDATE - SET account_id = excluded.account_id, suspended = excluded.suspended`, + SET account_id = excluded.account_id, suspended = excluded.suspended, + confirmed = 1`, accountId, suspended ? 1 : 0 ) @@ -1141,24 +1221,39 @@ export class BuddyInbox extends DurableObject { } /** - * Verifies the signature, then checks the signer's budget of requests that - * take effect (`#commit` spends it). Both are about the authenticated - * signer, so nobody else can use up an inbox's budget. + * Verifies the signature (or, for an account call, that the inbox is + * linked to that account), then checks the signer's budget of requests + * that take effect (`#commit` spends it). Both are about the authenticated + * signer, so nobody else can use up an inbox's budget. An owner's budget is + * shared by both ways of authenticating. + * + * `linkedOnly` (a signed call admitted after the legacy switch went off): + * once the signature holds, an inbox without a confirmed account link is + * `upgrade_required`. Nothing un-confirms a link (a wipe drops the inbox + * too), so the check holds through `#commit`. */ async #authenticate( op: BuddiesSigningOp, - call: Signed, - ref: KeyRef + call: AuthCall, + ref: KeyRef, + { linkedOnly = false }: { linkedOnly?: boolean } = {} ): Promise> { const key = this.#key(call, ref) if (!key.ok) return key - const valid = await verifyBuddiesSignature( - key.value, - op, - call.payloadBytes, - call.signature - ) - if (!valid) return fail('bad_signature') + if (isAccountCall(call)) { + const refused = this.#refuseAccount(call.accountId) + if (refused) return refused + } else { + const valid = await verifyBuddiesSignature( + key.value, + op, + call.payloadBytes, + call.signature + ) + if (!valid) return fail('bad_signature') + if (linkedOnly && this.#confirmedAccountId() == null) + return fail('upgrade_required') + } if (ref === 'owner' && !SUSPENDED_ALLOWED_OPS.has(op) && this.#suspended()) return fail('suspended') const [budget, limit] = @@ -1179,18 +1274,20 @@ export class BuddyInbox extends DurableObject { /** * After the signature check: the verifying key must be unchanged, `ts` - * fresh, and the nonce unseen, then `work` runs in the same transaction. - * Owner ops also count as owner activity for the 180-day retention unless - * `activity` is false (the live socket). + * fresh, and the nonce unseen (an account call: the inbox still linked to + * its account), then `work` runs in the same transaction. Owner ops also + * count as owner activity for the 180-day retention unless `activity` is + * false (the live socket). * * Only a request that took effect keeps its nonce (and spends its signer's * budget), so refused ones (caps, rate limits, conflicts) add no rows. * Replay protection is unchanged: the check and the record share this * synchronous transaction, so each signed request still takes effect at - * most once, and only within ±5 minutes. + * most once, and only within ±5 minutes. Account calls carry no nonce and + * record none. */ #commit( - call: KeyedCall & SignedFields, + call: AuthCall, ref: KeyRef, verifiedKey: string, work: (now: number) => BuddiesResult, @@ -1201,18 +1298,32 @@ export class BuddyInbox extends DurableObject { const key = this.#key(call, ref) if (!key.ok) return key if (key.value !== verifiedKey) return fail('bad_signature') - const denied = this.#admit(call, now) + const denied = isAccountCall(call) + ? this.#refuseAccount(call.accountId)?.error + : this.#admit(call, now) if (denied) return fail(denied) if (activity) this.#touch(now) const result = work(now) if (result.ok) { - this.#recordNonce(call, now) + if (!isAccountCall(call)) this.#recordNonce(call, now) this.#signers.spend(signerOf(call, ref), now) } return result }) } + /** + * Whether an account call may act for this inbox: only the account it's + * confirmed to be linked to. An inbox registered before accounts, or whose + * link the accounts database hasn't confirmed, has none to compare + * (`not_linked`); the app links it with `inbox/register` and retries. + */ + #refuseAccount(accountId: string): BuddiesFailure | null { + const linked = this.#confirmedAccountId() + if (linked == null) return fail('not_linked') + return linked === accountId ? null : fail('forbidden') + } + #admit(call: SignedFields, now: number): BuddiesErrorCode | null { if (this.#isStale(call, now)) return 'stale' return this.#nonceSeen(call, now) ? 'replay' : null @@ -1281,10 +1392,24 @@ export class BuddyInbox extends DurableObject { ) } if (this.#hasTable('device')) this.#migrateDevices() + if (this.#hasTable('owner_account')) this.#ensureOwnerAccount() } return exists } + /** + * `owner_account`, with `confirmed` added in place to a table from before + * it (as 0: those links were never checked against the accounts database). + */ + #ensureOwnerAccount(): void { + this.#sql.exec(OWNER_ACCOUNT_SCHEMA) + if (!this.#hasColumn('owner_account', 'confirmed')) { + this.#sql.exec( + 'ALTER TABLE owner_account ADD COLUMN confirmed INTEGER NOT NULL DEFAULT 0' + ) + } + } + /** * Inboxes from before FCM kept APNs devices in `device`; they move to * `push_device` once, in the same synchronous turn, so atomically. @@ -1341,6 +1466,20 @@ export class BuddyInbox extends DurableObject { : null } + /** The account this inbox is confirmed to be linked to, or null. */ + #confirmedAccountId(): string | null { + if (!this.#hasTable('owner_account')) return null + return ( + this.#sql + .exec<{ + accountId: string + }>( + 'SELECT account_id AS accountId FROM owner_account WHERE singleton = 1 AND confirmed = 1' + ) + .toArray()[0]?.accountId ?? null + ) + } + /** Whether this inbox's account is suspended. */ #suspended(): boolean { if (!this.#hasTable('owner_account')) return false diff --git a/src/buddies/limits.ts b/src/buddies/limits.ts index 625a9d6..2d399a4 100644 --- a/src/buddies/limits.ts +++ b/src/buddies/limits.ts @@ -1,4 +1,5 @@ import type { Environment } from '../types' +import { bytesToBase64Url, sha256Bytes } from '../crypto' import type { BuddiesRouteOp } from './contracts' /** @@ -18,10 +19,13 @@ const MIB = 1_024 * 1_024 export const BUDDIES_ABUSE_LIMITS = { /** - * Requests per minute per caller (client IP, IPv6 by /64), checked in the - * Worker before any Durable Object wakes. Enforced by the `[[ratelimits]]` - * bindings in `wrangler.toml` (repeated under `[env.dev]`), which - * `limits.test.ts` keeps equal to these numbers. + * Requests per minute per caller, checked in the Worker before any Durable + * Object wakes. The caller is the session's account for a bearer request + * (`buddiesAccountCallerKey`), else the client IP, IPv6 by /64 + * (`buddiesCallerKey`). Enforced by the `[[ratelimits]]` bindings in + * `wrangler.toml` (repeated under `[env.dev]`), which `limits.test.ts` + * keeps equal to these numbers. The budgets were sized per IP, so a whole + * household; one account gets the same, which is only more generous. */ edge: { /** Invite fetch + claim: ~3 calls per acceptance, ~4 people accepting at once on one Wi-Fi ≈ 12/min → 10×. */ @@ -37,7 +41,7 @@ export const BUDDIES_ABUSE_LIMITS = { /** Photo downloads: each buddy device fetches each photo once; a household opening a few shared Plans ≈ 60/min → 10×. */ blobGet: { binding: 'BUDDIES_BLOB_GET_LIMITER', perMinute: 600 }, }, - /** Signed registrations per caller per rolling day: ~200 people starting Buddies on one assembly-hall Wi-Fi → 10×. */ + /** Signed registrations per caller (IP or account, as above) per rolling day: ~200 people starting Buddies on one assembly-hall Wi-Fi → 10×. */ registrationsPerDay: 2_000, registrationWindowMs: DAY_MS, /** Stored events per slot (kept 30 days): a power user sharing every Plan from 3 devices sends ≤ ~900 a month → 11×. */ @@ -147,6 +151,21 @@ export const buddiesCallerKey = (ip: string | null | undefined): string => { return `${prefix.join(':')}::/64` } +const encoder = new TextEncoder() + +/** + * The key a bearer request's per-caller limits count against: a hash of the + * session's account, so people behind one NAT stop sharing a budget and + * neither the limiter nor a quota's name ever holds an account id. The + * `account:` prefix keeps it apart from every IP key. + */ +export const buddiesAccountCallerKey = async ( + accountId: string +): Promise => + `account:${bytesToBase64Url( + await sha256Bytes(encoder.encode(`ww-buddies/v1/caller-account\n${accountId}`)) + )}` + /** Logs a limit hit for tuning: the op and the limit's name, nothing else. */ export const logLimitHit = (op: string, limit: string): void => { console.warn('buddies: limit hit', { op, limit }) diff --git a/src/buddies/openapi.ts b/src/buddies/openapi.ts index afd578c..ab5bd97 100644 --- a/src/buddies/openapi.ts +++ b/src/buddies/openapi.ts @@ -5,6 +5,7 @@ import { apiErrorSchema, staleErrorSchema, b64u, + signedEnvelopeSchema, unsignedEnvelopeSchema, buddiesId, errorResponse, @@ -14,9 +15,11 @@ import { signedBody, } from '../openapi/schemas' import { + BUDDIES_CLIENT_HEADER, BUDDIES_ERROR_STATUS, BUDDIES_LIMITS, BUDDIES_LIVE_CLOSE, + BUDDIES_NOTIFICATION_SERVICE_CLIENT, type BuddiesErrorCode, type BuddiesOp, } from './contracts' @@ -25,13 +28,23 @@ import { BUDDIES_ABUSE_LIMITS, type BuddiesEdgeTier } from './limits' /** * OpenAPI descriptions for the Buddies relay (`/buddies/v1`). The wire * contract is docs/buddies-protocol.md; the payload schemas below are for - * readers only and mirror `BUDDIES_PAYLOAD_PARSERS` in contracts.ts, which + * readers only and mirror `BUDDIES_FIELD_PARSERS` in contracts.ts, which * stays the authority. Update both together. + * + * Every route documents both ways to authenticate (witness-work ADR 0027): + * a bearer request (`Authorization: Bearer`, first) and the legacy form + * shipped app builds send while the legacy switch is on. */ type Responses = NonNullable const TAGS = ['Buddies'] +/** A bearer token, or (while the legacy switch is on) none: the legacy form. */ +const BEARER_OR_LEGACY: Record[] = [{ bearerAuth: [] }, {}] + +/** `describeRoute` with the Buddies tag and both ways to authenticate. */ +const describeOp = (options: DescribeRouteOptions) => + describeRoute({ tags: TAGS, security: BEARER_OR_LEGACY, ...options }) const KIB = 1_024 const kib = (bytes: number) => `${bytes / KIB} KiB` const count = (n: number) => n.toLocaleString('en-US') @@ -62,6 +75,32 @@ const sealed = (what: string, maxBytes: number) => const signedPayload = (fields: z.ZodRawShape) => z.object({ ...fields, ts, nonce }) +/** An owner op's fields, with the legacy envelope's `ts` and `nonce` marked as such. */ +const ownerFields = (fields: z.ZodRawShape) => + z.object({ + ...fields, + ts: ts.optional().meta({ + description: `Legacy envelope only, and required there. ${ts.description}`, + }), + nonce: nonce.optional().meta({ + description: `Legacy envelope only, and required there. ${nonce.description}`, + }), + }) + +/** + * An owner op's body: the fields as plain JSON (bearer), or the + * owner-signed envelope whose decoded `payload` adds `ts` and `nonce` + * (legacy). + */ +const ownerBody = (op: string, fields: z.ZodRawShape) => + jsonBody( + z.union([ + z.object(fields).meta({ title: 'Bearer' }), + signedEnvelopeSchema.meta({ title: 'Legacy envelope' }), + ]), + `**Bearer:** the fields as plain JSON, without \`ts\` or \`nonce\`; an envelope is \`bad_request\`.\n\n**Legacy:** the envelope, owner-signed (op \`${op}\`); its decoded \`payload\` is the fields plus \`ts\` and \`nonce\`.\n\n**Fields:**\n\n${fieldTable(ownerFields(fields))}` + ) + const okSchema = z.object({ ok: z.literal(true) }) const seqSchema = z.object({ ok: z.literal(true), @@ -72,6 +111,8 @@ const seqSchema = z.object({ // --- Descriptions --------------------------------------------------------- +const paragraphs = (...lines: string[]) => lines.join('\n\n') + const TIER_NAMES: Record = { unsigned: 'unsigned invite', register: 'registration', @@ -86,7 +127,7 @@ const perMinute = (tier: BuddiesEdgeTier) => /** The per-caller tier sentence every op's description carries. */ const tierLine = (tier: BuddiesEdgeTier) => - `**Rate limit:** the ${TIER_NAMES[tier]} tier, ${perMinute(tier)} per caller (client IP, IPv6 by /64), checked before any Durable Object wakes.` + `**Rate limit:** the ${TIER_NAMES[tier]} tier, ${perMinute(tier)} per caller (the session’s account for a bearer request, else the client IP, IPv6 by /64), checked before any Durable Object wakes.` const OWNER_BUDGET = `the owner key's ${count(BUDDIES_ABUSE_LIMITS.ownerRequests)} requests that take effect per 10 minutes` const WRITER_BUDGET = `the slot's ${count(BUDDIES_ABUSE_LIMITS.writerRequests)} requests that take effect per 10 minutes` @@ -98,18 +139,30 @@ const KILL_SWITCH_EXEMPT = const PHOTOS_SWITCH = '**Photos switch:** also needs photo blobs on (KV `buddies:photos`, else `BUDDIES_PHOTOS`); off, it answers `photos_disabled`. Buddies’ own switch wins.' +const LEGACY_SWITCH = + 'It works while the legacy switch (KV `auth:legacy`) is on; once it’s off, a legacy request is 426 `upgrade_required`. A request with an `Authorization` header is always a bearer request and never falls back.' + const SIGNED_BY = { - owner: - '**Signed by** the inbox owner key (the `ownerPub` stored for `inboxId`).', - writer: - '**Signed by** the writer key registered for (`inboxId`, `slotId`) with `slot/add`.', - self: '**Self-signed** by `ownerPub` in the payload (proof of possession).', - unsigned: + owner: paragraphs( + '**Bearer:** `Authorization: Bearer ` and the fields as plain JSON, without `ts` or `nonce`. The inbox must be linked to the token’s account: another account’s inbox is `forbidden`, and one with no confirmed link (registered before accounts, or its linking registration failed part-way) is `not_linked` (link it with a bearer `inbox/register`, then retry).', + `**Legacy** (no \`Authorization\` header; app versions before sessions): the envelope, signed by the inbox owner key (the \`ownerPub\` stored for \`inboxId\`). ${LEGACY_SWITCH}` + ), + writer: paragraphs( + '**Signed by** the writer key registered for (`inboxId`, `slotId`) with `slot/add`: the signature is the slot’s capability, in both forms.', + '**Bearer:** `Authorization: Bearer ` plus the writer-signed envelope. The relay checks the token’s session but never compares its account with the slot or stores it, so it can’t tell which account writes into which inbox.', + `**Legacy** (no \`Authorization\` header): the envelope alone. ${LEGACY_SWITCH}` + ), + self: paragraphs( + '**Self-signed** by `ownerPub` in the payload (proof of possession), in both forms.', + '**Bearer:** `Authorization: Bearer ` plus the envelope, whose `account` proof is required and must name the token’s account (`forbidden` otherwise), so the inbox is linked to it.', + `**Legacy** (no \`Authorization\` header): the envelope alone; \`account\` is optional. ${LEGACY_SWITCH}` + ), + unsigned: paragraphs( '**Unsigned:** send `{ "payload": … }` only, no `signature`, `ts`, or `nonce`. Knowing the ids and secrets is the capability.', + `**Bearer:** \`Authorization: Bearer \` plus the same body. **Legacy** (no \`Authorization\` header): the body alone. ${LEGACY_SWITCH}` + ), } as const -const paragraphs = (...lines: string[]) => lines.join('\n\n') - // --- Errors --------------------------------------------------------------- const DEFAULT_REASONS: Partial> = { @@ -121,7 +174,15 @@ const DEFAULT_REASONS: Partial> = { 'Unknown inbox: never registered, deleted, or wiped after 180 days without an owner op.', suspended: 'The inbox’s account is suspended (only `inbox/delete` and `slot/remove` still work).', - disabled: 'The Buddies kill switch is off. `Retry-After: 60`.', + disabled: + 'The Buddies kill switch is off, or (bearer) this worker has no account sessions. `Retry-After: 60`.', + unauthorized: + 'A bearer request’s access token is missing, malformed, expired, or invalid (`WWW-Authenticate: Bearer error="invalid_token"`). Refresh it once and retry; it never falls back to the legacy form.', + upgrade_required: + 'A legacy request (no `Authorization` header) after the legacy switch (KV `auth:legacy`) was turned off. Only updating the app helps; no `Retry-After`.', + forbidden: 'Bearer: the inbox is linked to another account than the token’s.', + not_linked: + 'Bearer: the inbox has no confirmed link to an account: it was registered before accounts, or the registration that linked it failed part-way. Link it with a bearer `inbox/register` (with its `account` proof), then retry; registering again is idempotent.', photos_disabled: 'Photo blobs are off (KV `buddies:photos`), or this worker has no blob bucket. `Retry-After: 60`.', } @@ -141,6 +202,13 @@ const STALE_HEADERS = { }, } +const BEARER_CHALLENGE_HEADERS = { + 'WWW-Authenticate': { + description: '`Bearer error="invalid_token"` (on `unauthorized`).', + schema: { type: 'string' as const }, + }, +} + const STATUS_SUMMARY: Record = { 400: 'Bad request.', 401: 'Not authenticated.', @@ -149,7 +217,7 @@ const STATUS_SUMMARY: Record = { 409: 'Conflict.', 410: 'Gone.', 413: 'Body too large.', - 426: 'Not a WebSocket upgrade.', + 426: 'Upgrade required.', 429: 'Too many requests, or a cap was hit.', 503: 'Switched off.', } @@ -180,12 +248,14 @@ const refusals = ( codes, hasStale ? staleErrorSchema : apiErrorSchema ) - responses[status] = - status === 429 || status === 503 - ? { ...response, headers: RETRY_AFTER_HEADER } - : hasStale - ? { ...response, headers: STALE_HEADERS } - : response + const headers = { + ...(status === 429 || status === 503 ? RETRY_AFTER_HEADER : {}), + ...(hasStale ? STALE_HEADERS : {}), + ...(codes.includes('unauthorized') ? BEARER_CHALLENGE_HEADERS : {}), + } + responses[status] = Object.keys(headers).length + ? { ...response, headers } + : response } responses[500] = errorResponse( 'Unexpected failure. The body never names the request.', @@ -197,17 +267,27 @@ const refusals = ( const edgeLimited = (tier: BuddiesEdgeTier) => `More than ${perMinute(tier)} from this caller in the ${TIER_NAMES[tier]} tier (\`Retry-After: 60\`)` +/** The refusals every route shares, for its two ways to authenticate. */ +const AUTH_REFUSALS = { + unauthorized: true as const, + upgrade_required: true as const, +} + /** The refusals every owner op shares, before op-specific ones. */ const ownerRefusals = ( tier: BuddiesEdgeTier, { killSwitch = true, suspendable = true } = {} ) => ({ bad_request: true as const, - bad_signature: 'Signature missing, malformed, or not by the owner key.', - stale: true as const, + bad_signature: + 'Legacy: signature missing, malformed, or not by the owner key.', + stale: `Legacy only. ${DEFAULT_REASONS.stale}`, + ...AUTH_REFUSALS, + forbidden: true as const, ...(suspendable ? { suspended: true as const } : {}), not_found: true as const, - replay: true as const, + replay: `Legacy only. ${DEFAULT_REASONS.replay}`, + not_linked: true as const, rate_limited: `${edgeLimited(tier)}, or ${OWNER_BUDGET} are spent (\`Retry-After\` until the window ends).`, ...(killSwitch ? { disabled: true as const } : {}), }) @@ -218,6 +298,7 @@ const writerRefusals = (extraRateLimit = '') => ({ bad_signature: 'Signature missing, malformed, or not by the slot’s writer key.', stale: true as const, + ...AUTH_REFUSALS, not_found: true as const, gone: 'The slot doesn’t exist: the inbox owner removed you (or never added you).', replay: true as const, @@ -285,8 +366,7 @@ const syncSchema = z.object({ }) export const buddiesOpDocs: Record = { - 'inbox/register': describeRoute({ - tags: TAGS, + 'inbox/register': describeOp({ summary: 'Register an inbox', description: paragraphs( 'Creates the inbox, or refreshes it. Idempotent for the same `ownerPub`; a different key is `conflict`, even after the inbox was deleted or wiped (the relay keeps a hash of the owner key forever).', @@ -294,7 +374,7 @@ export const buddiesOpDocs: Record = { 'With `account`, the inbox is linked to that anonymous account (written only after the inbox accepted the registration, so the link takes both keys). The answer carries `accountId` once the inbox belongs to an account. Without the accounts database the proof is ignored.', `${tierLine('register')} Validly signed registrations also count toward ${count(BUDDIES_ABUSE_LIMITS.registrationsPerDay)} per caller per rolling day.`, KILL_SWITCH_OFF, - 'App Attest is not required yet: a reserved `attest` field is accepted and ignored.' + 'Bearer requests come from an attested session (attestation happens once, when the session opens); legacy requests aren’t attested. A reserved `attest` field is accepted and ignored.' ), requestBody: signedBody( signedPayload({ @@ -310,7 +390,7 @@ export const buddiesOpDocs: Record = { .optional() .meta({ description: - 'Links the inbox to an account. Absent from builds before accounts.', + 'Links the inbox to an account. Required in a bearer request, for the token’s account; optional in a legacy one (absent from builds before accounts).', }), }), 'Self-signed: `signature` verifies against the payload’s own `ownerPub`.' @@ -326,10 +406,13 @@ export const buddiesOpDocs: Record = { }) ), ...refusals({ - bad_request: true, + bad_request: `${DEFAULT_REASONS.bad_request} Also a bearer request without \`account\`.`, bad_signature: 'Signature not by `ownerPub`, or the `account` signature is not by that account.', stale: true, + ...AUTH_REFUSALS, + forbidden: + 'Bearer: `account` names another account than the token’s.', suspended: 'The inbox, or the account named in `account`, is suspended.', unknown_account: '`account.accountId` doesn’t exist.', @@ -337,38 +420,49 @@ export const buddiesOpDocs: Record = { conflict: 'The inbox is registered (or was, before a wipe) with a different `ownerPub`, or is linked to a different account.', 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: true, + disabled: `${DEFAULT_REASONS.disabled} Also a bearer request on a worker without the accounts database.`, }), }, }), - 'inbox/sync': describeRoute({ - tags: TAGS, + 'inbox/sync': describeOp({ summary: 'Sync the inbox', description: paragraphs( 'Returns everything with `seq > since` (cards, events, the roster) plus the complete slot list, and which optional relay features are on. Counts as owner activity for the 180-day retention.', SIGNED_BY.owner, + `**Notification Service Extension** (iOS): it fetches events too big for a push and can’t hold the session’s rotating refresh token, so it keeps sending the legacy owner-signed form with \`${BUDDIES_CLIENT_HEADER}: ${BUDDIES_NOTIFICATION_SERVICE_CLIENT}\`. That request is still served after the legacy switch is off, for an inbox with a confirmed account link (its owner registered it on a session): the signature, \`ts\`, nonce, and suspension are checked as ever. An inbox without one gets 426 \`upgrade_required\`, as does every other op with the header. While the switch is on, the header changes nothing.`, tierLine('read'), KILL_SWITCH_OFF ), - requestBody: signedBody( - signedPayload({ + parameters: [ + { + name: BUDDIES_CLIENT_HEADER, + in: 'header' as const, + required: false, + schema: { + type: 'string' as const, + enum: [BUDDIES_NOTIFICATION_SERVICE_CLIENT], + }, + description: `Only the iOS Notification Service Extension sends it, on its legacy owner-signed sync: \`${BUDDIES_NOTIFICATION_SERVICE_CLIENT}\`. Ignored on bearer requests and while the legacy switch is on.`, + }, + ], + requestBody: ownerBody('inbox/sync', { inboxId, since: z.number().int().min(0).meta({ description: 'The last `seq` this device has applied; `0` for everything.', }), }), - 'Owner-signed (op `inbox/sync`).' - ), responses: { 200: jsonResponse('What changed since `since`.', syncSchema), - ...refusals(ownerRefusals('read')), + ...refusals({ + ...ownerRefusals('read'), + upgrade_required: `${DEFAULT_REASONS.upgrade_required} Except the Notification Service Extension’s sync (\`${BUDDIES_CLIENT_HEADER}: ${BUDDIES_NOTIFICATION_SERVICE_CLIENT}\`) for an inbox with a confirmed account link; without one, it’s refused too.`, + }), }, }), - 'inbox/delete': describeRoute({ - tags: TAGS, + 'inbox/delete': describeOp({ summary: 'Delete the inbox', 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.', @@ -376,10 +470,7 @@ export const buddiesOpDocs: Record = { `${tierLine('write')} Works for a suspended account.`, KILL_SWITCH_EXEMPT ), - requestBody: signedBody( - signedPayload({ inboxId }), - 'Owner-signed (op `inbox/delete`).' - ), + requestBody: ownerBody('inbox/delete', { inboxId }), responses: { 200: jsonResponse('Deleted.', okSchema), ...refusals( @@ -388,8 +479,7 @@ export const buddiesOpDocs: Record = { }, }), - 'device/register': describeRoute({ - tags: TAGS, + 'device/register': describeOp({ summary: 'Register a device for pushes', description: paragraphs( `Upserts a push device by \`deviceId\`. At most ${BUDDIES_LIMITS.devices} per inbox; past that the least recently registered is evicted. One device per token: a token another \`deviceId\` holds moves here. Apps re-register about daily, which bumps eviction order.`, @@ -398,8 +488,7 @@ export const buddiesOpDocs: Record = { tierLine('write'), KILL_SWITCH_OFF ), - requestBody: signedBody( - signedPayload({ + requestBody: ownerBody('device/register', { inboxId, deviceId: buddiesId('The device'), pushService: z.enum(['apns', 'fcm']).optional().meta({ @@ -437,8 +526,6 @@ export const buddiesOpDocs: Record = { description: `\`{ [kind]: { title, body } }\`, already localized: at most ${BUDDIES_LIMITS.templates} kinds (\`^[a-z][a-z0-9.]*$\`, ≤ ${BUDDIES_LIMITS.kindChars} chars), strings ≤ ${BUDDIES_LIMITS.templateChars} characters. A kind without a template gets no push on this device.`, }), }), - 'Owner-signed (op `device/register`).' - ), responses: { 200: jsonResponse('Registered.', okSchema), ...refusals({ @@ -448,8 +535,7 @@ export const buddiesOpDocs: Record = { }, }), - 'device/unregister': describeRoute({ - tags: TAGS, + 'device/unregister': describeOp({ summary: 'Unregister a device', description: paragraphs( 'Removes a push device. Idempotent.', @@ -457,18 +543,14 @@ export const buddiesOpDocs: Record = { tierLine('write'), KILL_SWITCH_OFF ), - requestBody: signedBody( - signedPayload({ inboxId, deviceId: buddiesId('The device') }), - 'Owner-signed (op `device/unregister`).' - ), + requestBody: ownerBody('device/unregister', { inboxId, deviceId: buddiesId('The device') }), responses: { 200: jsonResponse('Unregistered.', okSchema), ...refusals(ownerRefusals('write')), }, }), - 'slot/add': describeRoute({ - tags: TAGS, + 'slot/add': describeOp({ summary: 'Add a buddy’s writer slot', description: paragraphs( `Registers a buddy’s writer key in this inbox. Idempotent for the same key. At most ${BUDDIES_LIMITS.slotsPlusOpenInvites} slots plus open invites. Signals live sockets.`, @@ -476,14 +558,11 @@ export const buddiesOpDocs: Record = { tierLine('write'), KILL_SWITCH_OFF ), - requestBody: signedBody( - signedPayload({ + requestBody: ownerBody('slot/add', { inboxId, slotId: buddiesId('The new slot'), writerPub: bytes32('The buddy’s Ed25519 writer public key'), }), - 'Owner-signed (op `slot/add`).' - ), responses: { 200: jsonResponse('Added.', okSchema), ...refusals({ @@ -494,8 +573,7 @@ export const buddiesOpDocs: Record = { }, }), - 'slot/remove': describeRoute({ - tags: TAGS, + 'slot/remove': describeOp({ summary: 'Remove a buddy’s slot', description: paragraphs( 'Deletes the slot, its card, and its events. Idempotent. Signals live sockets when a slot was removed.', @@ -503,10 +581,7 @@ export const buddiesOpDocs: Record = { `${tierLine('write')} Works for a suspended account.`, KILL_SWITCH_EXEMPT ), - requestBody: signedBody( - signedPayload({ inboxId, slotId: buddiesId('The slot to remove') }), - 'Owner-signed (op `slot/remove`).' - ), + requestBody: ownerBody('slot/remove', { inboxId, slotId: buddiesId('The slot to remove') }), responses: { 200: jsonResponse('Removed (or already gone).', okSchema), ...refusals( @@ -515,8 +590,7 @@ export const buddiesOpDocs: Record = { }, }), - 'roster/put': describeRoute({ - tags: TAGS, + 'roster/put': describeOp({ summary: 'Replace the roster', description: paragraphs( 'Replaces the encrypted roster (last writer wins; clients merge first). Takes the next `seq` and signals live sockets.', @@ -524,31 +598,26 @@ export const buddiesOpDocs: Record = { tierLine('write'), KILL_SWITCH_OFF ), - requestBody: signedBody( - signedPayload({ + requestBody: ownerBody('roster/put', { inboxId, blob: sealed('The roster', BUDDIES_LIMITS.rosterBytes), }), - 'Owner-signed (op `roster/put`).' - ), responses: { 200: jsonResponse('Stored.', seqSchema), ...refusals(ownerRefusals('write')), }, }), - 'invite/create': describeRoute({ - tags: TAGS, + 'invite/create': describeOp({ summary: 'Create an invite', description: paragraphs( `Uploads a sealed invite card under \`inviteId\` for a buddy to fetch and claim. Per inbox: at most ${BUDDIES_LIMITS.openInvites} open invites and ${BUDDIES_LIMITS.slotsPlusOpenInvites} slots plus open invites (\`limit\`), and ${BUDDIES_LIMITS.inviteCreations} creations per rolling 24 h (\`rate_limited\`).`, SIGNED_BY.owner, tierLine('write'), KILL_SWITCH_OFF, - 'App Attest is not required yet: a reserved `attest` field is accepted and ignored.' + 'Bearer requests come from an attested session (attestation happens once, when the session opens); legacy requests aren’t attested. A reserved `attest` field is accepted and ignored.' ), - requestBody: signedBody( - signedPayload({ + requestBody: ownerBody('invite/create', { inboxId, inviteId: buddiesId('The new invite, derived from the link secret'), claimVerifier: bytes32('`b64u(SHA-256(claimSecret))`'), @@ -558,8 +627,6 @@ export const buddiesOpDocs: Record = { 'Epoch ms, after now and at most 7 days + 5 minutes ahead of the relay clock.', }), }), - 'Owner-signed (op `invite/create`).' - ), responses: { 200: jsonResponse('Created.', okSchema), ...refusals({ @@ -573,8 +640,7 @@ export const buddiesOpDocs: Record = { }, }), - 'invite/delete': describeRoute({ - tags: TAGS, + 'invite/delete': describeOp({ summary: 'Delete an invite', description: paragraphs( 'Deletes an invite this inbox created (cancel, confirm, or reject). Idempotent; an invite another inbox created is left alone.', @@ -582,18 +648,14 @@ export const buddiesOpDocs: Record = { tierLine('write'), KILL_SWITCH_OFF ), - requestBody: signedBody( - signedPayload({ inboxId, inviteId: buddiesId('The invite') }), - 'Owner-signed (op `invite/delete`).' - ), + requestBody: ownerBody('invite/delete', { inboxId, inviteId: buddiesId('The invite') }), responses: { 200: jsonResponse('Deleted (or already gone).', okSchema), ...refusals(ownerRefusals('write')), }, }), - 'blob/delete': describeRoute({ - tags: TAGS, + 'blob/delete': describeOp({ summary: 'Delete photo blobs', description: paragraphs( 'Deletes photo blobs from the sender’s own inbox. Reads stop at once; the stored objects go right after (retried from the inbox alarm if storage fails). Idempotent: unknown ids succeed.', @@ -602,8 +664,7 @@ export const buddiesOpDocs: Record = { KILL_SWITCH_OFF, PHOTOS_SWITCH ), - requestBody: signedBody( - signedPayload({ + requestBody: ownerBody('blob/delete', { inboxId, blobIds: z .array(bytes32('A blob id')) @@ -613,16 +674,13 @@ export const buddiesOpDocs: Record = { description: `1 to ${BUDDIES_LIMITS.blobDeleteIds} canonical blob ids; duplicates are fine.`, }), }), - 'Owner-signed (op `blob/delete`).' - ), responses: { 200: jsonResponse('Deleted.', okSchema), ...refusals({ ...ownerRefusals('write'), photos_disabled: true }), }, }), - 'card/put': describeRoute({ - tags: TAGS, + 'card/put': describeOp({ summary: 'Replace your Buddy Card', description: paragraphs( `Replaces the writer slot’s card in the recipient’s inbox. Takes the next \`seq\` and signals live sockets. Counts toward the slot’s ${BUDDIES_LIMITS.writesPerSlot} writes per hour (with \`event/put\`).`, @@ -648,8 +706,7 @@ export const buddiesOpDocs: Record = { }, }), - 'event/put': describeRoute({ - tags: TAGS, + 'event/put': describeOp({ summary: 'Append an event', description: paragraphs( 'Appends a sealed event to the recipient’s inbox, deduplicated on `eventId`: a repeat returns the original `seq` and never pushes. Takes the next `seq` and signals live sockets. Events are deleted 30 days after creation.', @@ -686,8 +743,7 @@ export const buddiesOpDocs: Record = { }, }), - 'slot/leave': describeRoute({ - tags: TAGS, + 'slot/leave': describeOp({ summary: 'Leave a buddy’s inbox', description: paragraphs( 'The writer removes its own slot (with its card and events) from the recipient’s inbox, when ending a connection. Idempotent: an unknown inbox or slot succeeds, since there is nothing left to leave.', @@ -709,14 +765,14 @@ export const buddiesOpDocs: Record = { bad_signature: 'Signature missing, malformed, or not by the slot’s writer key.', stale: true, + ...AUTH_REFUSALS, replay: true, rate_limited: `${edgeLimited('write')}.`, }), }, }), - 'invite/fetch': describeRoute({ - tags: TAGS, + 'invite/fetch': describeOp({ summary: 'Fetch an invite', description: paragraphs( 'Reads an invite’s sealed card and status. Expired, deleted, and burned invites are `not_found`.', @@ -742,6 +798,7 @@ export const buddiesOpDocs: Record = { ), ...refusals({ bad_request: true, + ...AUTH_REFUSALS, not_found: 'Unknown, expired, deleted, or burned invite.', rate_limited: `${edgeLimited('unsigned')}.`, disabled: true, @@ -749,8 +806,7 @@ export const buddiesOpDocs: Record = { }, }), - 'invite/claim': describeRoute({ - tags: TAGS, + 'invite/claim': describeOp({ summary: 'Claim an invite', description: paragraphs( 'Claims an invite with its secret and a sealed claim card. The relay compares `SHA-256(claimSecret)` to the stored verifier in constant time. The first valid claim wins; the winner’s exact retry (same secret, byte-identical `blob`) gets `{ ok }` again without a second event or push. On success the relay appends `{ slotId: "", kind: "invite.claimed", eventId: inviteId, blob }` to the creator’s inbox and pushes it.', @@ -758,7 +814,7 @@ export const buddiesOpDocs: Record = { SIGNED_BY.unsigned, tierLine('unsigned'), KILL_SWITCH_OFF, - 'App Attest is not required yet.' + 'Bearer requests come from an attested session; legacy requests aren’t attested.' ), requestBody: jsonBody( unsignedEnvelopeSchema, @@ -775,6 +831,7 @@ export const buddiesOpDocs: Record = { ...refusals({ bad_request: true, bad_signature: `Wrong \`claimSecret\`. The ${BUDDIES_LIMITS.badClaims}th wrong one burns the invite.`, + ...AUTH_REFUSALS, not_found: 'Unknown, expired, deleted, or burned invite, or the creator cancelled it while the claim was delivered.', conflict: @@ -788,30 +845,35 @@ export const buddiesOpDocs: Record = { // --- Header-envelope routes ----------------------------------------------- -const envelopeHeaders = (op: string, payload: z.ZodType) => [ +/** + * The request's fields in `x-buddies-payload`, and (legacy only) the owner's + * signature over them in `x-buddies-signature`. + */ +const fieldHeaders = (op: string, fields: z.ZodRawShape) => [ { name: 'x-buddies-payload', in: 'header' as const, required: true, schema: { type: 'string' as const }, - description: `The envelope’s \`payload\`: the request’s fields as UTF-8 JSON, base64url without padding.\n\n**Decoded:**\n\n${fieldTable(payload)}`, + description: `The request’s fields as UTF-8 JSON, base64url without padding. A legacy request’s are its envelope \`payload\`, which adds \`ts\` and \`nonce\`.\n\n**Decoded:**\n\n${fieldTable(ownerFields(fields))}`, }, { name: 'x-buddies-signature', in: 'header' as const, - required: true, + required: false, schema: { type: 'string' as const }, - description: `The envelope’s \`signature\`: Ed25519 signature by the owner key over \`ww-buddies/v1\\n${op}\\n\` followed by the decoded \`x-buddies-payload\` bytes (base64url, no padding).`, + description: `**Legacy only, and required there** (no \`Authorization\` header): the envelope’s \`signature\`, an Ed25519 signature by the owner key over \`ww-buddies/v1\\n${op}\\n\` followed by the decoded \`x-buddies-payload\` bytes (base64url, no padding). A bearer request sends none (\`bad_request\`).`, }, ] const { gone, replaced, suspended } = BUDDIES_LIVE_CLOSE -export const buddiesLiveDocs = describeRoute({ - tags: TAGS, +export const buddiesLiveDocs = describeOp({ summary: 'Live socket (WebSocket)', description: paragraphs( - 'Upgrades to a WebSocket that says *that* the owner’s inbox changed, so the app syncs within a second while in the foreground. It never carries content. The owner-signed envelope (op `inbox/live`) rides in headers, since an upgrade has no body and ids never go in URLs. Same `stale` and `replay` checks as a POST owner op. Opening a socket is not owner activity for the 180-day retention.', + 'Upgrades to a WebSocket that says *that* the owner’s inbox changed, so the app syncs within a second while in the foreground. It never carries content. The request rides in headers, since an upgrade has no body and ids never go in URLs. Opening a socket is not owner activity for the 180-day retention.', + '**Bearer:** `Authorization: Bearer ` and `x-buddies-payload` (no signature). Same account check as an owner op: `forbidden` for another account’s inbox, `not_linked` for one with no confirmed link.', + `**Legacy** (no \`Authorization\` header): the owner-signed envelope (op \`inbox/live\`) in \`x-buddies-payload\` and \`x-buddies-signature\`, with the same \`stale\` and \`replay\` checks as a POST owner op. ${LEGACY_SWITCH}`, '**Server → app messages:**\n\n- `{"type":"hello","seq":N}` right after the upgrade, `N` being the inbox head.\n- `{"type":"changed","seq":N}` after every committed write that changes what `inbox/sync` returns: new events, cards, the roster, a delivered claim, and slot changes. Slot changes don’t advance `seq`, so sync on every `changed`.', '**Keepalive:** send the text `ping` about every 25 s; the relay answers `pong` without waking the inbox. Anything else the app sends is ignored.', `**Close codes:** \`${gone.code} "${gone.reason}"\` the inbox was deleted or wiped (sync, which restores it); \`${replaced.code} "${replaced.reason}"\` an ${BUDDIES_LIMITS.liveSockets + 1}th socket opened and closed the oldest (at most ${BUDDIES_LIMITS.liveSockets} per inbox); \`${suspended.code} "${suspended.reason}"\` the inbox’s account was suspended. Reconnect with backoff while in the foreground.`, @@ -826,17 +888,16 @@ export const buddiesLiveDocs = describeRoute({ schema: { type: 'string' as const, enum: ['websocket'] }, description: 'Must be `websocket`, else `426 upgrade_required`.', }, - ...envelopeHeaders( - 'inbox/live', - signedPayload({ inboxId: buddiesId('Your inbox') }) - ), + ...fieldHeaders('inbox/live', { inboxId: buddiesId('Your inbox') }), ], responses: { 101: { description: 'Switching protocols: the socket is open.' }, ...refusals({ ...ownerRefusals('read'), - bad_request: 'Missing or malformed `x-buddies-payload`, or a bad payload.', - upgrade_required: 'Not a WebSocket upgrade (`Upgrade: websocket`).', + bad_request: + 'Missing or malformed `x-buddies-payload`, a bad payload, or a bearer upgrade with `x-buddies-signature`.', + upgrade_required: + 'Not a WebSocket upgrade (`Upgrade: websocket`), or a legacy upgrade after the legacy switch (KV `auth:legacy`) was turned off. No `Retry-After`.', }), }, }) @@ -850,11 +911,12 @@ const binary = (description: string) => ({ }, }) -export const buddiesBlobPutDocs = describeRoute({ - tags: TAGS, +export const buddiesBlobPutDocs = describeOp({ summary: 'Upload a photo blob', description: paragraphs( - `Stores one sealed photo in the sender’s own inbox for buddies to fetch with \`blob/get\`. The body is the raw sealed bytes (not base64), 1 to ${count(BUDDIES_LIMITS.blobBytes)}; the owner-signed envelope (op \`blob/put\`) rides in headers. The relay recomputes \`blobId = b64u(SHA-256(body))\`, so the signature covers the bytes too. Only an inbox with at least one buddy (slot) can store one.`, + `Stores one sealed photo in the sender’s own inbox for buddies to fetch with \`blob/get\`. The body is the raw sealed bytes (not base64), 1 to ${count(BUDDIES_LIMITS.blobBytes)}, so the request’s fields ride in headers. The relay recomputes \`blobId = b64u(SHA-256(body))\`, so the fields (and a legacy signature) cover the bytes too. Only an inbox with at least one buddy (slot) can store one.`, + '**Bearer:** `Authorization: Bearer ` and `x-buddies-payload` (no signature). Same account check as an owner op: `forbidden` for another account’s inbox, `not_linked` for one with no confirmed link.', + `**Legacy** (no \`Authorization\` header): the owner-signed envelope (op \`blob/put\`) in \`x-buddies-payload\` and \`x-buddies-signature\`. ${LEGACY_SWITCH}`, 'Idempotent per `blobId`: a repeat keeps the first `readTokenHash` (retries must reuse the token) and moves the expiry to the later of the two; one that doesn’t move it stores nothing and costs no upload.', `**Caps per inbox:** ${BUDDIES_ABUSE_LIMITS.inboxBlobs} live blobs and 150 MiB live; ${BUDDIES_ABUSE_LIMITS.blobUploads} uploads (object writes) per rolling 24 h. Blobs are deleted at \`expiresAt\`, by \`blob/delete\`, or with the inbox.`, 'If storing the object fails the answer is a 500; a retry finishes the upload.', @@ -862,9 +924,7 @@ export const buddiesBlobPutDocs = describeRoute({ KILL_SWITCH_OFF, PHOTOS_SWITCH ), - parameters: envelopeHeaders( - 'blob/put', - signedPayload({ + parameters: fieldHeaders('blob/put', { inboxId: buddiesId('Your (the sender’s) inbox'), blobId: bytes32('`b64u(SHA-256(body))`'), bytes: z @@ -881,8 +941,7 @@ export const buddiesBlobPutDocs = describeRoute({ readTokenHash: bytes32( '`b64u(SHA-256(readToken))`, the 32-byte read capability the app shares with recipients' ), - }) - ), + }), requestBody: { required: true, ...binary( @@ -902,7 +961,7 @@ export const buddiesBlobPutDocs = describeRoute({ ...refusals({ ...ownerRefusals('blobPut'), bad_request: - 'Missing or malformed headers or payload, an `expiresAt` out of range, a body whose length isn’t `bytes`, or whose SHA-256 isn’t `blobId`.', + 'Missing or malformed headers or payload, an `expiresAt` out of range, a body whose length isn’t `bytes`, or whose SHA-256 isn’t `blobId`. Also a bearer upload with `x-buddies-signature`.', not_found: `${DEFAULT_REASONS.not_found} Also when the blob or inbox was deleted while it uploaded.`, no_buddies: 'The inbox has no slots, so nobody could read the photo.', too_large: `\`bytes\`, \`Content-Length\`, or the streamed body is over ${kib(BUDDIES_LIMITS.blobBytes)}.`, @@ -912,8 +971,7 @@ export const buddiesBlobPutDocs = describeRoute({ }, }) -export const buddiesBlobGetDocs = describeRoute({ - tags: TAGS, +export const buddiesBlobGetDocs = describeOp({ summary: 'Download a photo blob', description: paragraphs( 'Returns the exact sealed bytes put, for a recipient holding the reference from a share event. Changes nothing and isn’t owner activity.', @@ -952,6 +1010,7 @@ export const buddiesBlobGetDocs = describeRoute({ }, ...refusals({ bad_request: true, + ...AUTH_REFUSALS, not_found: 'Unknown inbox or blob, a deleted, expired, or unfinished blob, or a wrong token.', rate_limited: `${edgeLimited('blobGet')}.`, diff --git a/src/buddies/registrationQuota.ts b/src/buddies/registrationQuota.ts index 3500ade..8bb6a5c 100644 --- a/src/buddies/registrationQuota.ts +++ b/src/buddies/registrationQuota.ts @@ -18,7 +18,8 @@ const SCHEMA = `CREATE TABLE IF NOT EXISTS registration ( type Bucket = { hour: number; n: number } /** - * One SQLite Durable Object per caller (a hash of the client IP, IPv6 by /64): + * One SQLite Durable Object per caller (a hash of its caller key: the client + * IP, IPv6 by /64, or a bearer request's hashed account; `limits.ts`): * how many validly signed `inbox/register` calls it made in each hour of the * last day, and nothing else. Single-threaded, so the check and the count * can't race. It deletes itself once its newest hour leaves the window. diff --git a/src/buddies/route.ts b/src/buddies/route.ts index 8d151b0..ae30dcc 100644 --- a/src/buddies/route.ts +++ b/src/buddies/route.ts @@ -1,36 +1,49 @@ import { Hono } from 'hono' -import type { AppContext, Environment } from '../types' -import { Sentry } from '../sentry' +import type { AppContext, AppEnv, Environment } from '../types' +import { captureFailure } from '../sentry' import { bytesToBase64Url, sha256Bytes } from '../crypto' import { BUDDIES_ALWAYS_ALLOWED_OPS, BUDDIES_BLOB_GET_OP, BUDDIES_BLOB_PUT_OP, + BUDDIES_CLIENT_HEADER, + BUDDIES_FIELD_PARSERS, BUDDIES_LIMITS, + BUDDIES_LIVE_ACCOUNT_HEADER, BUDDIES_LIVE_OP, + BUDDIES_NOTIFICATION_SERVICE_CLIENT, BUDDIES_OPS, + BUDDIES_OWNER_OPS, BUDDIES_PAYLOAD_PARSERS, BUDDIES_PHOTO_OPS, BUDDIES_UNSIGNED_OPS, buddyBlobKey, fail, + isPlainObject, ok, + type BlobPutPayload, type BuddiesErrorCode, type BuddiesFailure, type BuddiesOp, + type BuddiesOwnerOp, type BuddiesPayloads, type BuddiesResult, + type BuddiesRouteOp, type BuddiesSignedOp, type BuddiesUnsignedOp, + type OwnerCall, type PushJob, type Signed, + type SyncPayload, } from './contracts' import { + isEnvelopeBody, isWebSocketUpgrade, parseEnvelope, readBlobBody, readBlobPutCall, readEnvelopeText, + readHeaderFields, readLiveCall, signedCall, verifyBuddiesSignature, @@ -44,11 +57,22 @@ import { findInboxAccount, linkInbox, } from '../accounts/store' +import { + authenticateAccount, + hasAuthorization, + parseJsonOrNull, + type AccountAuth, +} from '../accounts/auth' +import { + isLegacyAuthEnabled, + upgradeRequiredResponse, +} from '../accounts/legacySwitch' import { isBuddiesEnabled, isBuddiesPhotosEnabled } from './killSwitch' import { buddiesErrorResponse, buddiesInternalError } from './errorResponse' import { EDGE_RETRY_AFTER_SECONDS, allowBuddiesCaller, + buddiesAccountCallerKey, buddiesCallerKey, logLimitHit, } from './limits' @@ -66,15 +90,30 @@ import { * `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). * + * Authentication (witness-work ADR 0027, "Authentication (sessions)" in the + * protocol): a request with an `Authorization` header is a bearer request. + * Its access token must verify (401 `unauthorized` otherwise, never a + * fallback). Owner ops then send plain JSON fields (`inbox/live` and + * `blob/put` in `x-buddies-payload`), authorized by the inbox DO against the + * account the inbox is linked to; `inbox/register` still sends its + * self-signed envelope, whose `account` proof must name the token's account; + * writer ops send their writer-signed envelope (the slot capability, never + * compared with the token's account); invite and blob reads send today's + * body. A request without the header is a legacy request, served as before + * sessions while the legacy switch is on, 426 `upgrade_required` once it's + * off, except the iOS Notification Service Extension's owner-signed + * `inbox/sync` for an inbox with a confirmed account link (`admitCaller`). + * * Privacy: every id travels in the body (or, for the live socket, in headers), - * and nothing here logs or reports the body, headers, payload, or any value - * from them. Errors are `{ "ok": false, "error": "", "code": "" }` - * (src/buddies/errorResponse.ts). + * and nothing here logs or reports the body, headers, payload, token, or any + * value from them. Errors are `{ "ok": false, "error": "", "code": + * "" }` (src/buddies/errorResponse.ts). * * Abuse limits (src/buddies/limits.ts): every request first counts against its - * op tier's per-caller limit, keyed on the client IP (IPv6 by /64), never on - * the target inbox. Malformed, stale, and (for `inbox/register`) badly signed - * requests are refused here, before any Durable Object wakes. + * op tier's per-caller limit, keyed on the session's account for a bearer + * request (hashed), else the client IP (IPv6 by /64), never on the target + * inbox. Malformed, stale, and (for `inbox/register`) badly signed requests + * are refused here, before any Durable Object wakes. */ export interface BuddiesRouteDependencies { @@ -109,10 +148,82 @@ const withSeq = (result: BuddiesResult<{ seq: number }>): Outcome => const photosAvailable = async (env: Environment): Promise => env.BUDDY_BLOBS != null && (await isBuddiesPhotosEnabled(env)) +/** + * Who's calling: the key the per-caller limits count against, and, for a + * bearer request, the session's account. + */ +interface Caller { + key: string + account: AccountAuth | null + /** + * A legacy request admitted while the legacy switch is off: only the + * Notification Service Extension's `inbox/sync`, which the inbox serves + * only with a confirmed account link. + */ + linkedOnly: boolean +} + +type Admission = { ok: true; caller: Caller } | { ok: false; response: Response } + +/** + * Sorts a request into bearer or legacy and counts it against its op's + * per-caller edge tier. A bearer request's token must verify (401 + * `unauthorized`, or 503 `disabled` on a worker without sessions), and it + * counts against its account. A legacy request is refused with 426 + * `upgrade_required` once the legacy switch is off, and otherwise counts + * against the client IP. Suspension isn't checked here: the inbox's copy of + * the account status decides, as before sessions. + * + * One legacy request outlives the switch: `inbox/sync` from the iOS + * Notification Service Extension (`x-buddies-client: notification-service`), + * which fetches events too big for a push and can't hold the session's + * rotating refresh token without racing the app. It's admitted as + * `linkedOnly`: the inbox still checks the owner signature, `ts`, nonce, and + * suspension, and answers 426 unless its account link is confirmed, so it + * serves only inboxes whose owner has moved to sessions. + */ +const admitCaller = async ( + c: AppContext, + op: BuddiesRouteOp +): Promise => { + let caller: Caller + if (hasAuthorization(c)) { + const auth = await authenticateAccount(c) + if (!auth.ok) return auth + const key = await buddiesAccountCallerKey(auth.account.accountId) + caller = { key, account: auth.account, linkedOnly: false } + } else { + const legacy = await isLegacyAuthEnabled(c.env) + if (!legacy && !isNotificationServiceSync(c, op)) + return { ok: false, response: upgradeRequiredResponse() } + caller = { + key: buddiesCallerKey(c.req.header('CF-Connecting-IP')), + account: null, + linkedOnly: !legacy, + } + } + return (await allowBuddiesCaller(c.env, op, caller.key)) + ? { ok: true, caller } + : { ok: false, response: edgeLimited() } +} + +/** The Notification Service Extension's `inbox/sync` (see `admitCaller`). */ +const isNotificationServiceSync = (c: AppContext, op: BuddiesRouteOp) => + op === 'inbox/sync' && + c.req.header(BUDDIES_CLIENT_HEADER) === BUDDIES_NOTIFICATION_SERVICE_CLIENT + +type OwnerRunners = { + [K in BuddiesOwnerOp]: ( + env: Environment, + call: OwnerCall + ) => Promise +} + type SignedRunners = { [K in BuddiesSignedOp]: ( env: Environment, - call: Signed + call: Signed, + account: AccountAuth | null ) => Promise } @@ -149,53 +260,94 @@ const checkInboxAccount = async ( : { ok: true, linked: null, claim: account.id } } +/** + * A bearer `inbox/register` links the inbox to the token's own account: its + * `account` proof is required (`bad_request`) and must name that account + * (`forbidden`). Checked before the signature and quota. + */ +const refuseBearerRegistration = ( + call: Signed, + account: AccountAuth | null +): BuddiesFailure | null => { + if (!account) return null + if (!call.account) return fail('bad_request') + return call.account.accountId === account.accountId ? null : fail('forbidden') +} + /** * `inbox/register`, linking the inbox to the account its proof names. The link * is written only after the inbox accepted the owner-signed registration, so it * takes both the owner key and the account key. The answer carries `accountId` * once the inbox belongs to an account. Without the accounts database (or while - * it fails) registration works as before accounts, without links. + * it fails) a legacy registration works as before accounts, without links; a + * bearer one, which exists to link the inbox, answers `disabled` (or fails + * with 500) instead, so the app retries. + * + * The inbox lets bearer requests act as the owner only for a link the + * accounts database holds, where moderation finds it: an existing link goes + * along with the registration, and a new one is confirmed in the inbox once + * the database has written it, naming the database's winner (another + * 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. */ const registerInbox = async ( env: Environment, - call: Signed + call: Signed, + account: AccountAuth | null ): 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 } if (db) { try { check = await checkInboxAccount(db, call) } catch (error) { + if (account) throw error console.warn('buddies: accounts database unavailable; registering unlinked') - Sentry.captureException(error) + captureFailure(error, 'inbox/register') } } if (!check.ok) return check - const accountId = check.linked ?? check.claim - const result = await inbox(env, call.inboxId).register(call, accountId) + const stub = inbox(env, call.inboxId) + const result = await stub.register(call, check.linked) if (!result.ok) return result if (db && check.claim) { + let owner try { - const owner = await linkInbox(db, call.inboxId, check.claim) - if (owner !== check.claim) return fail('conflict') + owner = await linkInbox(db, call.inboxId, check.claim) } catch (error) { + if (account) throw error console.warn('buddies: linking an inbox failed') - Sentry.captureException(error) + captureFailure(error, 'inbox/register') return ok({ body: {} }) } + await stub.confirmAccount(owner.id, owner.status === 'suspended') + if (owner.id !== check.claim) return fail('conflict') } + const accountId = check.linked ?? check.claim return ok({ body: accountId ? { accountId } : {} }) } -const SIGNED_OPS: SignedRunners = { - 'inbox/register': registerInbox, - // `capabilities` tells the app which optional relay features it may use. - 'inbox/sync': async (env, call) => { - const result = await inbox(env, call.inboxId).sync(call) - if (!result.ok) return result - const capabilities = { photos: await photosAvailable(env) } - return ok({ body: { ...result.value, capabilities } }) - }, +/** + * `inbox/sync`. `linkedOnly`: a legacy one admitted after the legacy switch + * went off (`admitCaller`), served only for an inbox with a confirmed account + * link. `capabilities` tells the app which optional relay features it may use. + */ +const syncInbox = async ( + env: Environment, + call: OwnerCall, + linkedOnly = false +): Promise => { + const result = await inbox(env, call.inboxId).sync(call, { linkedOnly }) + if (!result.ok) return result + const capabilities = { photos: await photosAvailable(env) } + return ok({ body: { ...result.value, capabilities } }) +} + +/** Owner ops, signed (legacy) or by the bearer token's account. */ +const OWNER_OPS: OwnerRunners = { + 'inbox/sync': (env, call) => syncInbox(env, call), 'inbox/delete': async (env, call) => empty(await inbox(env, call.inboxId).deleteInbox(call)), 'device/register': async (env, call) => @@ -214,6 +366,11 @@ const SIGNED_OPS: SignedRunners = { empty(await inbox(env, call.inboxId).deleteInvite(call)), 'blob/delete': async (env, call) => empty(await inbox(env, call.inboxId).deleteBlobs(call)), +} + +const SIGNED_OPS: SignedRunners = { + 'inbox/register': registerInbox, + ...OWNER_OPS, 'card/put': async (env, call) => withSeq(await inbox(env, call.inboxId).putCard(call)), 'event/put': async (env, call) => { @@ -253,6 +410,9 @@ const UNSIGNED_OPS: UnsignedRunners = { const isUnsignedOp = (op: BuddiesOp): op is BuddiesUnsignedOp => (BUDDIES_UNSIGNED_OPS as readonly BuddiesOp[]).includes(op) +const isOwnerOp = (op: BuddiesOp): op is BuddiesOwnerOp => + (BUDDIES_OWNER_OPS as readonly BuddiesOp[]).includes(op) + /** The inbox DO checks this too; here it spares a wake-up. */ const isStale = (ts: number, now: number): boolean => Math.abs(now - ts) > BUDDIES_LIMITS.timestampSkewMs @@ -289,7 +449,7 @@ const admitRegistration = async ( admission = await quota.get(quota.idFromName(name)).admit(Date.now()) } catch (error) { console.warn('buddies: registration quota unavailable; allowing') - Sentry.captureException(error) + captureFailure(error, 'inbox/register') return null } if (admission.ok) return null @@ -305,21 +465,46 @@ const runSigned = async ( op: K, env: Environment, envelope: Envelope, - callerKey: string + caller: Caller ): Promise => { const now = Date.now() const call = signedCall(op, envelope, now) if (!call.ok) return call if (isStale(call.value.ts, now)) return { ok: false, error: 'stale' } + if (caller.linkedOnly) { + // `admitCaller` lets only `inbox/sync` through this way. + if (op !== 'inbox/sync') return fail('upgrade_required') + const sync = call.value as unknown as Signed + return syncInbox(env, sync, true) + } if (op === 'inbox/register') { - const refused = await admitRegistration( - env, - call.value as unknown as Signed, - callerKey - ) + const register = call.value as unknown as Signed< + BuddiesPayloads['inbox/register'] + > + const refused = + refuseBearerRegistration(register, caller.account) ?? + (await admitRegistration(env, register, caller.key)) if (refused) return refused } - return SIGNED_OPS[op](env, call.value) + return SIGNED_OPS[op](env, call.value, caller.account) +} + +/** + * 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 + * (`payload`/`signature`) is `bad_request`: bearer requests aren't signed. + */ +const runByAccount = async ( + op: K, + env: Environment, + text: string, + account: AccountAuth +): Promise => { + const body = parseJsonOrNull(text) + if (!isPlainObject(body) || isEnvelopeBody(body)) return fail('bad_request') + const fields = BUDDIES_FIELD_PARSERS[op](body, Date.now()) + if (!fields) return fail('bad_request') + return OWNER_OPS[op](env, { ...fields, accountId: account.accountId }) } const runUnsigned = async ( @@ -355,17 +540,22 @@ const handleBuddiesOp = async ( } if (BUDDIES_PHOTO_OPS.has(op) && !(await photosAvailable(c.env))) return errorResponse('photos_disabled') - const callerKey = buddiesCallerKey(c.req.header('CF-Connecting-IP')) - if (!(await allowBuddiesCaller(c.env, op, callerKey))) - return edgeLimited() + const admission = await admitCaller(c, op) + if (!admission.ok) return admission.response + const { caller } = admission const text = await readEnvelopeText(c.req.raw) - const envelope = text == null ? null : parseEnvelope(text) - if (!envelope) return errorResponse('bad_request') - - const result = isUnsignedOp(op) - ? await runUnsigned(op, c.env, envelope) - : await runSigned(op, c.env, envelope, callerKey) + if (text == null) return errorResponse('bad_request') + let result: Outcome + if (caller.account && isOwnerOp(op)) { + result = await runByAccount(op, c.env, text, caller.account) + } else { + const envelope = parseEnvelope(text) + if (!envelope) return errorResponse('bad_request') + result = isUnsignedOp(op) + ? await runUnsigned(op, c.env, envelope) + : await runSigned(op, c.env, envelope, caller) + } if (!result.ok) return errorResponse(result.error, result.retryAfterSeconds) @@ -375,34 +565,59 @@ const handleBuddiesOp = async ( } catch (error) { // Report the failure, never the request: bodies hold ids, keys, and blobs. console.error('buddies: request failed', { op }) - Sentry.captureException(error) + captureFailure(error, op) return buddiesInternalError() } } /** - * Upgrades to the owner's live socket. The signed envelope rides in the - * `x-buddies-payload` / `x-buddies-signature` headers because a WebSocket - * upgrade has no body (and ids never go in URLs). The Worker counts it as a read and checks - * its shape and `ts` to find the inbox; the inbox DO verifies it and accepts + * The upgrade as the inbox DO gets it. A bearer one names the token's account + * in `BUDDIES_LIVE_ACCOUNT_HEADER`, which the DO trusts; any copy a client + * sent is dropped, so a legacy upgrade never carries one. + */ +const liveRequest = (request: Request, account: AccountAuth | null): Request => { + if (!account && !request.headers.has(BUDDIES_LIVE_ACCOUNT_HEADER)) + return request + const headers = new Headers(request.headers) + headers.delete(BUDDIES_LIVE_ACCOUNT_HEADER) + if (account) headers.set(BUDDIES_LIVE_ACCOUNT_HEADER, account.accountId) + return new Request(request, { headers }) +} + +/** + * Upgrades to the owner's live socket. A WebSocket upgrade has no body (and + * ids never go in URLs), so the request rides in headers: the fields in + * `x-buddies-payload`, plus `x-buddies-signature` for a legacy (owner-signed) + * upgrade. The Worker counts it as a read and checks its shape (and a legacy + * one's `ts`) to find the inbox; the inbox DO authenticates it and accepts * the socket. */ const handleLive = async (c: AppContext): Promise => { try { if (!(await isBuddiesEnabled(c.env))) return errorResponse('disabled') - const callerKey = buddiesCallerKey(c.req.header('CF-Connecting-IP')) - if (!(await allowBuddiesCaller(c.env, BUDDIES_LIVE_OP, callerKey))) - return edgeLimited() + const admission = await admitCaller(c, BUDDIES_LIVE_OP) + if (!admission.ok) return admission.response + const { caller } = admission if (!isWebSocketUpgrade(c.req.raw)) return errorResponse('upgrade_required') const now = Date.now() - const call = readLiveCall(c.req.raw.headers, now) - if (!call.ok) return errorResponse(call.error) - if (isStale(call.value.ts, now)) return errorResponse('stale') - return await inbox(c.env, call.value.inboxId).fetch(c.req.raw) + let inboxId: string + if (caller.account) { + const fields = readHeaderFields(BUDDIES_LIVE_OP, c.req.raw.headers, now) + if (!fields.ok) return errorResponse(fields.error) + inboxId = fields.value.inboxId + } else { + const call = readLiveCall(c.req.raw.headers, now) + if (!call.ok) return errorResponse(call.error) + if (isStale(call.value.ts, now)) return errorResponse('stale') + inboxId = call.value.inboxId + } + return await inbox(c.env, inboxId).fetch( + liveRequest(c.req.raw, caller.account) + ) } catch (error) { console.error('buddies: request failed', { op: BUDDIES_LIVE_OP }) - Sentry.captureException(error) + captureFailure(error, BUDDIES_LIVE_OP) return buddiesInternalError() } } @@ -414,34 +629,54 @@ const BLOB_HEADERS = { 'X-Content-Type-Options': 'nosniff', } as const -/** Shared refusals before any blob op: both switches, then the caller's edge limit. */ -const refuseBlobOp = async ( +/** Shared gates before any blob op: both switches, then the caller and its edge limit. */ +const admitBlobOp = async ( c: AppContext, op: typeof BUDDIES_BLOB_PUT_OP | typeof BUDDIES_BLOB_GET_OP -): Promise => { - if (!(await isBuddiesEnabled(c.env))) return errorResponse('disabled') - if (!(await photosAvailable(c.env))) return errorResponse('photos_disabled') - const callerKey = buddiesCallerKey(c.req.header('CF-Connecting-IP')) - if (!(await allowBuddiesCaller(c.env, op, callerKey))) return edgeLimited() - return null +): Promise => { + if (!(await isBuddiesEnabled(c.env))) + return { ok: false, response: errorResponse('disabled') } + if (!(await photosAvailable(c.env))) + return { ok: false, response: errorResponse('photos_disabled') } + return admitCaller(c, op) } /** - * `POST blob/put`: an owner-signed upload of one sealed photo. The envelope - * rides in the `x-buddies-payload` / `x-buddies-signature` headers (op - * `blob/put`) and the body is the raw sealed bytes. `bytes` is checked before the body is read - * and the read stops past it; the body's SHA-256 must be `blobId`, so the - * signature covers the bytes too. The inbox DO verifies the signature, takes - * the nonce, applies the caps, and writes the object. + * A `blob/put`'s call from its headers: the bearer request's fields on behalf + * of its account, or the legacy owner-signed envelope (fresh `ts` required). + */ +const blobPutCall = ( + headers: Headers, + now: number, + account: AccountAuth | null +): BuddiesResult> => { + if (account) { + const fields = readHeaderFields(BUDDIES_BLOB_PUT_OP, headers, now) + return fields.ok + ? ok({ ...fields.value, accountId: account.accountId }) + : fields + } + const call = readBlobPutCall(headers, now) + if (!call.ok) return call + return isStale(call.value.ts, now) ? fail('stale') : call +} + +/** + * `POST blob/put`: an owner's upload of one sealed photo. The body is the raw + * sealed bytes, so the request's fields ride in `x-buddies-payload` (plus + * `x-buddies-signature`, op `blob/put`, for a legacy owner-signed upload). + * `bytes` is checked before the body is read and the read stops past it; the + * body's SHA-256 must be `blobId`, so a signature covers the bytes too. The + * inbox DO authenticates the call (and takes a legacy one's nonce), applies + * the caps, and writes the object. */ const handleBlobPut = async (c: AppContext): Promise => { try { - const refused = await refuseBlobOp(c, BUDDIES_BLOB_PUT_OP) - if (refused) return refused - const now = Date.now() - const call = readBlobPutCall(c.req.raw.headers, now) + const admission = await admitBlobOp(c, BUDDIES_BLOB_PUT_OP) + if (!admission.ok) return admission.response + const { account } = admission.caller + const call = blobPutCall(c.req.raw.headers, Date.now(), account) if (!call.ok) return errorResponse(call.error) - if (isStale(call.value.ts, now)) return errorResponse('stale') if (call.value.bytes > BUDDIES_LIMITS.blobBytes) return errorResponse('too_large') const body = await readBlobBody(c.req.raw, call.value.bytes) @@ -457,21 +692,22 @@ const handleBlobPut = async (c: AppContext): Promise => { return c.json({ ok: true, expiresAt: result.value.expiresAt }) } catch (error) { console.error('buddies: request failed', { op: BUDDIES_BLOB_PUT_OP }) - Sentry.captureException(error) + captureFailure(error, BUDDIES_BLOB_PUT_OP) return buddiesInternalError() } } /** * `POST blob/get`: an unsigned `{ "payload": … }` envelope whose payload names the - * sender's inbox, the blob, and the read token (the capability). Answers the - * raw sealed bytes, or one `not_found` for an unknown inbox or blob, an - * expired blob, and a wrong token alike. + * sender's inbox, the blob, and the read token (the capability), the same + * with or without a bearer token. Answers the raw sealed bytes, or one + * `not_found` for an unknown inbox or blob, an expired blob, and a wrong + * token alike. */ const handleBlobGet = async (c: AppContext): Promise => { try { - const refused = await refuseBlobOp(c, BUDDIES_BLOB_GET_OP) - if (refused) return refused + const admission = await admitBlobOp(c, BUDDIES_BLOB_GET_OP) + if (!admission.ok) return admission.response const text = await readEnvelopeText(c.req.raw) const envelope = text == null ? null : parseEnvelope(text) const payload = envelope @@ -498,7 +734,7 @@ const handleBlobGet = async (c: AppContext): Promise => { }) } catch (error) { console.error('buddies: request failed', { op: BUDDIES_BLOB_GET_OP }) - Sentry.captureException(error) + captureFailure(error, BUDDIES_BLOB_GET_OP) return buddiesInternalError() } } @@ -507,7 +743,7 @@ const handleBlobGet = async (c: AppContext): Promise => { export const createBuddiesRoutes = ( deps: BuddiesRouteDependencies = { push: defaultApnsDependencies } ) => { - const routes = new Hono<{ Bindings: Environment }>() + const routes = new Hono() for (const op of BUDDIES_OPS) { routes.post(`/${op}`, buddiesOpDocs[op], (c) => handleBuddiesOp(c, op, deps) diff --git a/src/buddies/sessions.test.ts b/src/buddies/sessions.test.ts new file mode 100644 index 0000000..3d1243a --- /dev/null +++ b/src/buddies/sessions.test.ts @@ -0,0 +1,1230 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + DAY_MS, + Owner, + SigningKey, + Writer, + b64u, + blobOfSize, + createHarness, + envelope, + inviteSecrets, + liveHeaders, + photoBlob, + randomId, + relayError, + unsignedEnvelope, + type ApiResponse, + type Harness, + type HarnessOptions, + type PhotoBlob, +} from '../test/buddies' +import { bearer, openTestSession, type TestSession } from '../test/accounts' +import { inboxLinkMessage } from '../accounts/contracts' +import { createAccountsRoutes } from '../accounts/route' +import { createAccountsAdminRoutes } from '../accounts/admin' +import { resetAccountStatusCache } from '../accounts/auth' +import { + BUDDIES_CLIENT_HEADER, + BUDDIES_LIVE_ACCOUNT_HEADER, + BUDDIES_OPS, + type BuddiesSignedOp, +} from './contracts' + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + ctx: DurableObjectState + env: unknown + constructor(ctx: DurableObjectState, env: unknown) { + this.ctx = ctx + this.env = env + } + }, +})) + +/** + * The Buddies relay on account sessions (witness-work ADR 0027, + * "Authentication (sessions)" in docs/buddies-protocol.md): bearer requests + * for every op class, the legacy switch, and per-account limits. The legacy + * (signed) behavior is covered by the other Buddies tests, which run with the + * switch on. + */ + +const START = Date.UTC(2026, 9, 16, 12) +const ADMIN_TOKEN = 'admin-secret-for-tests' +const IP = '203.0.113.7' +const encoder = new TextEncoder() +const accountsApp = createAccountsRoutes() as never +const adminApp = createAccountsAdminRoutes() as never + +let h: Harness + +const setUp = async (options: HarnessOptions = {}) => { + h = await createHarness(options) + h.env.ADMIN_API_TOKEN = ADMIN_TOKEN +} + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(START) + resetAccountStatusCache() + await setUp() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +const session = () => openTestSession(accountsApp, h.env) +const legacyOff = () => h.kv.put('auth:legacy', 'off') +const encodeFields = (fields: Record) => + b64u(encoder.encode(JSON.stringify(fields))) + +const setStatus = async (accountId: string, status: string) => { + const response = await adminApp.request( + '/status', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-ww-admin-token': ADMIN_TOKEN, + }, + body: JSON.stringify({ accountId, status, reason: 'report' }), + }, + h.env + ) + expect(response.status).toBe(200) + resetAccountStatusCache() +} + +/** One person on a session: an account key, its token, and an inbox. */ +class BearerOwner { + readonly inboxId = randomId() + + private constructor( + readonly session: TestSession, + readonly key: SigningKey + ) {} + + /** Opens a session and (unless `register` is false) registers a linked inbox. */ + static async create(register = true): Promise { + const owner = new BearerOwner(await session(), await SigningKey.generate()) + if (register) { + const response = await owner.register() + if (response.status !== 200) + throw new Error(`register failed: ${response.status}`) + } + return owner + } + + get auth(): Record { + return bearer(this.session.accessToken) + } + + /** The `account` proof linking this inbox to `account` (this one's by default). */ + async proof(account: TestSession = this.session) { + const message = inboxLinkMessage( + account.accountId, + this.inboxId, + this.key.publicKey + ) + return { + accountId: account.accountId, + signature: b64u(await account.key.sign(message)), + } + } + + /** A bearer `inbox/register`: the token plus the self-signed envelope. */ + async register( + fields: Record = {}, + headers = this.auth + ): Promise { + const payload = { + inboxId: this.inboxId, + ownerPub: this.key.publicKey, + account: await this.proof(), + ts: Date.now(), + nonce: randomId(), + ...fields, + } + return h.post( + '/inbox/register', + await envelope('inbox/register', payload, this.key), + headers + ) + } + + /** A bearer owner op: plain JSON fields with this inbox's id. */ + op( + op: BuddiesSignedOp, + fields: Record = {}, + headers = this.auth + ): Promise { + return h.post(`/${op}`, { inboxId: this.inboxId, ...fields }, headers) + } + + sync(headers = this.auth): Promise { + return this.op('inbox/sync', { since: 0 }, headers) + } + + /** A bearer `inbox/live` upgrade: the fields in `x-buddies-payload`. */ + connect(headers = this.auth, fields: Record = {}) { + return h.live({ + upgrade: 'websocket', + 'x-buddies-payload': encodeFields({ inboxId: this.inboxId, ...fields }), + ...headers, + }) + } + + /** A bearer `blob/put`: the fields in `x-buddies-payload`, the bytes raw. */ + async putBlob( + blob: PhotoBlob, + headers = this.auth, + fields: Record = {} + ): Promise { + const response = await h.request('/blob/put', { + method: 'POST', + headers: { + 'cf-connecting-ip': IP, + 'content-type': 'application/octet-stream', + 'content-length': String(blob.bytes.byteLength), + 'x-buddies-payload': encodeFields({ + inboxId: this.inboxId, + blobId: blob.blobId, + bytes: blob.bytes.byteLength, + expiresAt: Date.now() + 30 * DAY_MS, + readTokenHash: blob.readTokenHash, + ...fields, + }), + ...headers, + }, + body: blob.bytes, + ...({ duplex: 'half' } as object), + }) + return { status: response.status, body: await response.json() } + } + + /** Adds a slot for a buddy's writer key, by bearer request. */ + async addWriter(): Promise { + const key = await SigningKey.generate() + const slotId = randomId() + const response = await this.op('slot/add', { + slotId, + writerPub: key.publicKey, + }) + expect(response.status).toBe(200) + return new Writer(h, this.inboxId, slotId, key) + } +} + +/** A writer op: the writer-signed envelope, plus `headers` (a token). */ +const writerSend = async ( + writer: Writer, + op: BuddiesSignedOp, + fields: Record, + headers: Record, + key = writer.key +) => + h.post( + `/${op}`, + await envelope( + op, + { + inboxId: writer.inboxId, + slotId: writer.slotId, + ts: Date.now(), + nonce: randomId(), + ...fields, + }, + key + ), + headers + ) + +const getBlob = async ( + inboxId: string, + blob: PhotoBlob, + headers: Record +) => { + const response = await h.request('/blob/get', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'cf-connecting-ip': IP, + ...headers, + }, + body: JSON.stringify( + unsignedEnvelope({ inboxId, blobId: blob.blobId, token: blob.token }) + ), + }) + return response.status === 200 + ? { status: 200, bytes: new Uint8Array(await response.arrayBuffer()) } + : { status: response.status, body: await response.json() } +} + +const nonceRows = (inboxId: string) => + h.inboxes.storage(inboxId).query('SELECT COUNT(*) AS n FROM nonce')[0].n + +describe('bearer owner ops', () => { + it('registers with a proof for the token’s account, then runs every owner op as plain JSON', async () => { + const owner = await BearerOwner.create(false) + expect(await owner.register()).toEqual({ + status: 200, + body: { ok: true, accountId: owner.session.accountId }, + }) + + const synced = await owner.sync() + expect(synced).toEqual({ + status: 200, + body: { + ok: true, + seq: 0, + slots: [], + cards: [], + events: [], + roster: null, + capabilities: { photos: true }, + }, + }) + + const deviceId = randomId() + expect( + ( + await owner.op('device/register', { + deviceId, + apnsToken: 'ab'.repeat(32), + apnsEnvironment: 'sandbox', + templates: { 'plan.joined': { title: 'Plans', body: 'Joined' } }, + }) + ).body + ).toEqual({ ok: true }) + expect(await owner.op('roster/put', { blob: blobOfSize(64) })).toEqual({ + status: 200, + body: { ok: true, seq: 1 }, + }) + const slotId = randomId() + const writer = await SigningKey.generate() + expect( + (await owner.op('slot/add', { slotId, writerPub: writer.publicKey })) + .body + ).toEqual({ ok: true }) + const invite = await inviteSecrets() + expect( + ( + await owner.op('invite/create', { + inviteId: invite.inviteId, + claimVerifier: invite.claimVerifier, + blob: blobOfSize(100), + expiresAt: Date.now() + DAY_MS, + }) + ).status + ).toBe(200) + for (const [op, fields] of [ + ['invite/delete', { inviteId: invite.inviteId }], + ['blob/delete', { blobIds: [b64u(new Uint8Array(32))] }], + ['device/unregister', { deviceId }], + ['slot/remove', { slotId }], + ] as const) { + expect(await owner.op(op, fields)).toEqual({ + status: 200, + body: { ok: true }, + }) + } + const after = await owner.sync() + expect(after.body).toMatchObject({ seq: 1, slots: [] }) + + expect((await owner.op('inbox/delete')).status).toBe(200) + expect(await owner.sync()).toEqual({ + status: 404, + body: relayError('not_found'), + }) + }) + + it('requires the proof on a bearer register, and for the token’s own account', async () => { + const owner = await BearerOwner.create(false) + const other = await session() + const quotas = vi.spyOn(h.env.BUDDY_REGISTRATION_QUOTA, 'idFromName') + + expect(await owner.register({ account: undefined })).toEqual({ + status: 400, + body: relayError('bad_request'), + }) + expect(await owner.register({ account: await owner.proof(other) })).toEqual( + { status: 403, body: relayError('forbidden') } + ) + // Refused before the quota counts them. + expect(quotas).not.toHaveBeenCalled() + // The other account's own token with its own proof would be fine. + expect( + ( + await owner.register( + { account: await owner.proof(other) }, + bearer(other.accessToken) + ) + ).body + ).toEqual({ ok: true, accountId: other.accountId }) + }) + + it('refuses another account’s inbox with forbidden', async () => { + const owner = await BearerOwner.create() + const stranger = await session() + const as = bearer(stranger.accessToken) + + expect(await owner.sync(as)).toEqual({ + status: 403, + body: relayError('forbidden'), + }) + expect( + await owner.op('slot/add', { slotId: randomId(), writerPub: owner.key.publicKey }, as) + ).toEqual({ status: 403, body: relayError('forbidden') }) + expect((await owner.op('inbox/delete', {}, as)).status).toBe(403) + expect((await owner.op('slot/remove', { slotId: randomId() }, as)).status).toBe(403) + expect((await owner.connect(as)).status).toBe(403) + const blob = await photoBlob() + await owner.addWriter() + expect(await owner.putBlob(blob, as)).toEqual({ + status: 403, + body: relayError('forbidden'), + }) + // The owner is untouched. + expect((await owner.sync()).status).toBe(200) + }) + + it('answers not_linked for an inbox registered before accounts, until it links', async () => { + const legacy = await Owner.create(h) + const owner = await BearerOwner.create(false) + // Same inbox and owner key as the legacy registration. + Object.assign(owner, { inboxId: legacy.inboxId, key: legacy.key }) + + expect(await owner.sync()).toEqual({ + status: 409, + body: relayError('not_linked'), + }) + expect((await owner.connect()).status).toBe(409) + expect(await owner.register()).toEqual({ + status: 200, + body: { ok: true, accountId: owner.session.accountId }, + }) + expect((await owner.sync()).status).toBe(200) + // The legacy envelope keeps working while the switch is on. + expect((await legacy.sync()).status).toBe(200) + }) + + it('answers not_found for an unknown inbox', async () => { + const owner = await BearerOwner.create(false) + expect(await owner.sync()).toEqual({ + status: 404, + body: relayError('not_found'), + }) + }) + + it('refuses a bad token without trying the envelope', async () => { + const owner = await Owner.create(h) + const signed = await envelope( + 'inbox/sync', + { inboxId: owner.inboxId, since: 0, ts: Date.now(), nonce: randomId() }, + owner.key + ) + for (const token of ['expired.or.forged', '']) { + const response = await h.request('/inbox/sync', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'cf-connecting-ip': IP }, + body: JSON.stringify(signed), + }) + expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toBe( + 'Bearer error="invalid_token"' + ) + expect(await response.json()).toEqual(relayError('unauthorized')) + } + }) + + it('refuses an envelope as a bearer owner body', async () => { + const owner = await BearerOwner.create() + const signed = await envelope( + 'inbox/sync', + { inboxId: owner.inboxId, since: 0, ts: Date.now(), nonce: randomId() }, + owner.key + ) + expect(await h.post('/inbox/sync', signed, owner.auth)).toEqual({ + status: 400, + body: relayError('bad_request'), + }) + expect( + await h.post('/inbox/sync', { ...signed, inboxId: owner.inboxId, since: 0 }, owner.auth) + ).toEqual({ status: 400, body: relayError('bad_request') }) + expect(await h.post('/inbox/sync', '{not json', owner.auth)).toEqual({ + status: 400, + body: relayError('bad_request'), + }) + }) + + it('takes no ts or nonce: repeats are no replay, and no nonce is stored', async () => { + const owner = await BearerOwner.create() + const before = nonceRows(owner.inboxId) + const blob = blobOfSize(64) + const fields = { blob, ts: Date.now() - DAY_MS, nonce: randomId() } + expect(await owner.op('roster/put', fields)).toEqual({ + status: 200, + body: { ok: true, seq: 1 }, + }) + expect(await owner.op('roster/put', fields)).toEqual({ + status: 200, + body: { ok: true, seq: 2 }, + }) + expect(nonceRows(owner.inboxId)).toBe(before) + }) + + it('refuses a suspended account’s owner ops, but lets it leave', async () => { + const owner = await BearerOwner.create() + const slotId = randomId() + expect( + (await owner.op('slot/add', { slotId, writerPub: owner.key.publicKey })) + .status + ).toBe(200) + await setStatus(owner.session.accountId, 'suspended') + + expect(await owner.sync()).toEqual({ + status: 403, + body: relayError('suspended'), + }) + expect((await owner.connect()).status).toBe(403) + expect((await owner.op('slot/remove', { slotId })).status).toBe(200) + expect((await owner.op('inbox/delete')).status).toBe(200) + }) + + it('keeps the kill switch: only leaving works while Buddies is off', async () => { + const owner = await BearerOwner.create() + await h.kv.put('buddies:enabled', 'false') + expect(await owner.sync()).toEqual({ + status: 503, + body: relayError('disabled'), + }) + expect((await owner.connect()).status).toBe(503) + expect((await owner.op('slot/remove', { slotId: randomId() })).status).toBe( + 200 + ) + expect((await owner.op('inbox/delete')).status).toBe(200) + }) + + it('answers disabled on a worker without sessions', async () => { + const owner = await BearerOwner.create() + delete h.env.ACCOUNT_TOKEN_SECRET + expect(await owner.sync()).toEqual({ + status: 503, + body: relayError('disabled'), + }) + }) +}) + +/** The inbox's `owner_account` row, straight from its storage. */ +const ownerAccountRow = (inboxId: string) => + h.inboxes + .storage(inboxId) + .query( + 'SELECT account_id AS accountId, suspended, confirmed FROM owner_account' + ) + +const lookupInbox = async (inboxId: string) => { + const response = await adminApp.request( + '/lookup', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-ww-admin-token': ADMIN_TOKEN, + }, + body: JSON.stringify({ inboxId }), + }, + h.env + ) + return { status: response.status, body: await response.json() } +} + +/** Makes the accounts database refuse writes to `account_inboxes`. */ +const failInboxLinks = () => { + const db = h.accountsDb! + const prepare = db.prepare.bind(db) + return vi.spyOn(db, 'prepare').mockImplementation((query: string) => { + if (/^insert into "account_inboxes"/i.test(query)) + throw new Error('D1 down') + return prepare(query) + }) +} + +describe('confirmed inbox links', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + it('keeps an inbox the accounts database never linked not_linked, until registering again links it', async () => { + const owner = await BearerOwner.create(false) + const failing = failInboxLinks() + expect(await owner.register()).toEqual({ + status: 500, + body: { ok: false, error: 'internal', code: 'internal' }, + }) + failing.mockRestore() + + // The inbox accepted the registration, but moderation can't find it, so + // no token may act as its owner. + expect(await lookupInbox(owner.inboxId)).toMatchObject({ status: 404 }) + expect(h.inboxes.storage(owner.inboxId).tables()).not.toContain( + 'owner_account' + ) + expect(await owner.sync()).toEqual({ + status: 409, + body: relayError('not_linked'), + }) + expect((await owner.connect()).status).toBe(409) + expect(await owner.putBlob(await photoBlob())).toEqual({ + status: 409, + body: relayError('not_linked'), + }) + + // The app's relink is the same registration again. + expect(await owner.register()).toEqual({ + status: 200, + body: { ok: true, accountId: owner.session.accountId }, + }) + expect(await owner.register()).toEqual({ + status: 200, + body: { ok: true, accountId: owner.session.accountId }, + }) + expect((await owner.sync()).status).toBe(200) + + // Moderation reaches it now. + await setStatus(owner.session.accountId, 'suspended') + expect(await owner.sync()).toEqual({ + status: 403, + body: relayError('suspended'), + }) + }) + + it('leaves a link the inbox never confirmed to moderation and the next registration', async () => { + const owner = await BearerOwner.create(false) + const object = h.inboxes.object(owner.inboxId) + vi.spyOn(object, 'confirmAccount').mockRejectedValueOnce( + new Error('RPC reply lost') + ) + expect((await owner.register()).status).toBe(500) + // The accounts database holds the link; the inbox doesn't trust it yet. + expect((await lookupInbox(owner.inboxId)).body.account.id).toBe( + owner.session.accountId + ) + expect(await owner.sync()).toEqual({ + status: 409, + body: relayError('not_linked'), + }) + + // A suspension finds the inbox through the database and confirms it. + await setStatus(owner.session.accountId, 'suspended') + expect(ownerAccountRow(owner.inboxId)).toEqual([ + { accountId: owner.session.accountId, suspended: 1, confirmed: 1 }, + ]) + expect(await owner.sync()).toEqual({ + status: 403, + body: relayError('suspended'), + }) + expect(await owner.register()).toEqual({ + status: 403, + body: relayError('suspended'), + }) + expect((await owner.op('slot/remove', { slotId: randomId() })).status).toBe( + 200 + ) + }) + + it('names the database’s winner when two accounts claim an inbox at once', async () => { + // One person with the inbox's owner key and two accounts. + const first = await BearerOwner.create(false) + const second = await BearerOwner.create(false) + Object.assign(second, { inboxId: first.inboxId, key: first.key }) + const object = h.inboxes.object(first.inboxId) + const register = object.register.bind(object) + let secondChecked!: () => void + const bothChecked = new Promise((resolve) => (secondChecked = resolve)) + let releaseSecond!: () => void + const secondMayRegister = new Promise( + (resolve) => (releaseSecond = resolve) + ) + // Both pass the database check before either links; the first finishes + // before the second's registration reaches the inbox (the order that + // used to leave the inbox naming the loser). + vi.spyOn(object, 'register').mockImplementation(async (call, linked) => { + if (call.account?.accountId === second.session.accountId) { + secondChecked() + await secondMayRegister + } else { + await bothChecked + } + return register(call, linked) + }) + + const firstReply = first.register() + const secondReply = second.register() + expect(await firstReply).toEqual({ + status: 200, + body: { ok: true, accountId: first.session.accountId }, + }) + releaseSecond() + expect(await secondReply).toEqual({ + status: 409, + body: relayError('conflict'), + }) + + expect((await lookupInbox(first.inboxId)).body.account.id).toBe( + first.session.accountId + ) + expect(ownerAccountRow(first.inboxId)).toEqual([ + { accountId: first.session.accountId, suspended: 0, confirmed: 1 }, + ]) + expect((await first.sync()).status).toBe(200) + expect(await second.sync()).toEqual({ + status: 403, + body: relayError('forbidden'), + }) + }) + + it('carries a suspension that lands while the link is written', async () => { + const owner = await BearerOwner.create(false) + const object = h.inboxes.object(owner.inboxId) + const register = object.register.bind(object) + // Suspended after the registration's check, while no link exists for + // the admin to find. + vi.spyOn(object, 'register').mockImplementationOnce(async (call, linked) => { + await setStatus(owner.session.accountId, 'suspended') + return register(call, linked) + }) + expect((await owner.register()).status).toBe(200) + expect(ownerAccountRow(owner.inboxId)).toEqual([ + { accountId: owner.session.accountId, suspended: 1, confirmed: 1 }, + ]) + expect(await owner.sync()).toEqual({ + status: 403, + body: relayError('suspended'), + }) + }) + + it('treats a link from before confirmation as none until the app links it again', async () => { + const owner = await BearerOwner.create() + const loser = await session() + // A row as the inbox stored it before confirmation, naming an account + // the accounts database doesn't link (a lost concurrent claim). + const storage = h.inboxes.storage(owner.inboxId) + storage.query('UPDATE owner_account SET account_id = ?', loser.accountId) + storage.query('ALTER TABLE owner_account DROP COLUMN confirmed') + h.inboxes.restart(owner.inboxId) + + expect(await owner.sync(bearer(loser.accessToken))).toEqual({ + status: 409, + body: relayError('not_linked'), + }) + expect(await owner.sync()).toEqual({ + status: 409, + body: relayError('not_linked'), + }) + expect(ownerAccountRow(owner.inboxId)).toEqual([ + { accountId: loser.accountId, suspended: 0, confirmed: 0 }, + ]) + + expect(await owner.register()).toEqual({ + status: 200, + body: { ok: true, accountId: owner.session.accountId }, + }) + expect(ownerAccountRow(owner.inboxId)).toEqual([ + { accountId: owner.session.accountId, suspended: 0, confirmed: 1 }, + ]) + expect((await owner.sync()).status).toBe(200) + expect((await owner.sync(bearer(loser.accessToken))).status).toBe(403) + }) + + it('keeps an old row’s suspension while it waits to be confirmed', async () => { + const owner = await BearerOwner.create() + await setStatus(owner.session.accountId, 'suspended') + const storage = h.inboxes.storage(owner.inboxId) + storage.query('ALTER TABLE owner_account DROP COLUMN confirmed') + h.inboxes.restart(owner.inboxId) + const legacy = await Owner.create(h, false) + Object.assign(legacy, { inboxId: owner.inboxId, key: owner.key }) + expect(await legacy.sync()).toMatchObject({ + status: 403, + body: relayError('suspended'), + }) + }) +}) + +describe('bearer writer and capability ops', () => { + it('takes the token plus the writer envelope, never comparing the token’s account', async () => { + const owner = await BearerOwner.create() + const buddy = await session() + const writer = await owner.addWriter() + + const put = await writerSend( + writer, + 'event/put', + { eventId: randomId(), kind: 'plan.joined', blob: blobOfSize(48), push: false }, + bearer(buddy.accessToken) + ) + expect(put).toEqual({ status: 200, body: { ok: true, seq: 1 } }) + // Any valid session works: the writer signature is the capability. + expect( + (await writerSend(writer, 'card/put', { blob: blobOfSize(64) }, owner.auth)) + .body + ).toEqual({ ok: true, seq: 2 }) + const stranger = await SigningKey.generate() + expect( + await writerSend( + writer, + 'card/put', + { blob: blobOfSize(64) }, + bearer(buddy.accessToken), + stranger + ) + ).toEqual({ status: 401, body: relayError('bad_signature') }) + expect( + await writerSend(writer, 'card/put', { blob: blobOfSize(64) }, bearer('forged')) + ).toEqual({ status: 401, body: relayError('unauthorized') }) + + const synced = await owner.sync() + expect(synced.body.events).toHaveLength(1) + expect(synced.body.cards).toHaveLength(1) + // The inbox knows only its owner's account, never a writer's. + const accounts = h.inboxes + .storage(owner.inboxId) + .query('SELECT account_id AS accountId FROM owner_account') + expect(accounts).toEqual([{ accountId: owner.session.accountId }]) + + expect( + (await writerSend(writer, 'slot/leave', {}, bearer(buddy.accessToken))) + .body + ).toEqual({ ok: true }) + expect((await owner.sync()).body.slots).toEqual([]) + }) + + it('fetches and claims invites with the token and today’s body', async () => { + const owner = await BearerOwner.create() + const buddy = await session() + const as = bearer(buddy.accessToken) + const invite = await inviteSecrets() + await owner.op('invite/create', { + inviteId: invite.inviteId, + claimVerifier: invite.claimVerifier, + blob: blobOfSize(100), + expiresAt: Date.now() + DAY_MS, + }) + + const fetched = await h.post( + '/invite/fetch', + unsignedEnvelope({ inviteId: invite.inviteId }), + as + ) + expect(fetched.body).toMatchObject({ ok: true, status: 'open' }) + const claimed = await h.post( + '/invite/claim', + unsignedEnvelope({ + inviteId: invite.inviteId, + claimSecret: invite.claimSecret, + blob: blobOfSize(80), + }), + as + ) + expect(claimed).toEqual({ status: 200, body: { ok: true } }) + expect((await owner.sync()).body.events).toMatchObject([ + { kind: 'invite.claimed', eventId: invite.inviteId }, + ]) + }) + + it('puts photo blobs by token and reads them with the read token', async () => { + const owner = await BearerOwner.create() + const blob = await photoBlob() + expect(await owner.putBlob(blob)).toEqual({ + status: 403, + body: relayError('no_buddies'), + }) + await owner.addWriter() + const put = await owner.putBlob(blob) + expect(put).toEqual({ + status: 200, + body: { ok: true, expiresAt: expect.any(Number) }, + }) + // A repeat is idempotent, not a replay. + expect((await owner.putBlob(blob)).status).toBe(200) + + const buddy = await session() + const read = await getBlob(owner.inboxId, blob, bearer(buddy.accessToken)) + expect(read).toEqual({ status: 200, bytes: blob.bytes }) + expect( + await getBlob(owner.inboxId, { ...blob, token: b64u(new Uint8Array(32)) }, bearer(buddy.accessToken)) + ).toMatchObject({ status: 404, body: relayError('not_found') }) + + expect( + (await owner.op('blob/delete', { blobIds: [blob.blobId] })).status + ).toBe(200) + expect((await getBlob(owner.inboxId, blob, owner.auth)).status).toBe(404) + }) + + it('refuses a signature header, a short payload header, and a wrong body on a bearer blob/put', async () => { + const owner = await BearerOwner.create() + await owner.addWriter() + const blob = await photoBlob() + expect( + await owner.putBlob(blob, { ...owner.auth, 'x-buddies-signature': 'x' }) + ).toEqual({ status: 400, body: relayError('bad_request') }) + expect( + await owner.putBlob(blob, { ...owner.auth, 'x-buddies-s': 'x' }) + ).toEqual({ status: 400, body: relayError('bad_request') }) + expect( + await owner.putBlob(blob, { + ...owner.auth, + 'x-buddies-payload': '', + 'x-buddies-p': encodeFields({ inboxId: owner.inboxId }), + }) + ).toEqual({ status: 400, body: relayError('bad_request') }) + const other = await photoBlob() + expect( + await owner.putBlob(blob, owner.auth, { blobId: other.blobId }) + ).toEqual({ status: 400, body: relayError('bad_request') }) + }) +}) + +describe('bearer live socket', () => { + it('says hello, signals changes, and closes with 4003 on suspension', async () => { + const owner = await BearerOwner.create() + const live = await owner.connect() + expect(live.status).toBe(101) + expect(live.socket!.messages()).toEqual([{ type: 'hello', seq: 0 }]) + await owner.op('roster/put', { blob: blobOfSize(32) }) + expect(live.socket!.messages()).toEqual([ + { type: 'hello', seq: 0 }, + { type: 'changed', seq: 1 }, + ]) + await setStatus(owner.session.accountId, 'suspended') + expect(live.socket!.closedBy).toEqual({ code: 4003, reason: 'suspended' }) + }) + + it('refuses a signature header, and ignores an account header from the client', async () => { + const owner = await BearerOwner.create() + expect( + (await owner.connect({ ...owner.auth, 'x-buddies-signature': 'x' })) + .status + ).toBe(400) + + // A legacy upgrade can't claim an account through the internal header. + const legacy = await Owner.create(h) + const forged = await liveHeaders( + { inboxId: owner.inboxId, ts: Date.now(), nonce: randomId() }, + legacy.key + ) + const refused = await h.live({ + ...forged, + [BUDDIES_LIVE_ACCOUNT_HEADER]: owner.session.accountId, + }) + expect(refused).toMatchObject({ + status: 401, + body: relayError('bad_signature'), + }) + const unsigned = await h.live({ + upgrade: 'websocket', + 'x-buddies-payload': encodeFields({ inboxId: owner.inboxId }), + [BUDDIES_LIVE_ACCOUNT_HEADER]: owner.session.accountId, + }) + expect(unsigned.status).toBe(400) + expect(unsigned.socket).toBeNull() + }) + + it('takes the bearer upgrade only as a WebSocket', async () => { + const owner = await BearerOwner.create() + const response = await h.live( + { + 'x-buddies-payload': encodeFields({ inboxId: owner.inboxId }), + ...owner.auth, + }, + 'GET' + ) + expect(response).toMatchObject({ + status: 426, + body: relayError('upgrade_required'), + }) + }) +}) + +describe('the legacy switch', () => { + it('off: every legacy request is 426, leaving included, while bearer requests work', async () => { + const owner = await BearerOwner.create() + const legacy = await Owner.create(h) + const writer = await owner.addWriter() + await legacyOff() + + for (const op of BUDDIES_OPS) { + const response = await h.request(`/${op}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'cf-connecting-ip': IP }, + body: '{}', + }) + expect(response.status, op).toBe(426) + expect(response.headers.get('retry-after'), op).toBeNull() + expect(await response.json()).toEqual(relayError('upgrade_required')) + } + expect((await legacy.sync()).status).toBe(426) + expect((await legacy.send('inbox/delete')).status).toBe(426) + expect((await legacy.connect()).status).toBe(426) + expect((await legacy.putBlob(await photoBlob())).status).toBe(426) + expect((await legacy.getBlob(await photoBlob())).status).toBe(426) + expect((await writer.putCard()).status).toBe(426) + + expect((await owner.sync()).status).toBe(200) + expect((await owner.connect()).status).toBe(101) + expect( + (await writerSend(writer, 'card/put', { blob: blobOfSize(64) }, owner.auth)) + .status + ).toBe(200) + expect((await owner.op('inbox/delete')).status).toBe(200) + }) + + it('off: a legacy register is 426, a bearer one works', async () => { + await legacyOff() + const legacy = await Owner.create(h, false) + expect( + (await legacy.send('inbox/register', { ownerPub: legacy.key.publicKey })) + .status + ).toBe(426) + expect((await BearerOwner.create(false).then((o) => o.register())).status).toBe( + 200 + ) + }) +}) + +describe('the Notification Service Extension’s sync', () => { + const EXTENSION = { [BUDDIES_CLIENT_HEADER]: 'notification-service' } + + /** The extension's fetch: an owner-signed `inbox/sync`, no token. */ + const extensionSync = async ( + owner: { inboxId: string; key: SigningKey }, + headers: Record = EXTENSION, + fields: Record = {}, + op: BuddiesSignedOp = 'inbox/sync' + ) => + h.post( + `/${op}`, + await envelope( + op, + { + inboxId: owner.inboxId, + since: 0, + ts: Date.now(), + nonce: randomId(), + ...fields, + }, + owner.key + ), + headers + ) + + it('off: serves a confirmed inbox’s signed sync, with every check it had', async () => { + const owner = await BearerOwner.create() + const writer = await owner.addWriter() + expect((await writer.putEvent()).status).toBe(200) + await legacyOff() + const nonces = nonceRows(owner.inboxId) + + const served = await extensionSync(owner) + expect(served.status).toBe(200) + expect(served.body.events).toHaveLength(1) + expect(served.body).toEqual((await owner.sync()).body) + // Signed as ever: the nonce is recorded and can't be replayed. + expect(nonceRows(owner.inboxId)).toBe(nonces + 1) + const nonce = randomId() + expect((await extensionSync(owner, EXTENSION, { nonce })).status).toBe(200) + expect(await extensionSync(owner, EXTENSION, { nonce })).toEqual({ + status: 409, + body: relayError('replay'), + }) + expect( + await extensionSync(owner, EXTENSION, { ts: Date.now() - 6 * 60_000 }) + ).toMatchObject({ status: 401, body: { code: 'stale' } }) + const stranger = { inboxId: owner.inboxId, key: await SigningKey.generate() } + expect(await extensionSync(stranger)).toEqual({ + status: 401, + body: relayError('bad_signature'), + }) + expect(await extensionSync({ ...owner, inboxId: randomId() })).toEqual({ + status: 404, + body: relayError('not_found'), + }) + + await setStatus(owner.session.accountId, 'suspended') + expect(await extensionSync(owner)).toEqual({ + status: 403, + body: relayError('suspended'), + }) + await setStatus(owner.session.accountId, 'active') + expect((await extensionSync(owner)).status).toBe(200) + }) + + it('off: still 426 for an inbox without a confirmed link', async () => { + // Registered before accounts: no link at all. + const unlinked = await Owner.create(h) + // Linked in the inbox, but the accounts database never wrote it. + const unconfirmed = await BearerOwner.create(false) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + const failing = failInboxLinks() + expect((await unconfirmed.register()).status).toBe(500) + failing.mockRestore() + // A row from before confirmation. + const old = await BearerOwner.create() + h.inboxes + .storage(old.inboxId) + .query('ALTER TABLE owner_account DROP COLUMN confirmed') + h.inboxes.restart(old.inboxId) + await legacyOff() + + for (const owner of [unlinked, unconfirmed, old]) { + const nonces = nonceRows(owner.inboxId) + expect(await extensionSync(owner)).toEqual({ + status: 426, + body: relayError('upgrade_required'), + }) + expect(nonceRows(owner.inboxId)).toBe(nonces) + } + // The signature comes first, so a stranger learns nothing about links. + const stranger = { inboxId: old.inboxId, key: await SigningKey.generate() } + expect((await extensionSync(stranger)).status).toBe(401) + + // Relinking confirms it, and the extension's fetch works again. + expect((await old.register()).status).toBe(200) + expect((await extensionSync(old)).status).toBe(200) + }) + + it('off: 426 for every other legacy request, header or not', async () => { + const owner = await BearerOwner.create() + const writer = await owner.addWriter() + await legacyOff() + const nonces = nonceRows(owner.inboxId) + + expect((await extensionSync(owner, {})).status).toBe(426) + for (const value of ['Notification-Service', 'notification-services', 'app']) { + expect( + (await extensionSync(owner, { [BUDDIES_CLIENT_HEADER]: value })).status, + value + ).toBe(426) + } + for (const op of BUDDIES_OPS) { + if (op === 'inbox/sync') continue + const response = await h.post(`/${op}`, '{}', EXTENSION) + expect(response, op).toEqual({ + status: 426, + body: relayError('upgrade_required'), + }) + } + expect( + (await extensionSync(owner, EXTENSION, {}, 'inbox/delete')).status + ).toBe(426) + expect( + ( + await h.post( + '/event/put', + await envelope( + 'event/put', + { + inboxId: writer.inboxId, + slotId: writer.slotId, + eventId: randomId(), + kind: 'plan.joined', + blob: blobOfSize(48), + push: false, + ts: Date.now(), + nonce: randomId(), + }, + writer.key + ), + EXTENSION + ) + ).status + ).toBe(426) + const legacy = await Owner.create(h, false) + Object.assign(legacy, { inboxId: owner.inboxId, key: owner.key }) + expect( + ( + await h.live({ + ...(await liveHeaders( + { inboxId: owner.inboxId, ts: Date.now(), nonce: randomId() }, + owner.key + )), + ...EXTENSION, + }) + ).status + ).toBe(426) + expect( + (await legacy.putBlob(await photoBlob(), {}, { headers: EXTENSION })) + .status + ).toBe(426) + expect(nonceRows(owner.inboxId)).toBe(nonces) + }) + + it('on: the header changes nothing, legacy or bearer', async () => { + const unlinked = await Owner.create(h) + expect((await extensionSync(unlinked)).status).toBe(200) + expect((await extensionSync(unlinked, {})).status).toBe(200) + + const owner = await BearerOwner.create() + expect((await extensionSync(owner)).status).toBe(200) + expect((await owner.sync({ ...owner.auth, ...EXTENSION })).status).toBe(200) + await legacyOff() + expect((await owner.sync({ ...owner.auth, ...EXTENSION })).status).toBe(200) + }) +}) + +describe('per-account limits', () => { + it('keys bearer requests on a hash of the account, not the client IP', async () => { + await setUp({ unsignedLimit: 2 }) + const first = await session() + const second = await session() + const fetch = (headers: Record, ip = IP) => + h.post('/invite/fetch', unsignedEnvelope({ inviteId: randomId() }), { + 'cf-connecting-ip': ip, + ...headers, + }) + + expect((await fetch(bearer(first.accessToken))).status).toBe(404) + expect((await fetch(bearer(first.accessToken), '198.51.100.9')).status).toBe( + 404 + ) + // The account's budget is spent, from any address. + expect(await fetch(bearer(first.accessToken))).toEqual({ + status: 429, + body: relayError('rate_limited'), + }) + // Another account behind the same address has its own. + expect((await fetch(bearer(second.accessToken))).status).toBe(404) + // So does a legacy request from that address. + expect((await fetch({})).status).toBe(404) + + const accountKeys = h.rateLimitKeys.filter((key) => key.startsWith('account:')) + expect(new Set(accountKeys).size).toBe(2) + for (const key of h.rateLimitKeys) { + expect(key).not.toContain(first.accountId) + expect(key).not.toContain(second.accountId) + } + }) + + it('counts bearer registrations against the account’s daily quota', async () => { + const owner = await BearerOwner.create(false) + const names = vi.spyOn(h.env.BUDDY_REGISTRATION_QUOTA, 'idFromName') + expect( + (await owner.register({}, { ...owner.auth, 'cf-connecting-ip': '198.51.100.1' })) + .status + ).toBe(200) + expect( + (await owner.register({}, { ...owner.auth, 'cf-connecting-ip': '198.51.100.2' })) + .status + ).toBe(200) + await Owner.create(h) + const [bearerA, bearerB, legacy] = names.mock.calls.map(([name]) => name) + expect(bearerA).toBe(bearerB) + expect(legacy).not.toBe(bearerA) + expect(bearerA).not.toContain(owner.session.accountId) + }) +}) diff --git a/src/e2e/accounts.e2e.test.ts b/src/e2e/accounts.e2e.test.ts index 4eec027..44ed0da 100644 --- a/src/e2e/accounts.e2e.test.ts +++ b/src/e2e/accounts.e2e.test.ts @@ -7,11 +7,22 @@ import { randomId, sha256B64u, } from '../test/buddiesClient' -import { ADMIN_TOKEN, http, httpBytes, writeTranscript } from '../test/e2e' +import { + ADMIN_TOKEN, + DEV_BYPASS_TOKEN, + LOCAL_LAUNCHER, + http, + httpBytes, + localKv, + writeTranscript, +} from '../test/e2e' /** - * Anonymous accounts: register, read the account back with account/info, move - * the key through a handoff channel, and back up to R2. + * Anonymous accounts: open an attested session (the dev bypass), read the + * account with GET /account, refresh, and close it; register and read the + * account back with the legacy account/info, move the key through a handoff + * channel, and back up to R2, both ways; and the legacy switch refusing the + * envelope forms with 426. */ afterAll(() => { @@ -50,6 +61,31 @@ const send = async ( body: await signedEnvelope(op, key, fields, signer), }) +interface Session { + key: SigningKey + accountId: string + sessionId: string + accessToken: string + refreshToken: string + accessExpiresAt: number + sessionExpiresAt: number +} + +const openSession = async (key?: SigningKey): Promise => { + const signingKey = key ?? (await SigningKey.generate()) + const res = await http('POST', '/accounts/v1/sessions', { + headers: { 'x-ww-dev-bypass': DEV_BYPASS_TOKEN ?? '' }, + body: await signedEnvelope('session/open', signingKey), + }) + expect(res.status).toBe(201) + expect(res.headers.get('location')).toBe('/accounts/v1/sessions/current') + return { key: signingKey, ...(res.body as Omit) } +} + +const bearer = (session: Session) => ({ + authorization: `Bearer ${session.accessToken}`, +}) + const registered = async () => { const key = await SigningKey.generate() expect((await send('account/register', key)).status).toBe(200) @@ -226,6 +262,238 @@ describe('POST /accounts/v1/backup/*', () => { }) }) +describe.skipIf(!DEV_BYPASS_TOKEN)('/accounts/v1/sessions (dev bypass)', () => { + it('opens a session, reads the account with it, refreshes, and closes', async () => { + const session = await openSession() + expect(session).toMatchObject({ + accountId: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), + sessionId: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), + accessToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/), + refreshToken: expect.stringMatching( + /^[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\.[A-Za-z0-9_-]{43}$/ + ), + }) + expect(session.sessionExpiresAt - session.accessExpiresAt).toBeGreaterThan(89 * 24 * 3_600_000) + + const info = await http('GET', '/accounts/v1/account', { headers: bearer(session) }) + expect(info.status).toBe(200) + expect(info.body).toMatchObject({ + ok: true, + accountId: session.accountId, + accountPub: session.key.publicKey, + status: 'active', + }) + // The legacy path answers bearer requests the same way. + const legacyInfo = await http('POST', '/accounts/v1/account/info', { + headers: bearer(session), + }) + expect(legacyInfo.body).toEqual(info.body) + + const refreshed = await http('POST', '/accounts/v1/sessions/refresh', { + body: { refreshToken: session.refreshToken }, + }) + expect(refreshed.status).toBe(200) + expect(refreshed.body).toMatchObject({ + ok: true, + sessionExpiresAt: session.sessionExpiresAt, + }) + expect(refreshed.body.refreshToken).not.toBe(session.refreshToken) + const next = { ...session, ...(refreshed.body as Partial) } + expect( + (await http('GET', '/accounts/v1/account', { headers: bearer(next) })).body.accountId + ).toBe(session.accountId) + + const closed = await http('DELETE', '/accounts/v1/sessions/current', { headers: bearer(next) }) + expect(closed.status).toBe(200) + expect(closed.body).toEqual({ ok: true }) + // Idempotent while the access token lives. + expect( + (await http('DELETE', '/accounts/v1/sessions/current', { headers: bearer(next) })).status + ).toBe(200) + const after = await http('POST', '/accounts/v1/sessions/refresh', { + body: { refreshToken: next.refreshToken }, + }) + expect(after.status).toBe(401) + expect(after.body.code).toBe('unauthorized') + expect(after.headers.get('www-authenticate')).toBe('Bearer error="invalid_token"') + }) + + it('ends the session when an older refresh token comes back', async () => { + const refresh = (refreshToken: string) => + http('POST', '/accounts/v1/sessions/refresh', { body: { refreshToken } }) + const session = await openSession() + const first = await refresh(session.refreshToken) + expect(first.status).toBe(200) + const second = await refresh(first.body.refreshToken as string) + expect(second.status).toBe(200) + + // Two tokens old: refused, and the session ends with it. + const reused = await refresh(session.refreshToken) + expect(reused.status).toBe(401) + expect(reused.body.code).toBe('unauthorized') + expect((await refresh(second.body.refreshToken as string)).status).toBe(401) + + // Malformed tokens are refused alike. + for (const token of ['', 'not-a-token', b64u(randomBytes(32))]) + expect((await refresh(token)).status).toBe(401) + }) + + it('never ends a session for a refresh token it didn’t issue', async () => { + const refresh = (refreshToken: string) => + http('POST', '/accounts/v1/sessions/refresh', { body: { refreshToken } }) + const session = await openSession() + const [sessionId, secret, mac] = session.refreshToken.split('.') + // The session id is no secret (every access token's `sid`); without the + // worker's MAC, a token naming it is refused and the session lives. + for (const token of [ + `${sessionId}.${b64u(randomBytes(32))}.${b64u(randomBytes(32))}`, + `${sessionId}.${b64u(randomBytes(32))}.${mac}`, + // Two-part tokens from before the MAC. + `${sessionId}.${secret}`, + `${sessionId}.${b64u(randomBytes(32))}`, + ]) { + const refused = await refresh(token) + expect(refused.status).toBe(401) + expect(refused.body.code).toBe('unauthorized') + } + const next = await refresh(session.refreshToken) + expect(next.status).toBe(200) + expect((await refresh(next.body.refreshToken as string)).status).toBe(200) + }) + + it('gives a double refresh and a lost-response retry the same token', async () => { + const refresh = (refreshToken: string) => + http('POST', '/accounts/v1/sessions/refresh', { body: { refreshToken } }) + const session = await openSession() + const [one, two] = await Promise.all([ + refresh(session.refreshToken), + refresh(session.refreshToken), + ]) + expect(one.status).toBe(200) + expect(two.status).toBe(200) + expect(two.body.refreshToken).toBe(one.body.refreshToken) + // A retry within the minute, as after a lost response, gets it again. + const retried = await refresh(session.refreshToken) + expect(retried.body.refreshToken).toBe(one.body.refreshToken) + + // Whichever answer the device kept, the session goes on. + const next = await refresh(one.body.refreshToken as string) + expect(next.status).toBe(200) + const info = await http('GET', '/accounts/v1/account', { + headers: { authorization: `Bearer ${next.body.accessToken}` }, + }) + expect(info.body.accountId).toBe(session.accountId) + }) + + it('refuses a bad bearer token without trying the envelope', async () => { + const key = await registered() + const res = await http('POST', '/accounts/v1/account/info', { + headers: { authorization: 'Bearer not.a.token' }, + body: await signedEnvelope('account/info', key), + }) + expect(res.status).toBe(401) + expect(res.body.code).toBe('unauthorized') + }) + + it('backs up and hands off with a bearer token', async () => { + const session = await openSession() + const slotId = randomId() + const bytes = randomBytes(8 * 1_024) + const stored = await httpBytes('/accounts/v1/backup/put', { + bytes, + headers: { + ...bearer(session), + 'x-ww-slot-id': slotId, + 'x-ww-sha256': await sha256B64u(bytes), + }, + }) + expect(stored.status).toBe(200) + const got = await httpBytes('/accounts/v1/backup/get', { + json: { slotId }, + headers: bearer(session), + }) + expect(got.bytes).toEqual(bytes) + // A token alone can't delete; the account key signs it too. + expect( + (await http('POST', '/accounts/v1/backup/delete', { headers: bearer(session), body: { slotId } })) + .status + ).toBe(400) + const deleted = await http('POST', '/accounts/v1/backup/delete', { + headers: bearer(session), + body: await signedEnvelope('backup/delete', session.key, { slotId }), + }) + expect(deleted.status).toBe(200) + const listed = await http('POST', '/accounts/v1/backup/list', { headers: bearer(session) }) + expect(listed.body).toEqual({ ok: true, slots: [] }) + + const token = randomBytes(16) + const channelId = randomId() + const opened = await http('POST', '/accounts/v1/handoff/open', { + headers: bearer(session), + body: { channelId, verifier: await sha256B64u(token) }, + }) + expect(opened.status).toBe(200) + const status = await http('POST', '/accounts/v1/handoff/status', { + headers: bearer(session), + body: { channelId }, + }) + expect(status.body).toMatchObject({ ok: true, status: 'open' }) + const cancelled = await http('POST', '/accounts/v1/handoff/cancel', { + headers: bearer(session), + body: { channelId }, + }) + expect(cancelled.status).toBe(200) + }) +}) + +describe.skipIf(!LOCAL_LAUNCHER || !DEV_BYPASS_TOKEN)('the legacy switch (local KV)', () => { + it('refuses envelope-authenticated requests with 426 while bearer ones work', async () => { + const key = await registered() + const session = await openSession(key) + try { + localKv('put', 'auth:legacy', 'off') + const info = await send('account/info', key) + expect(info.status).toBe(426) + expect(info.body).toEqual({ ok: false, error: 'upgrade_required', code: 'upgrade_required' }) + expect(info.headers.get('retry-after')).toBeNull() + expect((await send('account/register', await SigningKey.generate())).status).toBe(426) + expect((await send('backup/list', key)).status).toBe(426) + + const viaToken = await http('GET', '/accounts/v1/account', { headers: bearer(session) }) + expect(viaToken.status).toBe(200) + expect(viaToken.body.accountId).toBe(session.accountId) + const legacyPath = await http('POST', '/accounts/v1/account/info', { + headers: bearer(session), + }) + expect(legacyPath.status).toBe(200) + // Sessions still open (they replace account/register). + await openSession() + } finally { + localKv('delete', 'auth:legacy') + } + expect((await send('account/info', key)).status).toBe(200) + }) +}) + +describe('GET /app/v1/config', () => { + it('answers without authentication', async () => { + const res = await http('GET', '/app/v1/config') + expect(res.status).toBe(200) + expect(res.headers.get('cache-control')).toBe('no-store') + expect(res.body.ok).toBe(true) + }) + + it.skipIf(!LOCAL_LAUNCHER)('serves KV app:min-version', async () => { + try { + localKv('put', 'app:min-version', '{"minVersion":"9.8.7"}') + expect((await http('GET', '/app/v1/config')).body).toEqual({ ok: true, minAppVersion: '9.8.7' }) + } finally { + localKv('delete', 'app:min-version') + } + expect((await http('GET', '/app/v1/config')).body).toEqual({ ok: true }) + }) +}) + describe('GET /accounts/handoff', () => { it.each(['/accounts/handoff', '/h'])('%s serves the scan-from-the-app page', async (path) => { const res = await http('GET', path) diff --git a/src/e2e/buddiesSessions.e2e.test.ts b/src/e2e/buddiesSessions.e2e.test.ts new file mode 100644 index 0000000..451a91b --- /dev/null +++ b/src/e2e/buddiesSessions.e2e.test.ts @@ -0,0 +1,319 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { + blobOfSize, + envelope, + photoBlob, + randomBytes, + randomId, + relayError, + unsignedEnvelope, + b64u, + type SigningKey, +} from '../test/buddiesClient' +import { + BearerRelayOwner, + DEV_BYPASS_TOKEN, + LOCAL_LAUNCHER, + RelayOwner, + bearerHeaders, + buddies, + fieldsHeader, + getBlob, + http, + localKv, + type Exchange, + openLive, + openSession, + sendWriter, + writeTranscript, +} from '../test/e2e' +import { BUDDIES_ABUSE_LIMITS } from '../buddies/limits' + +/** + * The Buddies relay on account sessions (witness-work ADR 0027): sessions + * opened through the dev bypass, then a bearer round trip (register with a + * proof → slot/add → a buddy's event/put → owner sync → blob put/get → the + * live socket's hello, changed, and pong), the account checks + * (`not_linked`, `forbidden`), a bad token, and the legacy switch refusing + * legacy requests with 426 while bearer ones work, except the iOS + * Notification Service Extension's signed sync for a confirmed inbox. + */ + +afterAll(() => { + writeTranscript('buddies-sessions') +}) + +const DAY_MS = 24 * 60 * 60 * 1000 + +const inviteSecrets = async () => { + const claimSecret = randomBytes(32) + const verifier = new Uint8Array(await crypto.subtle.digest('SHA-256', claimSecret)) + return { inviteId: randomId(), claimSecret: b64u(claimSecret), claimVerifier: b64u(verifier) } +} + +describe.skipIf(!DEV_BYPASS_TOKEN)('Buddies with bearer tokens (dev bypass)', () => { + it('round-trips a relay session: register, slot, buddy event, sync, photo, live socket', async () => { + const owner = await BearerRelayOwner.create(undefined, false) + const registered = await owner.register() + expect(registered.status).toBe(200) + expect(registered.body).toEqual({ ok: true, accountId: owner.session.accountId }) + + const buddy = await openSession() + const writer = await owner.addWriter() + + const attempt = await openLive(owner.liveHeaders()) + expect(attempt.status).toBe(101) + if (attempt.status !== 101 || !('connection' in attempt)) return + const live = attempt.connection + expect(JSON.parse(await live.next())).toEqual({ type: 'hello', seq: 0 }) + + // The buddy writes with its own token plus the writer-signed envelope. + const eventId = randomId() + const put = await sendWriter( + writer, + 'event/put', + { eventId, kind: 'plan.invite', blob: blobOfSize(48), push: false }, + bearerHeaders(buddy) + ) + expect(put.status).toBe(200) + expect(put.body).toEqual({ ok: true, seq: 1 }) + expect(JSON.parse(await live.next())).toEqual({ type: 'changed', seq: 1 }) + + const synced = await owner.sync(0) + expect(synced.status).toBe(200) + expect(synced.body).toMatchObject({ + ok: true, + seq: 1, + slots: [{ slotId: writer.slotId }], + events: [{ eventId, slotId: writer.slotId, kind: 'plan.invite' }], + capabilities: { photos: true }, + }) + // Repeating a bearer request is no replay: there is no nonce. + expect((await owner.sync(1)).body).toMatchObject({ ok: true, events: [] }) + expect((await owner.sync(1)).status).toBe(200) + + const photo = await photoBlob(64 * 1_024) + const expiresAt = Date.now() + 7 * DAY_MS + const stored = await owner.putBlob(photo, owner.auth, { expiresAt }) + expect(stored.status).toBe(200) + expect(stored.body).toEqual({ ok: true, expiresAt }) + const read = await getBlob( + { inboxId: owner.inboxId, blobId: photo.blobId, token: photo.token }, + bearerHeaders(buddy) + ) + expect(read.status).toBe(200) + expect(read.bytes).toEqual(photo.bytes) + + live.send('ping') + expect(await live.next()).toBe('pong') + + // An invite, fetched and claimed by the buddy with its token. + const invite = await inviteSecrets() + expect( + ( + await owner.op('invite/create', { + inviteId: invite.inviteId, + claimVerifier: invite.claimVerifier, + blob: blobOfSize(100), + expiresAt: Date.now() + DAY_MS, + }) + ).status + ).toBe(200) + const fetched = await http('POST', '/buddies/v1/invite/fetch', { + headers: bearerHeaders(buddy), + body: unsignedEnvelope({ inviteId: invite.inviteId }), + }) + expect(fetched.body).toMatchObject({ ok: true, status: 'open' }) + const claimed = await http('POST', '/buddies/v1/invite/claim', { + headers: bearerHeaders(buddy), + body: unsignedEnvelope({ + inviteId: invite.inviteId, + claimSecret: invite.claimSecret, + blob: blobOfSize(80), + }), + }) + expect(claimed.body).toEqual({ ok: true }) + expect(JSON.parse(await live.next())).toEqual({ type: 'changed', seq: 2 }) + + expect((await owner.op('blob/delete', { blobIds: [photo.blobId] })).body).toEqual({ ok: true }) + expect((await owner.op('inbox/delete')).body).toEqual({ ok: true }) + expect(await live.closed).toEqual({ code: 4001, reason: 'gone' }) + live.end() + }) + + it('answers not_linked for an inbox registered before accounts, until it links', async () => { + const legacy = await RelayOwner.register() + const owner = BearerRelayOwner.adopt(legacy, await openSession()) + expect(await owner.sync()).toMatchObject({ status: 409, body: relayError('not_linked') }) + expect(await openLive(owner.liveHeaders())).toEqual({ + status: 409, + body: relayError('not_linked'), + }) + const linked = await owner.register() + expect(linked.body).toEqual({ ok: true, accountId: owner.session.accountId }) + expect((await owner.sync()).status).toBe(200) + await owner.op('inbox/delete') + }) + + it("refuses another account's token on an inbox with forbidden", async () => { + const owner = await BearerRelayOwner.create() + await owner.addWriter() + const stranger = bearerHeaders(await openSession()) + expect(await owner.sync(0, stranger)).toMatchObject({ status: 403, body: relayError('forbidden') }) + expect(await openLive(owner.liveHeaders(stranger))).toEqual({ + status: 403, + body: relayError('forbidden'), + }) + const refused = await owner.putBlob(await photoBlob(512), stranger) + expect(refused.status).toBe(403) + expect(refused.body).toEqual(relayError('forbidden')) + // A proof for another account than the token's. + const other = await openSession() + expect( + await owner.register({ account: await owner.proof(other) }) + ).toMatchObject({ status: 403, body: relayError('forbidden') }) + expect((await owner.sync()).status).toBe(200) + await owner.op('inbox/delete') + }) + + it('refuses a bad token with 401 and a challenge, without trying the envelope', async () => { + const owner = await BearerRelayOwner.create() + const bad = await owner.sync(0, { authorization: 'Bearer expired.or.forged' }) + expect(bad.status).toBe(401) + expect(bad.body).toEqual(relayError('unauthorized')) + expect(bad.headers.get('www-authenticate')).toBe('Bearer error="invalid_token"') + // A bearer owner request is plain JSON, never an envelope. + const enveloped = await buddies('inbox/sync', { payload: fieldsHeader({ inboxId: owner.inboxId, since: 0 }) }) + expect(enveloped.status).toBe(400) + const withEnvelope = await http('POST', '/buddies/v1/inbox/sync', { + headers: owner.auth, + body: { payload: fieldsHeader({ inboxId: owner.inboxId, since: 0 }), signature: 'x' }, + }) + expect(withEnvelope.status).toBe(400) + expect(withEnvelope.body).toEqual(relayError('bad_request')) + // A signature header on a bearer upgrade is a mistake, not a fallback. + expect( + await openLive({ ...owner.liveHeaders(), 'x-buddies-signature': 'x' }) + ).toEqual({ status: 400, body: relayError('bad_request') }) + await owner.op('inbox/delete') + }) +}) + +describe.skipIf(!LOCAL_LAUNCHER || !DEV_BYPASS_TOKEN)('the legacy switch on Buddies (local KV)', () => { + it('refuses every legacy request with 426, leaving included, while bearer requests work', async () => { + const legacy = await RelayOwner.register() + const legacyWriter = await legacy.addWriter() + const owner = await BearerRelayOwner.create() + const writer = await owner.addWriter() + try { + localKv('put', 'auth:legacy', 'off') + const refused = await legacy.sync() + expect(refused.status).toBe(426) + expect(refused.body).toEqual(relayError('upgrade_required')) + expect(refused.headers.get('retry-after')).toBeNull() + expect((await legacy.send('inbox/delete')).status).toBe(426) + expect((await legacy.send('slot/remove', { slotId: legacyWriter.slotId })).status).toBe(426) + expect((await legacyWriter.send('slot/leave')).status).toBe(426) + expect((await legacy.send('inbox/register', { ownerPub: legacy.key.publicKey })).status).toBe(426) + expect((await buddies('invite/fetch', unsignedEnvelope({ inviteId: randomId() }))).status).toBe(426) + expect(await openLive(await legacy.liveHeaders())).toEqual({ + status: 426, + body: relayError('upgrade_required'), + }) + expect((await legacy.putBlob(await photoBlob(512))).status).toBe(426) + expect((await legacy.getBlob(await photoBlob(512))).status).toBe(426) + + expect((await owner.sync()).status).toBe(200) + expect( + (await sendWriter(writer, 'card/put', { blob: blobOfSize(64) }, owner.auth)).status + ).toBe(200) + const attempt = await openLive(owner.liveHeaders()) + expect(attempt.status).toBe(101) + if ('connection' in attempt) attempt.connection.end() + expect((await owner.op('inbox/delete')).status).toBe(200) + } finally { + localKv('delete', 'auth:legacy') + } + expect((await legacy.send('inbox/delete')).status).toBe(200) + }) + + it('still serves the Notification Service Extension’s signed sync for a confirmed inbox', async () => { + const extension = { 'x-buddies-client': 'notification-service' } + const extensionSync = async ( + who: { inboxId: string; key: SigningKey }, + headers: Record = extension, + op = 'inbox/sync' + ) => + http('POST', `/buddies/v1/${op}`, { + body: await envelope( + op, + { inboxId: who.inboxId, since: 0, ts: Date.now(), nonce: randomId() }, + who.key + ), + headers, + }) + const owner = await BearerRelayOwner.create() + const writer = await owner.addWriter() + const unlinked = await RelayOwner.register() + try { + localKv('put', 'auth:legacy', 'off') + const served = await extensionSync(owner) + expect(served.status).toBe(200) + expect(served.body).toMatchObject({ ok: true, seq: 0 }) + expect((served.body as { slots: unknown[] }).slots).toEqual([ + expect.objectContaining({ slotId: writer.slotId }), + ]) + + expect(await extensionSync(owner, {})).toMatchObject({ + status: 426, + body: relayError('upgrade_required'), + }) + expect(await extensionSync(unlinked)).toMatchObject({ + status: 426, + body: relayError('upgrade_required'), + }) + expect((await extensionSync(owner, extension, 'inbox/delete')).status).toBe(426) + expect((await owner.sync()).status).toBe(200) + } finally { + localKv('delete', 'auth:legacy') + } + expect((await owner.op('inbox/delete')).status).toBe(200) + expect((await unlinked.send('inbox/delete')).status).toBe(200) + }) +}) + +describe.skipIf(!LOCAL_LAUNCHER || !DEV_BYPASS_TOKEN)('per-account limits (local caller IPs)', () => { + /** + * Up to `2 × limit + 1` requests; the first `limit` always pass, and a 429 + * must come by the end even if a minute boundary falls mid-burst. + */ + const burst = async (limit: number, send: () => Promise) => { + const statuses: number[] = [] + for (let i = 0; i < 2 * limit + 1; i++) { + const res = await send() + statuses.push(res.status) + if (res.status === 429) return { statuses, refused: res } + } + return { statuses, refused: null } + } + + it('counts a bearer caller by its account, from any address, apart from others on the same one', async () => { + const limit = BUDDIES_ABUSE_LIMITS.edge.unsigned.perMinute + const [first, second] = [await openSession(), await openSession()] + const fetchAs = (headers: Record, ip?: string) => + http('POST', '/buddies/v1/invite/fetch', { + body: unsignedEnvelope({ inviteId: randomId() }), + headers: { ...headers, ...(ip ? { 'cf-connecting-ip': ip } : {}) }, + }) + // Every request comes from a fresh address (the helper rotates them). + const { statuses, refused } = await burst(limit, () => fetchAs(bearerHeaders(first))) + expect(statuses.slice(0, limit).every((s) => s === 404)).toBe(true) + expect(refused?.body).toEqual(relayError('rate_limited')) + expect(refused?.headers.get('retry-after')).toBe('60') + // Another account, and a legacy caller, from the address that was refused last. + const shared = '198.18.250.250' + expect((await fetchAs(bearerHeaders(first), shared)).status).toBe(429) + expect((await fetchAs(bearerHeaders(second), shared)).status).toBe(404) + expect((await fetchAs({}, shared)).status).toBe(404) + }, 60_000) +}) diff --git a/src/e2e/notesImportAccounts.e2e.test.ts b/src/e2e/notesImportAccounts.e2e.test.ts new file mode 100644 index 0000000..da36bc1 --- /dev/null +++ b/src/e2e/notesImportAccounts.e2e.test.ts @@ -0,0 +1,223 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { accountSignedMessage } from '../accounts/contracts' +import { SigningKey, b64u, randomId } from '../test/buddiesClient' +import { + DEV_BYPASS_TOKEN, + LOCAL_LAUNCHER, + configured, + http, + localKv, + writeTranscript, +} from '../test/e2e' + +/** + * Notes Import on account sessions: a dev-bypass session's bearer token runs + * kickoff → SSE events → result → destroy, another account gets 404 for that + * import, empty notes are a 400, and legacy requests get 426 once the legacy + * switch is off. + * + * The lifecycle runs only against the local launcher without an OpenRouter + * key, where the run fails at the model call (free) and still walks every + * route; with a key it would spend, so it needs WW_API_E2E_ALLOW_PAID=1. + */ + +afterAll(() => { + writeTranscript('notes-import-accounts') +}) + +const encoder = new TextEncoder() +const CONTEXT = { + now: '2026-10-15T08:00:00-04:00', + timeZone: 'America/New_York', + existingContacts: [], + existingCategories: [], +} + +interface Session { + accountId: string + accessToken: string +} + +/** Opens a session with the dev bypass in place of attestation. */ +const openSession = async (): Promise => { + const key = await SigningKey.generate() + const bytes = encoder.encode( + JSON.stringify({ accountPub: key.publicKey, ts: Date.now(), nonce: randomId() }) + ) + const res = await http('POST', '/accounts/v1/sessions', { + headers: { 'x-ww-dev-bypass': DEV_BYPASS_TOKEN ?? '' }, + body: { + payload: b64u(bytes), + signature: b64u(await key.sign(accountSignedMessage('session/open', bytes))), + }, + }) + expect(res.status).toBe(201) + return res.body +} + +const bearer = (session: Session) => ({ + authorization: `Bearer ${session.accessToken}`, +}) + +const runRoute = (importId: string, op: string, session: Session) => + http(op === 'events' || op === 'result' ? 'GET' : 'POST', `/notes-import/${importId}/${op}`, { + headers: bearer(session), + }) + +const TERMINAL = ['done', 'error', 'cancelled'] + +const waitForTerminal = async (importId: string, session: Session) => { + let result = await runRoute(importId, 'result', session) + const deadline = Date.now() + 25_000 + while (!TERMINAL.includes(result.body.status) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1_000)) + result = await runRoute(importId, 'result', session) + } + return result +} + +const freeRun = + (LOCAL_LAUNCHER && !configured('OPENROUTER_API_KEY')) || + process.env.WW_API_E2E_ALLOW_PAID === '1' + +describe.skipIf(!DEV_BYPASS_TOKEN)('Notes Import with a bearer token', () => { + it('refuses a bad token with 401 and never falls back to attestation', async () => { + const res = await http('POST', '/notes-import/kickoff', { + headers: { + authorization: 'Bearer not.a.token', + 'x-ww-dev-bypass': DEV_BYPASS_TOKEN ?? '', + }, + body: { uuid: 'E5F6A7B8-0000-4000-8000-000000000004', notesText: 'x', context: CONTEXT }, + }) + expect(res.status).toBe(401) + expect(res.body).toMatchObject({ ok: false, code: 'unauthorized' }) + expect(typeof res.body.error).toBe('string') + expect(res.headers.get('www-authenticate')).toBe('Bearer error="invalid_token"') + }) + + it('refuses empty or whitespace-only notes with 400 bad_request, bearer and legacy', async () => { + const session = await openSession() + for (const notesText of ['', ' \n\t ']) { + const refused = [ + await http('POST', '/notes-import/kickoff', { + headers: bearer(session), + body: { notesText, context: CONTEXT }, + }), + await http('POST', '/notes-import', { + headers: bearer(session), + body: { notesText, context: CONTEXT }, + }), + await http('POST', '/notes-import/kickoff', { + headers: { 'x-ww-dev-bypass': DEV_BYPASS_TOKEN ?? '' }, + body: { uuid: 'A7B8C9D0-0000-4000-8000-000000000006', notesText, context: CONTEXT }, + }), + ] + for (const res of refused) { + expect(res.status).toBe(400) + expect(res.body).toEqual({ ok: false, error: 'notesText is empty', code: 'bad_request' }) + } + } + }) + + it('answers an unknown import with 404 not_found', async () => { + const session = await openSession() + const res = await runRoute(`imp_${'0'.repeat(40)}`, 'result', session) + expect(res.status).toBe(404) + expect(res.body).toEqual({ ok: false, error: 'Import not found', code: 'not_found' }) + }) + + it.skipIf(!freeRun)( + 'kickoff → events → result → destroy, and another account gets 404', + { timeout: 60_000 }, + async () => { + const owner = await openSession() + const stranger = await openSession() + const body = { notesText: `Field service 1 hour ${randomId()}`, context: CONTEXT } + const kickoff = await http('POST', '/notes-import/kickoff', { + headers: bearer(owner), + body, + }) + expect(kickoff.status).toBe(200) + expect(Object.keys(kickoff.body).sort()).toEqual(['credits', 'importId', 'refinement']) + expect(kickoff.body.importId).toMatch(/^imp_[a-f0-9]{40}$/) + // A dev session gets the bypass's unlimited allowances. + expect(kickoff.body.credits).toMatchObject({ limit: null, isSupporter: false }) + const { importId } = kickoff.body + + const result = await waitForTerminal(importId, owner) + expect(result.status).toBe(200) + expect(TERMINAL).toContain(result.body.status) + + const events = await runRoute(importId, 'events', owner) + expect(events.status).toBe(200) + expect(events.headers.get('content-type')).toContain('text/event-stream') + expect(events.text).toMatch(/event: (done|error)\n/) + + for (const op of ['events', 'result', 'cancel', 'destroy']) { + const foreign = await runRoute(importId, op, stranger) + expect(foreign.status, op).toBe(404) + expect(foreign.body, op).toEqual({ ok: false, error: 'Import not found', code: 'not_found' }) + } + // The stranger's destroy touched nothing. + expect((await runRoute(importId, 'result', owner)).body.status).toBe(result.body.status) + + const destroyed = await runRoute(importId, 'destroy', owner) + expect(destroyed.status).toBe(200) + expect(destroyed.body).toEqual({ ok: true }) + expect((await runRoute(importId, 'result', owner)).status).toBe(404) + + // The same notes again: the same run object, recreated after deleteAll(). + const again = await http('POST', '/notes-import/kickoff', { + headers: bearer(owner), + body, + }) + expect(again.status).toBe(200) + expect(again.body.importId).toBe(importId) + expect(TERMINAL).toContain((await waitForTerminal(importId, owner)).body.status) + await runRoute(importId, 'destroy', owner) + } + ) +}) + +describe.skipIf(!LOCAL_LAUNCHER || !DEV_BYPASS_TOKEN)('the legacy switch (local KV)', () => { + it('refuses legacy Notes Import requests with 426 while bearer ones work', async () => { + const session = await openSession() + const legacyKickoff = { + uuid: 'F6A7B8C9-0000-4000-8000-000000000005', + notesText: 'Field service 1 hour', + context: CONTEXT, + } + try { + localKv('put', 'auth:legacy', 'off') + const refused = [ + await http('POST', '/notes-import/kickoff', { + headers: { 'x-ww-dev-bypass': DEV_BYPASS_TOKEN ?? '' }, + body: legacyKickoff, + }), + await http('POST', '/notes-import/transcribe', { + headers: { 'x-ww-dev-bypass': DEV_BYPASS_TOKEN ?? '' }, + body: { uuid: legacyKickoff.uuid, image: '/9j/4AAQ', mimeType: 'image/jpeg' }, + }), + await http('GET', `/notes-import/imp_${'0'.repeat(40)}/result?token=anything`), + ] + for (const res of refused) { + expect(res.status).toBe(426) + expect(res.body).toEqual({ + ok: false, + error: 'Update WitnessWork to keep using Notes Import', + code: 'upgrade_required', + }) + expect(res.headers.get('retry-after')).toBeNull() + } + // Bearer requests carry on (no inference: an unknown import). + const viaToken = await runRoute(`imp_${'0'.repeat(40)}`, 'result', session) + expect(viaToken.status).toBe(404) + // Status stays public. + expect((await http('GET', '/notes-import/status')).status).toBe(200) + } finally { + localKv('delete', 'auth:legacy') + } + const back = await http('GET', `/notes-import/imp_${'0'.repeat(40)}/result?token=anything`) + expect(back.status).toBe(401) + }) +}) diff --git a/src/index.test.ts b/src/index.test.ts index 19643a2..2ca19ab 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Environment } from './types' import { RATE_LIMIT_PERIOD_SECONDS, rateLimitKey } from './rateLimit' +import { makeMemoryD1 } from './test/memoryD1' +import { makeMemoryKv } from './test/memoryKv' vi.mock('cloudflare:workers', () => ({ DurableObject: class {} })) @@ -134,3 +136,34 @@ describe('per-IP rate limits', () => { ) }) }) + +describe('the hourly cron', () => { + it('deletes account sessions that ended over a week ago', async () => { + here.fetch = vi.fn(async () => new Response(null, { status: 404 })) + const db = makeMemoryD1() + db.db.exec(`INSERT INTO accounts (id, account_pub, status, created_at) + VALUES ('account', 'pub', 'active', 0)`) + const insert = db.db.prepare( + `INSERT INTO account_sessions + (id, account_id, platform, refresh_hash, created_at, expires_at, revoked_at) + VALUES (?, 'account', 'ios', ?, 0, ?, ?)` + ) + const week = 7 * 24 * 60 * 60 * 1000 + insert.run('live', 'h1', NOW + week, null) + insert.run('revoked', 'h2', NOW + week, NOW - week) + insert.run('expired', 'h3', NOW - week, null) + insert.run('recently-ended', 'h4', NOW + week, NOW - 1) + const pending: Promise[] = [] + await worker.scheduled!( + { scheduledTime: NOW, cron: '17 * * * *', noRetry: () => {} } as ScheduledController, + { ...makeEnv(), NOTES_KV: makeMemoryKv(), ACCOUNTS_DB: db } as never, + { waitUntil: (promise: Promise) => pending.push(promise) } as never + ) + await Promise.all(pending) + const ids = db.db + .prepare('SELECT id FROM account_sessions ORDER BY id') + .all() + .map((row) => row.id) + expect(ids).toEqual(['live', 'recently-ended']) + }) +}) diff --git a/src/index.ts b/src/index.ts index 8380e2e..bf924dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,20 @@ import type { Environment } from "./types"; import { runRatingsSweepStep } from "./appStoreRatings/ratings"; -import { Sentry, createSentryConfig } from "./sentry"; +import { Sentry, captureFailure, createSentryConfig } from "./sentry"; import { app } from "./app"; +import { accountsDb } from "./accounts/store"; +import { pruneEndedSessions } from "./accounts/sessionStore"; + +/** Deletes account sessions that ended over a week ago; failures only report. */ +async function pruneAccountSessions(environment: Environment): Promise { + if (!environment.ACCOUNTS_DB) return; + try { + await pruneEndedSessions(accountsDb(environment.ACCOUNTS_DB)); + } catch (error) { + console.error("accounts: pruning ended sessions failed"); + captureFailure(error, "session prune"); + } +} // Durable Object classes must be re-exported from the entry module so Wrangler // can bind them (see wrangler.toml [[durable_objects.bindings]] + migrations). @@ -32,6 +45,7 @@ export default Sentry.withSentry( ): Promise { // Hourly (wrangler.toml [triggers]); idle once a daily sweep is done. context.waitUntil(runRatingsSweepStep({ kv: environment.NOTES_KV })); + context.waitUntil(pruneAccountSessions(environment)); }, } satisfies ExportedHandler ); diff --git a/src/notesImport/admin.test.ts b/src/notesImport/admin.test.ts index a6a54b3..d4e3fda 100644 --- a/src/notesImport/admin.test.ts +++ b/src/notesImport/admin.test.ts @@ -89,9 +89,34 @@ describe('admin Notes Import usage reset', () => { expect(JSON.stringify(log.mock.calls)).not.toContain(VALID_METER) log.mockRestore() }) + + it('takes an account id, the meter of bearer requests', async () => { + const { deps, indexFor } = makeDeps() + // A server account id: 22 base64url characters, `-` and `_` included. + const accountId = 'Ab-9_xYz01234567890aBc' + await expect( + handleAdminResetRequest( + request('correct-token', { meterId: accountId }), + deps + ).then(responseBody) + ).resolves.toEqual({ status: 200, body: { ok: true } }) + expect(indexFor).toHaveBeenCalledWith(accountId) + vi.restoreAllMocks() + }) }) describe('admin reset CLI target selection', () => { + it('accepts an account id', async () => { + const { resolveResetCommand } = await import( + '../../scripts/reset-notes-import-usage.mjs' + ) + expect( + resolveResetCommand(['Ab-9_xYz01234567890aBc'], { + ADMIN_API_TOKEN: 'prod-token', + }).meterId + ).toBe('Ab-9_xYz01234567890aBc') + }) + it('selects production URL and token by default', async () => { const { resolveResetCommand } = await import( '../../scripts/reset-notes-import-usage.mjs' diff --git a/src/notesImport/analytics.ts b/src/notesImport/analytics.ts index e906c13..41f9337 100644 --- a/src/notesImport/analytics.ts +++ b/src/notesImport/analytics.ts @@ -22,7 +22,11 @@ export const NOTES_IMPORT_EVENTS = { limitReached: 'api_notes_import_limit_reached', } as const -/** `dev` is the dev-bypass token (simulator / dev worker only). */ +/** + * A bearer request's session platform. Legacy: `ios` (App Attest), `android` + * (Play Integrity), or `dev` for the dev-bypass header. `dev` exists on the + * dev worker only. + */ export type NotesImportPlatform = 'ios' | 'android' | 'dev' export type NotesImportTransport = 'stream' | 'legacy' @@ -40,14 +44,20 @@ export type NotesImportLimit = 'imports' | 'refinements' | 'active_cap' * terminal event shares the kickoff's breakdowns. Structured-clone-safe. */ export interface NotesImportAnalyticsContext { - /** `analyticsDistinctId(meterId)`; never the meter id itself. */ + /** + * `analyticsDistinctId(meterId)`, the session's account id on bearer + * requests; never the meter id itself. + */ distinctId: string platform: NotesImportPlatform transport: NotesImportTransport refinement: boolean /** Real entitlement at kickoff (the dev bypass never implies it). */ supporter: boolean - /** The client sent a shared account id (ADR 0011), not just an install uuid. */ + /** + * A bearer request (an account session), or a legacy client that sent a + * shared account id (ADR 0011), not just an install uuid. + */ hasAccount: boolean notesChars: number /** Epoch ms when the run started; `duration_ms` is measured from here. */ diff --git a/src/notesImport/cap.test.ts b/src/notesImport/cap.test.ts index cd75aab..868a694 100644 --- a/src/notesImport/cap.test.ts +++ b/src/notesImport/cap.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { decideAcquire, decideStartOutcome } from './cap' +import { + SYNC_IMPORT_DEADLINE_MS, + SYNC_SLOT_MAX_AGE_MS, + decideAcquire, + decideStartOutcome, +} from './cap' import type { ImportStatus } from './events' describe('decideAcquire', () => { @@ -67,3 +72,15 @@ describe('decideStartOutcome', () => { expect(releasable).toEqual(['done', 'cancelled']) }) }) + +describe('synchronous slot lifetime and model deadline', () => { + it('ends the model call minutes before its slot could age out', () => { + expect(SYNC_IMPORT_DEADLINE_MS).toBeLessThan(SYNC_SLOT_MAX_AGE_MS) + // Room for settlement and the response after a call that ran to the end. + expect(SYNC_SLOT_MAX_AGE_MS - SYNC_IMPORT_DEADLINE_MS).toBeGreaterThanOrEqual( + 5 * 60 * 1000 + ) + // Still far beyond a real model run (about a minute). + expect(SYNC_IMPORT_DEADLINE_MS).toBeGreaterThanOrEqual(5 * 60 * 1000) + }) +}) diff --git a/src/notesImport/cap.ts b/src/notesImport/cap.ts index ce7aee9..3e189be 100644 --- a/src/notesImport/cap.ts +++ b/src/notesImport/cap.ts @@ -16,6 +16,34 @@ export const decideAcquire = ( cap: number ): boolean => alreadyHeld || activeCount < cap +/** Prefix of a synchronous `POST /notes-import` slot (a run's is `imp_`). */ +export const SYNC_SLOT_PREFIX = 'legacy:' + +/** + * How long a synchronous import's slot counts before it's taken as + * abandoned. The synchronous path releases its slot in a `finally`, which + * may never run if the client disconnects mid-run (the runtime can cancel + * the request) or the isolate goes away, and each invocation holds a slot of + * its own, so without an age limit every such request would leak one for + * good. + * + * A request still running when its slot ages out would let one more import + * past the cap, so the model call it waits on ends first, at + * `SYNC_IMPORT_DEADLINE_MS`. Change the two together: the deadline stays + * minutes short of this age, which leaves room for the work after the model + * (settlement, the response) and the release. + */ +export const SYNC_SLOT_MAX_AGE_MS = 15 * 60 * 1000 + +/** + * How long the synchronous `POST /notes-import` waits on the model before it + * aborts the call, answers `model_error`, and releases its slot. Far beyond + * any real model run (about a minute), and well short of + * `SYNC_SLOT_MAX_AGE_MS`, the slot's lifetime, so a slow call can't outlast + * its slot. + */ +export const SYNC_IMPORT_DEADLINE_MS = 10 * 60 * 1000 + /** * What a re-kick of an existing run DO resolves to, from its persisted status. * Split out (like {@link decideAcquire}) so the leak-relevant decision is pure diff --git a/src/notesImport/contracts.ts b/src/notesImport/contracts.ts index b8aa1c8..19a8f08 100644 --- a/src/notesImport/contracts.ts +++ b/src/notesImport/contracts.ts @@ -7,6 +7,15 @@ export interface NotesImportKickoffResponse { credits: CreditsSnapshot } +/** + * A bearer kickoff's answer: no subscribe token, because the run routes take + * the access token and check the run belongs to its account. + */ +export type NotesImportAccountKickoffResponse = Omit< + NotesImportKickoffResponse, + 'subscribeToken' +> + export interface AllowanceDenialResponse { status: 402 | 429 body: { diff --git a/src/notesImport/indexDO.ts b/src/notesImport/indexDO.ts index 754c6a4..da56dfc 100644 --- a/src/notesImport/indexDO.ts +++ b/src/notesImport/indexDO.ts @@ -1,6 +1,6 @@ import { DurableObject } from 'cloudflare:workers' import type { Environment } from '../types' -import { decideAcquire } from './cap' +import { SYNC_SLOT_MAX_AGE_MS, SYNC_SLOT_PREFIX, decideAcquire } from './cap' import { allowanceDeniedEvent, emitNotesImportOperationalEvent, @@ -31,6 +31,10 @@ export interface AcquireResult { active: number } +export type AcquireSyncResult = + | { ok: true; active: number; slot: string } + | { ok: false; active: number } + export interface CheckCreditArgs { hash: string isSupporter: boolean @@ -134,6 +138,33 @@ export class NotesImportIndex extends DurableObject { return { ok: true, active: held ? active : active + 1 } } + /** + * A slot for one synchronous import (`POST /notes-import`), under a key of + * its own: unlike a run, the synchronous path has nothing to share a model + * run with, so identical requests at once each run the model and each + * count against `cap`. Synchronous slots older than + * `SYNC_SLOT_MAX_AGE_MS` are dropped first, since a cancelled request + * never releases its own. `release(slot)` frees it. + */ + acquireSync(cap: number): AcquireSyncResult { + this.ctx.storage.sql.exec( + 'DELETE FROM active WHERE substr(importId, 1, ?) = ? AND startedAt <= ?', + SYNC_SLOT_PREFIX.length, + SYNC_SLOT_PREFIX, + Date.now() - SYNC_SLOT_MAX_AGE_MS + ) + const active = this.#activeCount() + if (!decideAcquire(active, false, cap)) return { ok: false, active } + const slot = `${SYNC_SLOT_PREFIX}${crypto.randomUUID()}` + this.ctx.storage.sql.exec( + 'INSERT INTO active (importId, status, startedAt) VALUES (?, ?, ?)', + slot, + 'running', + Date.now() + ) + return { ok: true, active: active + 1, slot } + } + release(importId: string): void { this.ctx.storage.sql.exec('DELETE FROM active WHERE importId = ?', importId) } diff --git a/src/notesImport/openapi.ts b/src/notesImport/openapi.ts index b588ab2..5fbafca 100644 --- a/src/notesImport/openapi.ts +++ b/src/notesImport/openapi.ts @@ -21,6 +21,7 @@ import { V1_CODES, } from '../attestation/openapi' import { notesImportResultValidator } from './schema' +import { SYNC_IMPORT_DEADLINE_MS } from './cap' /** * OpenAPI descriptions for the Notes Import routes (`src/notesImport/route.ts`). @@ -30,6 +31,9 @@ import { notesImportResultValidator } from './schema' const TAG = 'Notes Import' +/** How long the synchronous path waits on the model. */ +const SYNC_DEADLINE_MINUTES = SYNC_IMPORT_DEADLINE_MS / 60_000 + // --- Shared bodies and responses ---------------------------------------- const creditsSchema = z @@ -87,6 +91,34 @@ const niError = (description: string, codes: readonly string[]) => const killSwitchUnavailable = '`unavailable`: the kill switch is off or no allowlisted model host is healthy (`detail` says which; `Retry-After: 60`). ' +// --- Bearer tokens and the legacy switch ----------------------------------- + +/** A bearer token, or (while the legacy switch is on) none: the legacy form. */ +const bearerOrLegacy: Record[] = [{ bearerAuth: [] }, {}] + +const BEARER_FORM = + '**Bearer** (`Authorization: Bearer `, from `POST /accounts/v1/sessions`): the session attested once, so the body carries no attestation fields and no hashes, and usage is metered by the token’s account (a fresh allowance: usage under an install id or shared account id doesn’t carry over). A request with an `Authorization` header is always a bearer request: a bad token is 401 `unauthorized`, never a fallback to the legacy form.' + +const LEGACY_FORM = + '**Legacy** (no `Authorization` header; app versions before sessions): the attested forms, metered by `accountId` or `uuid`. They work while the legacy switch (KV `auth:legacy`) is on and are 426 `upgrade_required` once it’s off.' + +const revenueCatAppUserId = z + .string() + .regex(/^[A-Za-z0-9_-]{8,64}$/) + .optional() + .meta({ + description: + 'Bearer only: the app’s RevenueCat app user id, checked with RevenueCat for Supporter status. Absent: not a Supporter. Temporary, until RevenueCat is linked to the account; a malformed value is a 400.', + }) + +const upgradeRequired = niError( + 'A legacy request (no `Authorization` header) after the legacy switch was turned off. No `Retry-After`: only updating the app helps.', + ['upgrade_required'] +) + +const accountsDisabled = + '`disabled`: account sessions are off on this worker (`Retry-After: 60`). ' + // --- GET /notes-import/status -------------------------------------------- const allowancePair = z.object({ @@ -155,7 +187,7 @@ export const statusDocs = describeRoute({ Availability checks the KV kill switch (\`notes-import:enabled\`) first, then whether any allowlisted model host is healthy on OpenRouter (a free metadata call, cached 60 seconds). Fails open: if the probe itself errors, the response says available without \`limits\`. -Completed responses are reused in each Worker isolate for up to 30 seconds (KV reads add up to 60 seconds more); the response itself is \`Cache-Control: no-store\`.`, +Completed responses are reused in each Worker isolate for up to 30 seconds (KV reads add up to 60 seconds more); the response itself is \`Cache-Control: no-store\`. Public and the same for everyone: it ignores \`Authorization\` and never reports one account’s allowance (kickoff’s \`credits\` does).`, responses: { 200: jsonResponse('The current status.', statusSchema), ...rateLimitedResponse, @@ -227,7 +259,8 @@ const importBaseFields = { uuid: installId, accountId, notesText: z.string().meta({ - description: 'The notes to import, at most 100,000 characters by default.', + description: + 'The notes to import, at most 100,000 characters by default. Empty or whitespace-only text is a 400 `bad_request`.', }), context: contextSchema, refinement: refinementSchema, @@ -252,7 +285,19 @@ const v1ImportFields = { contentHash: notesContentHash.optional(), } +const bearerImportFields = { + notesText: importBaseFields.notesText, + context: contextSchema, + refinement: refinementSchema, + revenueCatAppUserId, +} + +const bearerImportSchema = z + .object(bearerImportFields) + .meta({ title: 'Bearer' }) + const kickoffRequestSchema = z.union([ + bearerImportSchema, z .object({ ...importBaseFields, ...v1ImportFields }) .meta({ title: 'App Attest v1 (legacy)' }), @@ -263,7 +308,7 @@ const kickoffRequestSchema = z.union([ contentHash: notesContentHash, requestHash: notesRequestHash, }) - .meta({ title: 'App Attest v2' }), + .meta({ title: 'App Attest v2 (legacy)' }), z .object({ ...importBaseFields, @@ -271,7 +316,7 @@ const kickoffRequestSchema = z.union([ contentHash: notesContentHash, requestHash: notesRequestHash, }) - .meta({ title: 'Play Integrity' }), + .meta({ title: 'Play Integrity (legacy)' }), ]) const successSchema = z @@ -299,44 +344,53 @@ const successSchema = z const importGateErrors = (legacy: boolean) => ({ 400: niError( - legacy - ? 'Invalid JSON; missing `uuid`, `notesText`, or `context`; an invalid `accountId`; `contentHash` doesn’t match `notesText`; or an App Attest v2 / Play Integrity body (`reason: unsupported_protocol`).' - : 'Invalid JSON; missing `uuid`, `notesText`, or `context`; an invalid `accountId`; `contentHash` or `requestHash` doesn’t match the payload; or an invalid v2 / Play Integrity request (`reason: invalid_request`).', + `Bearer: invalid JSON, missing \`notesText\` or \`context\`, empty or whitespace-only \`notesText\`, or an invalid \`revenueCatAppUserId\`. Legacy: ${ + legacy + ? 'invalid JSON; missing `uuid`, `notesText`, or `context`; empty or whitespace-only `notesText`; an invalid `accountId`; `contentHash` doesn’t match `notesText`; or an App Attest v2 / Play Integrity body (`reason: unsupported_protocol`).' + : 'invalid JSON; missing `uuid`, `notesText`, or `context`; empty or whitespace-only `notesText`; an invalid `accountId`; `contentHash` or `requestHash` doesn’t match the payload; or an invalid v2 / Play Integrity request (`reason: invalid_request`).' + }`, ['bad_request', 'attestation_failed'] ), 401: niError( - `No App Attest credentials (\`attestation_required\`, \`action: bind\`), or attestation failed. ${ATTESTATION_NOTE}`, - ['attestation_required', 'attestation_failed', ...V1_CODES] + `Bearer: the access token is missing, expired, or invalid (\`unauthorized\`, with \`WWW-Authenticate: Bearer error="invalid_token"\`; refresh once and retry). Legacy: no App Attest credentials (\`attestation_required\`, \`action: bind\`), or attestation failed. ${ATTESTATION_NOTE}`, + ['unauthorized', 'attestation_required', 'attestation_failed', ...V1_CODES] ), 402: niError( 'The import allowance for this window is used up. `credits` says when it resets; no `Retry-After`. Replays of already-imported content are free and never get this.', ['limit_reached'] ), - ...(legacy - ? {} - : { - 403: attestationResponses[403], - 409: attestationResponses[409], - }), + 403: niError( + legacy + ? 'Bearer: the account is suspended.' + : 'Bearer: the account is suspended (`suspended`). Legacy, Play Integrity only: the app isn’t the Play-distributed build (`app_not_recognized`) or the device fails the required integrity verdict (`device_integrity_failed`).', + legacy ? ['suspended'] : ['suspended', 'attestation_failed'] + ), + ...(legacy ? {} : { 409: attestationResponses[409] }), 413: niError('`notesText` is over the character limit.', ['too_large']), 429: niError( 'Too many requests from this IP (`rate_limited`, `Retry-After: 60`); this content’s refinement allowance is used up (`refinement_limit`, with `credits`, no `Retry-After`; it never resets); or this user already has the maximum imports running (`active_cap`, 2 free / 5 Supporter by default, `Retry-After: 15`).', ['rate_limited', 'refinement_limit', 'active_cap'] ), + 426: upgradeRequired, }) -const GATE_DESCRIPTION = `**Gate**, in order: the kill switch and provider health (503 \`unavailable\`), body checks, the content and request hashes, attestation (or the dev bypass), Supporter status from RevenueCat on \`accountId\` or \`uuid\` (an outage counts as free), then the allowance. Content already imported by this user replays without using an import; a refinement uses the per-content refinement allowance. An import is charged only when it succeeds.` +const GATE_DESCRIPTION = `**Gate**, in order: the access token and account (bearer) or the legacy switch (legacy), the kill switch and provider health (503 \`unavailable\`), body checks, then for legacy requests the content and request hashes and attestation (or the dev bypass), Supporter status from RevenueCat (an outage counts as free), then the allowance. Bearer requests ask RevenueCat about \`revenueCatAppUserId\` (none: free); legacy requests about \`accountId\` or \`uuid\`. A bearer \`dev\` session (dev worker only) gets the dev bypass’s unlimited allowances. Content already imported by this user replays without using an import; a refinement uses the per-content refinement allowance. An import is charged only when it succeeds. + +${BEARER_FORM} + +${LEGACY_FORM}` export const kickoffDocs = describeRoute({ tags: [TAG], summary: 'Start a streaming import', - description: `The attested, metered entry point. Starts the model run in a per-import Durable Object and returns at once with an \`importId\` and a \`subscribeToken\` for \`GET /notes-import/{importId}/events\`, \`/result\`, \`/cancel\`, and \`/destroy\`. + description: `The authenticated, metered entry point. Starts the model run in a per-import Durable Object and returns at once with an \`importId\` for \`GET /notes-import/{importId}/events\`, \`/result\`, \`/cancel\`, and \`/destroy\`. A bearer request calls those with the same access token; a legacy request gets a \`subscribeToken\` for them. ${GATE_DESCRIPTION} -The \`importId\` is derived from the user and the content (plus the refinement instruction), so kicking off the same notes again reconnects to the same run instead of starting another; a run that failed restarts. The subscribe token lasts 1 hour, and a finished result is kept for 1 hour. +The \`importId\` is derived from the user (the account, on bearer requests) and the content (plus the refinement instruction), so kicking off the same notes again reconnects to the same run instead of starting another; a run that failed restarts. The subscribe token lasts 1 hour, and a finished result is kept for 1 hour. \`credits\` is a point-in-time preview and may differ from the terminal snapshot on \`done\`.`, + security: bearerOrLegacy, parameters: [devBypassHeader], requestBody: jsonBody(kickoffRequestSchema), responses: { @@ -349,9 +403,9 @@ The \`importId\` is derived from the user and the content (plus the refinement i .meta({ description: 'The run’s id, stable per user and content.', }), - subscribeToken: z.string().meta({ + subscribeToken: z.string().optional().meta({ description: - 'Capability for this import’s stream, result, cancel, and destroy routes (`?token=`). Expires after 1 hour.', + 'Legacy requests only: the capability for this import’s stream, result, cancel, and destroy routes (`?token=`). Expires after 1 hour. A bearer response has none; those routes take the access token.', }), refinement: z.boolean(), credits: creditsSchema, @@ -359,37 +413,42 @@ The \`importId\` is derived from the user and the content (plus the refinement i ), ...importGateErrors(false), 500: niError('The run could not be started.', ['server_error']), - 503: niError(`${killSwitchUnavailable}${storageUnavailable}`, [ - 'unavailable', - 'server_error', - ]), + 503: niError( + `${killSwitchUnavailable}${accountsDisabled}${storageUnavailable}`, + ['unavailable', 'disabled', 'server_error'] + ), }, }) export const legacyImportDocs = describeRoute({ tags: [TAG], summary: 'Import notes synchronously (legacy)', - description: `The original path, kept as a fallback: holds the request open for the whole model run (often over a minute) and returns the result in one response. App Attest v1 only; v2 and Play Integrity bodies are refused (use \`POST /notes-import/kickoff\`). + description: `The original path, kept as a fallback: holds the request open for the whole model run (often over a minute) and returns the result in one response. Bearer requests take the same body as kickoff’s. Legacy requests are App Attest v1 only; v2 and Play Integrity bodies are refused (use \`POST /notes-import/kickoff\`). -${GATE_DESCRIPTION} The same concurrency cap as kickoff applies.`, +${GATE_DESCRIPTION} The same concurrency cap as kickoff applies, and each request holds a slot of its own while it runs, identical ones included (they still charge once). A model run that hasn't finished within ${SYNC_DEADLINE_MINUTES} minutes is stopped and answered \`502\` \`model_error\`, charging nothing.`, deprecated: true, + security: bearerOrLegacy, parameters: [devBypassHeader], requestBody: jsonBody( - z - .object({ ...importBaseFields, ...v1ImportFields }) - .meta({ title: 'App Attest v1' }) + z.union([ + bearerImportSchema, + z + .object({ ...importBaseFields, ...v1ImportFields }) + .meta({ title: 'App Attest v1 (legacy)' }), + ]) ), responses: { 200: jsonResponse('The finished import.', successSchema), ...importGateErrors(true), 500: niError('Unexpected failure.', ['server_error']), - 502: niError('The model failed or returned an invalid result.', [ - 'model_error', - ]), - 503: niError(`${killSwitchUnavailable}${storageUnavailable}`, [ - 'unavailable', - 'server_error', - ]), + 502: niError( + `The model failed, returned an invalid result, or didn’t finish within ${SYNC_DEADLINE_MINUTES} minutes.`, + ['model_error'] + ), + 503: niError( + `${killSwitchUnavailable}${accountsDisabled}${storageUnavailable}`, + ['unavailable', 'disabled', 'server_error'] + ), }, }) @@ -414,22 +473,34 @@ export const transcribeDocs = describeRoute({ summary: 'Read a photographed page', description: `Import from Paper: reads one page image into plain text with a vision model on the zero-data-retention allowlist. Nothing is stored. The app shows the text for editing, then imports it through \`POST /notes-import/kickoff\`, which is where an import is used. -App Attest v2 (purpose \`notes-import-transcribe\`) or Play Integrity only; v1 is a 400 with \`reason: unsupported_protocol\`. Gated by the kill switch. Pages are free but limited per user per rolling 24 hours (40 free, 200 Supporter by default), and a free user with no imports left is refused before any page is read.`, +Bearer requests send just \`image\`, \`mimeType\`, and optionally \`revenueCatAppUserId\`, and are metered by the token’s account. Legacy requests take App Attest v2 (purpose \`notes-import-transcribe\`) or Play Integrity only; v1 is a 400 with \`reason: unsupported_protocol\`. Gated by the kill switch. Pages are free but limited per user per rolling 24 hours (40 free, 200 Supporter by default), and a free user with no imports left is refused before any page is read. + +${BEARER_FORM} + +${LEGACY_FORM}`, + security: bearerOrLegacy, parameters: [devBypassHeader], requestBody: jsonBody( z.union([ + z + .object({ + image: transcribeBaseFields.image, + mimeType: transcribeBaseFields.mimeType, + revenueCatAppUserId, + }) + .meta({ title: 'Bearer' }), z .object({ ...transcribeBaseFields, ...appAttestV2AssertionFields('notes-import-transcribe'), }) - .meta({ title: 'App Attest v2' }), + .meta({ title: 'App Attest v2 (legacy)' }), z .object({ ...transcribeBaseFields, ...playIntegrityFields('notes-import-transcribe'), }) - .meta({ title: 'Play Integrity' }), + .meta({ title: 'Play Integrity (legacy)' }), ]) ), responses: { @@ -447,33 +518,38 @@ App Attest v2 (purpose \`notes-import-transcribe\`) or Play Integrity only; v1 i }) ), 400: niError( - 'Invalid JSON; missing `uuid` or `image`; an unsupported `mimeType`; an invalid `accountId`; an image that isn’t base64 of the declared type; a hash mismatch; App Attest v1 (`reason: unsupported_protocol`); or an invalid v2 / Play Integrity request.', + 'Invalid JSON; missing `image`; an unsupported `mimeType`; an image that isn’t base64 of the declared type. Bearer: an invalid `revenueCatAppUserId`. Legacy: missing `uuid`; an invalid `accountId`; a hash mismatch; App Attest v1 (`reason: unsupported_protocol`); or an invalid v2 / Play Integrity request.', ['bad_request', 'attestation_failed'] ), - 401: niError(`Attestation failed. ${ATTESTATION_NOTE}`, [ - 'attestation_failed', - ]), + 401: niError( + `Bearer: the access token is missing, expired, or invalid (\`unauthorized\`, with \`WWW-Authenticate\`). Legacy: attestation failed. ${ATTESTATION_NOTE}`, + ['unauthorized', 'attestation_failed'] + ), 402: niError( 'A free user with no imports left in this window (with `credits`).', ['limit_reached'] ), - 403: attestationResponses[403], + 403: niError( + 'Bearer: the account is suspended (`suspended`). Legacy, Play Integrity only: the app isn’t the Play-distributed build or the device fails the required integrity verdict.', + ['suspended', 'attestation_failed'] + ), 409: attestationResponses[409], 413: niError('The decoded image is over the byte limit.', ['too_large']), 429: niError( 'Too many requests from this IP (`rate_limited`, `Retry-After: 60`), or the daily page limit is reached (`transcribe_limit`, `Retry-After` until the oldest page ages out).', ['rate_limited', 'transcribe_limit'] ), + 426: upgradeRequired, 500: niError('Unexpected failure.', ['server_error']), 502: niError('The vision model couldn’t read the page.', ['model_error']), - 503: niError(`${killSwitchUnavailable}${storageUnavailable}`, [ - 'unavailable', - 'server_error', - ]), + 503: niError( + `${killSwitchUnavailable}${accountsDisabled}${storageUnavailable}`, + ['unavailable', 'disabled', 'server_error'] + ), }, }) -// --- Run routes (subscribe-token capability) --------------------------------- +// --- Run routes (bearer token, or the legacy subscribe token) --------------- const importIdParameter = { name: 'importId', @@ -486,23 +562,29 @@ const importIdParameter = { const tokenParameter = { name: 'token', in: 'query' as const, - required: true, + required: false, schema: { type: 'string' as const }, description: - 'The `subscribeToken` from kickoff. It must have been issued for this `importId`; it expires after 1 hour.', + 'Legacy requests only (required there): the `subscribeToken` from kickoff. It must have been issued for this `importId`; it expires after 1 hour. A bearer request never reads it.', } const runAuthErrors = { - 400: niError('Missing `token`.', ['bad_request']), + 400: niError('Legacy: missing `token`.', ['bad_request']), 401: niError( - 'The token is unknown, expired, or for another import. Kick off again for a fresh one.', - ['attestation_required'] + 'Bearer: the access token is missing, expired, or invalid (`unauthorized`, with `WWW-Authenticate`; refresh once and retry). Legacy: the subscribe token is unknown, expired, or for another import (`attestation_required`); kick off again for a fresh one.', + ['unauthorized', 'attestation_required'] + ), + 404: niError( + 'Bearer: no import with this id belongs to the token’s account. Another account’s import, an unknown id, and one deleted or past its 1-hour retention all get this same answer. Kick off again to run the notes anew.', + ['not_found'] ), + 426: upgradeRequired, ...rateLimitedResponse, + 503: niError(accountsDisabled, ['disabled']), } const RUN_AUTH = - 'Authorized by the kickoff’s `subscribeToken` instead of attestation: this route can’t spend inference.' + 'This route can’t spend inference, so it needs no attestation. **Bearer:** the access token of the account that kicked the import off (a suspended account still may). **Legacy:** the kickoff’s `subscribeToken` as `?token=`, while the legacy switch is on.' const importStatus = z .enum([ @@ -559,6 +641,7 @@ Each frame has an \`event:\` line naming the type and a \`data:\` line with the | \`cancelled\` | \`{ "type": "cancelled" }\` (never charged) | yes, terminal | The stream is for showing progress; the result is read only from \`done\` or \`GET /notes-import/{importId}/result\`.`, + security: bearerOrLegacy, parameters: [ importIdParameter, tokenParameter, @@ -598,6 +681,7 @@ export const resultDocs = describeRoute({ tags: [TAG], summary: 'Get an import’s result', description: `The run’s current state and, once finished, its result, for a client that reconnects after the stream closed or missed the terminal event. ${RUN_AUTH} Results are kept for 1 hour after the run ends.`, + security: bearerOrLegacy, parameters: [importIdParameter, tokenParameter], responses: { 200: jsonResponse('The run’s snapshot.', snapshotSchema), @@ -611,6 +695,7 @@ export const cancelDocs = describeRoute({ description: `Stops a queued or running import: aborts the model call, frees the concurrency slot, and ends the stream with \`cancelled\`. A cancelled run is never charged. ${RUN_AUTH} No-op once the run has ended. If the run is already settling its result, cancel waits and returns \`done\` instead. Used by the app’s “edit & resend” flow.`, + security: bearerOrLegacy, parameters: [importIdParameter, tokenParameter], responses: { 200: jsonResponse('The run’s snapshot after the cancel.', snapshotSchema), @@ -621,7 +706,8 @@ No-op once the run has ended. If the run is already settling its result, cancel export const destroyDocs = describeRoute({ tags: [TAG], summary: 'Delete an import', - description: `Forgets a run now instead of after its retention: aborts any model call (uncharged), frees the concurrency slot, closes the stream, and deletes all stored state, the result included. Idempotent. ${RUN_AUTH} Backs the history view’s delete.`, + description: `Forgets a run now instead of after its retention: aborts any model call (uncharged), frees the concurrency slot, closes the stream, and deletes all stored state, the result included. Idempotent for legacy requests; a bearer request for an import already deleted is 404 \`not_found\`, which means the same thing. ${RUN_AUTH} Backs the history view’s delete.`, + security: bearerOrLegacy, parameters: [importIdParameter, tokenParameter], responses: { 200: jsonResponse('Deleted.', z.object({ ok: z.literal(true) })), diff --git a/src/notesImport/route.bearer.test.ts b/src/notesImport/route.bearer.test.ts new file mode 100644 index 0000000..b548445 --- /dev/null +++ b/src/notesImport/route.bearer.test.ts @@ -0,0 +1,1068 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { sha256Hex } from '../crypto' +import type { Environment } from '../types' +import { createHarness, TEST_DEV_BYPASS, type Harness } from '../test/buddies' +import { + bearer, + nextTestIp, + openTestSession, + type TestSession, +} from '../test/accounts' +import { createNamespace, type FakeNamespace } from '../test/durableObjects' +import { resetAccountStatusCache } from '../accounts/auth' +import { createAccountsRoutes } from '../accounts/route' +import { accountsDb, setAccountStatus } from '../accounts/store' +import { accessTokenKeys, signAccessToken } from '../accounts/tokens' +import { analyticsDistinctId } from '../analytics' +import { app } from '../app' +import { SYNC_IMPORT_DEADLINE_MS, SYNC_SLOT_MAX_AGE_MS } from './cap' +import { getNotesImportConfig } from './config' +import { NotesImportIndex } from './indexDO' +import { NotesImportRun } from './runDO' +import { ModelDeadlineError, importOwnerKey } from './route' + +/** + * Notes Import on account sessions (witness-work ADR 0027): bearer requests + * meter by the token's account and reach only that account's runs; legacy + * requests are unchanged while the legacy switch is on and 426 once it's + * off. Real run and index Durable Objects; the model, the page reader, and + * PostHog are stubbed, and RevenueCat answers through `fetch`. + */ + +const mocks = vi.hoisted(() => ({ + runModel: vi.fn(), + transcribe: vi.fn(), + capture: vi.fn(async () => undefined), + /** Stands in for App Attest v1 verification when a test sets it. */ + appAttest: null as { verifyAssertion: () => Promise } | null, +})) + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + ctx: DurableObjectState + env: unknown + constructor(ctx: DurableObjectState, env: unknown) { + this.ctx = ctx + this.env = env + } + }, +})) +vi.mock('./llm', () => ({ runNotesImportModel: mocks.runModel })) +vi.mock('../appAttest', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + appAttestLifecycle: (env: Environment) => + mocks.appAttest ?? original.appAttestLifecycle(env), + } +}) +vi.mock('./transcribe', async (importOriginal) => ({ + ...(await importOriginal()), + transcribeNotesPage: mocks.transcribe, +})) +vi.mock('../analytics', async (importOriginal) => ({ + ...(await importOriginal()), + captureAnalyticsEvent: mocks.capture, +})) + +const START = Date.UTC(2026, 9, 15, 12) +const accountsApp = createAccountsRoutes() as never + +const CONTEXT = { + now: '2026-10-15T08:00:00-04:00', + timeZone: 'America/New_York', + existingContacts: [], + existingCategories: [], +} +const RESULT = { + contacts: [], + visits: [], + timeEntries: [ + { date: '2026-10-14', hours: 1, minutes: 0, categoryId: null, credit: false }, + ], + categories: [], + publisher: null, + warnings: [], + summary: 'One hour', + assistantMessage: '', +} +/** The first bytes of a JPEG (FF D8 FF E0 00 10). */ +const JPEG = '/9j/4AAQ' +const RC_ID = 'B1C2D3E4-0000-4000-8000-000000000001' +const LIFETIME = { + subscriber: { entitlements: { 'Lifetime Supporter': { expires_date: null } } }, +} + +let h: Harness +let runs: FakeNamespace +let indexes: FakeNamespace +let pending: Promise[] +let fetchMock: ReturnType + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(START) + h = await createHarness() + resetAccountStatusCache() + await h.kv.put('notes-import:provider-health', 'up') + const env = h.env as Environment & Record + env.RATE_LIMITER = { limit: async () => ({ success: true }) } + env.OPENROUTER_API_KEY = 'openrouter-test' + env.REVENUECAT_API_KEY = 'revenuecat-test' + env.POSTHOG_PROJECT_TOKEN = 'phc_test' + runs = createNamespace((state) => new NotesImportRun(state as never, env as never)) + indexes = createNamespace( + (state) => new NotesImportIndex(state as never, env as never) + ) + env.NOTES_IMPORT_RUN = runs.namespace as Environment['NOTES_IMPORT_RUN'] + env.NOTES_IMPORT_INDEX = indexes.namespace as Environment['NOTES_IMPORT_INDEX'] + pending = [] + // RevenueCat: nobody is a Supporter unless a test says otherwise. + fetchMock = vi.fn(async () => new Response(null, { status: 404 })) + vi.stubGlobal('fetch', fetchMock) + mocks.runModel.mockReset() + mocks.runModel.mockResolvedValue({ + result: RESULT, + usage: { inputTokens: 100, outputTokens: 50, reasoningTokens: 10 }, + resolvedProvider: 'azure', + }) + mocks.transcribe.mockReset() + mocks.capture.mockClear() + mocks.appAttest = null +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +const executionCtx = { + waitUntil: (promise: Promise) => { + pending.push(promise) + }, + passThroughOnException: () => undefined, + props: {}, +} as unknown as ExecutionContext + +interface Reply { + status: number + headers: Headers + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: any + text: string +} + +const call = async ( + method: string, + path: string, + init: { body?: unknown; headers?: Record } = {} +): Promise => { + const response = await app.request( + path, + { + method, + headers: { + 'content-type': 'application/json', + 'cf-connecting-ip': nextTestIp(), + ...init.headers, + }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }, + h.env, + executionCtx + ) + const text = await response.text() + let body: unknown = text + try { + body = JSON.parse(text) + } catch { + // The SSE stream. + } + return { status: response.status, headers: response.headers, body, text } +} + +const flush = async () => { + while (pending.length) await Promise.all(pending.splice(0)) +} + +/** A dev session (the harness opens sessions with the dev bypass). */ +const devSession = () => openTestSession(accountsApp, h.env) + +/** A token for the same account and session, as an iOS session would carry. */ +const iosToken = async (session: TestSession) => { + const keys = await accessTokenKeys(h.env) + return ( + await signAccessToken(keys!, { + accountId: session.accountId, + sessionId: session.sessionId, + platform: 'ios', + }) + ).accessToken +} + +const kickoff = (token: string, fields: Record = {}) => + call('POST', '/notes-import/kickoff', { + headers: bearer(token), + body: { notesText: 'Field service 1 hour', context: CONTEXT, ...fields }, + }) + +/** Kicks off and runs the import to `done`; returns its id. */ +const importNotes = async (token: string, fields: Record = {}) => { + const started = await kickoff(token, fields) + expect(started.status, JSON.stringify(started.body)).toBe(200) + await runs.fireAlarm(started.body.importId) + await flush() + return started.body.importId as string +} + +const run = (importId: string, op: string, token: string) => + call(op === 'events' || op === 'result' ? 'GET' : 'POST', `/notes-import/${importId}/${op}`, { + headers: bearer(token), + }) + +const legacyBody = (fields: Record = {}) => ({ + uuid: 'C3D4E5F6-0000-4000-8000-000000000002', + notesText: 'Field service 1 hour', + context: CONTEXT, + ...fields, +}) +const legacyHeaders = { 'x-ww-dev-bypass': TEST_DEV_BYPASS } + +const revenueCatIds = () => + fetchMock.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.startsWith('https://api.revenuecat.com/')) + .map((url) => decodeURIComponent(url.split('/').pop()!)) + +describe('bearer kickoff', () => { + it('meters by the token’s account, with no attestation fields', async () => { + const session = await devSession() + const token = await iosToken(session) + const started = await kickoff(token, { + // Legacy identity fields are not read on a bearer request. + uuid: 'D4E5F6A7-0000-4000-8000-000000000003', + accountId: 'legacy-shared-account-id', + }) + + expect(started.status).toBe(200) + expect(Object.keys(started.body).sort()).toEqual([ + 'credits', + 'importId', + 'refinement', + ]) + expect(started.body.importId).toMatch(/^imp_[a-f0-9]{40}$/) + expect(started.body.credits).toMatchObject({ limit: 3, isSupporter: false }) + + await runs.fireAlarm(started.body.importId) + await flush() + const hash = await sha256Hex('Field service 1 hour') + expect( + indexes.storage(session.accountId).query('SELECT hash, charged FROM hash_record') + ).toEqual([{ hash, charged: 1 }]) + const result = await run(started.body.importId, 'result', token) + expect(result.status).toBe(200) + expect(result.body).toMatchObject({ + status: 'done', + payload: { result: RESULT, contentHash: hash, credits: { remaining: 2 } }, + }) + // No Supporter id named, so RevenueCat was never asked. + expect(revenueCatIds()).toEqual([]) + }) + + it('is idempotent per account and content, and distinct across accounts', async () => { + const a = await devSession() + const b = await devSession() + const first = await kickoff(a.accessToken) + const again = await kickoff(a.accessToken) + const other = await kickoff(b.accessToken) + expect(again.body.importId).toBe(first.body.importId) + expect(other.body.importId).not.toBe(first.body.importId) + }) + + it('never lands on a legacy run with the same meter and content', async () => { + const session = await devSession() + const legacy = await call('POST', '/notes-import/kickoff', { + headers: legacyHeaders, + body: legacyBody({ uuid: `account:${session.accountId}` }), + }) + expect(legacy.status).toBe(200) + const bearerStart = await kickoff(session.accessToken) + expect(bearerStart.body.importId).not.toBe(legacy.body.importId) + }) + + it('serves the synchronous POST /notes-import the same way', async () => { + const session = await devSession() + const token = await iosToken(session) + const response = await call('POST', '/notes-import', { + headers: bearer(token), + body: { notesText: 'Field service 1 hour', context: CONTEXT }, + }) + expect(response.status).toBe(200) + expect(response.body).toMatchObject({ + result: RESULT, + credits: { remaining: 2, limit: 3 }, + }) + expect( + indexes.storage(session.accountId).query('SELECT charged FROM hash_record') + ).toEqual([{ charged: 1 }]) + }) + + it('refuses a bad token with 401, never falling back to attestation', async () => { + const response = await call('POST', '/notes-import/kickoff', { + headers: { ...legacyHeaders, authorization: 'Bearer forged.token.value' }, + body: legacyBody(), + }) + expect(response.status).toBe(401) + expect(response.body).toEqual({ + ok: false, + error: 'The access token is missing, expired, or invalid', + code: 'unauthorized', + }) + expect(response.headers.get('www-authenticate')).toBe( + 'Bearer error="invalid_token"' + ) + expect(mocks.runModel).not.toHaveBeenCalled() + }) + + it('refuses a suspended account, which can still read and delete its runs', async () => { + const session = await devSession() + const importId = await importNotes(session.accessToken) + await setAccountStatus( + accountsDb(h.env.ACCOUNTS_DB!), + session.accountId, + 'suspended', + 'report' + ) + vi.setSystemTime(START + 30_000) + const refused = await kickoff(session.accessToken, { notesText: 'Other notes' }) + expect(refused.status).toBe(403) + expect(refused.body).toMatchObject({ ok: false, code: 'suspended' }) + expect((await run(importId, 'result', session.accessToken)).status).toBe(200) + expect((await run(importId, 'destroy', session.accessToken)).status).toBe(200) + }) + + it('validates the body without legacy fields', async () => { + const session = await devSession() + const missing = await call('POST', '/notes-import/kickoff', { + headers: bearer(session.accessToken), + body: { context: CONTEXT }, + }) + expect(missing.status).toBe(400) + expect(missing.body).toEqual({ + ok: false, + error: 'Missing notesText or context', + code: 'bad_request', + }) + const badId = await kickoff(session.accessToken, { + revenueCatAppUserId: 'not a valid id!', + }) + expect(badId.status).toBe(400) + expect(badId.body.error).toBe('Invalid revenueCatAppUserId') + }) +}) + +describe('empty notes', () => { + it('refuses empty or whitespace-only notesText before any credit or model work, bearer and legacy', async () => { + const session = await devSession() + const token = await iosToken(session) + const metered = vi.spyOn(indexes.namespace, 'idFromName') + const requests = (notesText: string) => [ + ['/notes-import/kickoff', bearer(token), { notesText, context: CONTEXT, revenueCatAppUserId: RC_ID }], + ['/notes-import', bearer(token), { notesText, context: CONTEXT, revenueCatAppUserId: RC_ID }], + ['/notes-import/kickoff', legacyHeaders, legacyBody({ notesText })], + ['/notes-import', legacyHeaders, legacyBody({ notesText })], + ] as const + + for (const notesText of ['', ' ', '\n\t \r\n', '  ']) { + for (const [path, headers, body] of requests(notesText)) { + const response = await call('POST', path, { headers, body }) + expect(response.status, `${path} ${JSON.stringify(notesText)}`).toBe(400) + expect(response.body).toEqual({ + ok: false, + error: 'notesText is empty', + code: 'bad_request', + }) + } + } + expect(metered).not.toHaveBeenCalled() + expect(revenueCatIds()).toEqual([]) + expect(mocks.runModel).not.toHaveBeenCalled() + expect(mocks.capture).not.toHaveBeenCalled() + + // Text around the whitespace is still an import. + expect((await kickoff(token, { notesText: ' 1 hour ' })).status).toBe(200) + }) +}) + +describe('synchronous imports and the concurrency cap', () => { + const MODEL_OUTPUT = { + result: RESULT, + usage: { inputTokens: 100, outputTokens: 50, reasoningTokens: 10 }, + resolvedProvider: 'azure', + } + + /** Holds every model run until `release()`. */ + const holdModel = () => { + let release!: () => void + const gate = new Promise((resolve) => (release = resolve)) + mocks.runModel.mockImplementation(async () => { + await gate + return MODEL_OUTPUT + }) + return release + } + + const slots = (meter: string) => + indexes.storage(meter).query('SELECT importId FROM active') as { + importId: string + }[] + + /** + * Sends `count` identical synchronous imports at once with the model held: + * `cap` of them run, each in a slot of its own, and the rest are refused + * while they do. Then they finish and charge once. + */ + const burst = async ( + meter: string, + cap: number, + send: () => Promise + ) => { + const release = holdModel() + const settled: Reply[] = [] + const replies = Array.from({ length: cap + 2 }, () => + send().then((reply) => (settled.push(reply), reply)) + ) + await vi.waitFor(() => { + expect(mocks.runModel).toHaveBeenCalledTimes(cap) + expect(settled).toHaveLength(2) + }) + for (const refused of settled) { + expect(refused.status).toBe(429) + expect(refused.body).toMatchObject({ code: 'active_cap' }) + expect(refused.headers.get('retry-after')).toBe('15') + } + const held = slots(meter).map(({ importId }) => importId) + expect(held).toHaveLength(cap) + expect(new Set(held).size).toBe(cap) + for (const slot of held) expect(slot).toMatch(/^legacy:[0-9a-f-]{36}$/) + + release() + const done = (await Promise.all(replies)).filter( + (reply) => reply.status === 200 + ) + expect(done).toHaveLength(cap) + expect(mocks.runModel).toHaveBeenCalledTimes(cap) + expect(slots(meter)).toEqual([]) + return done + } + + /** The meter's content records and import window. */ + const usage = (meter: string) => ({ + records: indexes + .storage(meter) + .query('SELECT hash, charged, refinements FROM hash_record'), + window: indexes + .storage(meter) + .query("SELECT v FROM credit_meta WHERE k = 'count'"), + }) + + it('gives every identical bearer request its own slot, and charges once', async () => { + const session = await devSession() + const token = await iosToken(session) + const done = await burst( + session.accountId, + getNotesImportConfig(h.env).activeImportCap, + () => + call('POST', '/notes-import', { + headers: bearer(token), + body: { notesText: 'Field service 1 hour', context: CONTEXT }, + }) + ) + // One charge for the content, however many runs it took. + expect(usage(session.accountId)).toEqual({ + records: [ + { + hash: await sha256Hex('Field service 1 hour'), + charged: 1, + refinements: 0, + }, + ], + window: [{ v: 1 }], + }) + for (const reply of done) + expect(reply.body.credits).toMatchObject({ remaining: 2, limit: 3 }) + }) + + it('gives every identical legacy request its own slot, and charges once', async () => { + // App Attest v1, the legacy synchronous route's only attestation. + mocks.appAttest = { verifyAssertion: vi.fn(async () => undefined) } + const body = legacyBody({ + keyId: 'key-id', + challenge: 'challenge', + assertion: 'assertion', + }) + const done = await burst( + body.uuid, + getNotesImportConfig(h.env).activeImportCap, + () => call('POST', '/notes-import', { body }) + ) + expect(mocks.appAttest.verifyAssertion).toHaveBeenCalledTimes( + getNotesImportConfig(h.env).activeImportCap + 2 + ) + expect(usage(body.uuid)).toEqual({ + records: [ + { + hash: await sha256Hex('Field service 1 hour'), + charged: 1, + refinements: 0, + }, + ], + window: [{ v: 1 }], + }) + for (const reply of done) + expect(reply.body.credits).toMatchObject({ remaining: 2, limit: 3 }) + }) + + it('gives every identical dev-bypass request its own slot, under the Supporter cap', async () => { + await burst( + legacyBody().uuid, + getNotesImportConfig(h.env).activeImportCapSupporter, + () => + call('POST', '/notes-import', { + headers: legacyHeaders, + body: legacyBody(), + }) + ) + // Unlimited, so nothing counts against a window. + expect(usage(legacyBody().uuid)).toEqual({ + records: [ + { + hash: await sha256Hex('Field service 1 hour'), + charged: 0, + refinements: 0, + }, + ], + window: [], + }) + }) + + it('counts each identical refinement that ran, as before', async () => { + const session = await devSession() + const token = await iosToken(session) + const send = (fields: Record = {}) => + call('POST', '/notes-import', { + headers: bearer(token), + body: { notesText: 'Field service 1 hour', context: CONTEXT, ...fields }, + }) + expect((await send()).status).toBe(200) + mocks.runModel.mockClear() + const refinement = { + instruction: 'Make it two hours', + previousResult: RESULT, + } + const cap = getNotesImportConfig(h.env).activeImportCap + await burst(session.accountId, cap, () => send({ refinement })) + // Refinements count per run, so the cap now bounds how many count at once. + expect(usage(session.accountId)).toEqual({ + records: [ + { + hash: await sha256Hex('Field service 1 hour'), + charged: 1, + refinements: cap, + }, + ], + window: [{ v: 1 }], + }) + }) + + it('never shares a slot with a streaming run of the same content', async () => { + const session = await devSession() + const token = await iosToken(session) + const started = await kickoff(token) + expect(started.status).toBe(200) + const release = holdModel() + const sync = call('POST', '/notes-import', { + headers: bearer(token), + body: { notesText: 'Field service 1 hour', context: CONTEXT }, + }) + await vi.waitFor(() => expect(slots(session.accountId)).toHaveLength(2)) + // With the cap (2) full, a third import of the same notes waits. + const third = await call('POST', '/notes-import', { + headers: bearer(token), + body: { notesText: 'Field service 1 hour', context: CONTEXT }, + }) + expect(third.status).toBe(429) + release() + expect((await sync).status).toBe(200) + expect(slots(session.accountId)).toEqual([ + { importId: started.body.importId }, + ]) + }) + + it('drops a synchronous slot nobody released after 15 minutes, never a run’s', async () => { + const session = await devSession() + const send = async () => + call('POST', '/notes-import', { + headers: bearer(await iosToken(session)), + body: { notesText: 'Field service 1 hour', context: CONTEXT }, + }) + // A streaming run holds a slot until it settles (its alarm never fires + // here). + const started = await kickoff(await iosToken(session), { + notesText: 'Other notes', + }) + expect(started.status).toBe(200) + // A request whose client went away: its model run never returns, so its + // `finally` never releases its slot. + mocks.runModel.mockImplementation(() => new Promise(() => undefined)) + void send() + await vi.waitFor(() => expect(slots(session.accountId)).toHaveLength(2)) + const [{ startedAt }] = indexes + .storage(session.accountId) + .query( + "SELECT startedAt FROM active WHERE importId LIKE 'legacy:%'" + ) as { startedAt: number }[] + + mocks.runModel.mockResolvedValue(MODEL_OUTPUT) + vi.setSystemTime(startedAt + SYNC_SLOT_MAX_AGE_MS - 1) + expect((await send()).status).toBe(429) + vi.setSystemTime(startedAt + SYNC_SLOT_MAX_AGE_MS) + expect((await send()).status).toBe(200) + expect(slots(session.accountId)).toEqual([ + { importId: started.body.importId }, + ]) + }) + + it('ends a model call at its deadline with model_error, freeing its slot before it could age out', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + vi.setSystemTime(START) + const session = await devSession() + const token = await iosToken(session) + const send = () => + call('POST', '/notes-import', { + headers: bearer(token), + body: { notesText: 'Field service 1 hour', context: CONTEXT }, + }) + // A model that never answers and ignores its abort signal, in every + // slot of the cap. (Not `vi.waitFor`, which advances fake timers.) + const cap = getNotesImportConfig(h.env).activeImportCap + let allStarted!: () => void + const started = new Promise((resolve) => (allStarted = resolve)) + mocks.runModel.mockImplementation(() => { + if (mocks.runModel.mock.calls.length === cap) allStarted() + return new Promise(() => undefined) + }) + const settled: Reply[] = [] + const hung = Array.from({ length: cap }, () => + send().then((reply) => (settled.push(reply), reply)) + ) + await started + expect(Date.now()).toBe(START) + const signals = mocks.runModel.mock.calls.map( + ([args]) => (args as { abortSignal: AbortSignal }).abortSignal + ) + + // Up to the deadline the calls run and hold the cap. + await vi.advanceTimersByTimeAsync(SYNC_IMPORT_DEADLINE_MS - 1) + expect(settled).toEqual([]) + expect(slots(session.accountId)).toHaveLength(cap) + for (const signal of signals) expect(signal.aborted).toBe(false) + expect((await send()).status).toBe(429) + + await vi.advanceTimersByTimeAsync(1) + for (const reply of await Promise.all(hung)) { + expect(reply.status).toBe(502) + expect(reply.body).toMatchObject({ + error: 'The import model could not process these notes', + code: 'model_error', + }) + } + // The upstream request is aborted, and each slot released by its own + // request (an age-out would take SYNC_SLOT_MAX_AGE_MS and another + // acquire). + for (const signal of signals) { + expect(signal.aborted).toBe(true) + expect(signal.reason).toBeInstanceOf(ModelDeadlineError) + } + expect(Date.now() - START).toBeLessThan(SYNC_SLOT_MAX_AGE_MS) + expect(slots(session.accountId)).toEqual([]) + // Charged nothing, like any other model error. + expect( + indexes.storage(session.accountId).query('SELECT hash FROM hash_record') + ).toEqual([]) + await flush() + const finished = mocks.capture.mock.calls + .map( + (args) => + (args as unknown[])[1] as { + event: string + properties: Record + } + ) + .filter((event) => event.event === 'api_notes_import_finished') + expect(finished.map((event) => event.properties.outcome)).toEqual( + Array(cap).fill('model_error') + ) + + mocks.runModel.mockResolvedValue(MODEL_OUTPUT) + expect((await send()).status).toBe(200) + }) +}) + +describe('NotesImportIndex synchronous slots', () => { + it('ages out the content-keyed slots from before, too', async () => { + const index = indexes.object('meter') + // How the synchronous path keyed its slot before each request got its own. + index.acquire(`legacy:imp_${'a'.repeat(40)}`, 2) + expect(index.acquireSync(1)).toEqual({ ok: false, active: 1 }) + vi.setSystemTime(START + SYNC_SLOT_MAX_AGE_MS) + const taken = index.acquireSync(1) + expect(taken).toEqual({ + ok: true, + active: 1, + slot: expect.stringMatching(/^legacy:[0-9a-f-]{36}$/), + }) + index.release((taken as { slot: string }).slot) + expect(index.list()).toEqual([]) + }) +}) + +describe('Supporter status through revenueCatAppUserId', () => { + it('checks the named RevenueCat id at kickoff and again at settlement', async () => { + fetchMock.mockImplementation(async () => Response.json(LIFETIME)) + const session = await devSession() + const token = await iosToken(session) + const started = await kickoff(token, { revenueCatAppUserId: RC_ID }) + expect(started.status).toBe(200) + expect(started.body.credits).toMatchObject({ isSupporter: true, limit: null }) + expect(revenueCatIds()).toEqual([RC_ID]) + + await runs.fireAlarm(started.body.importId) + await flush() + expect(revenueCatIds()).toEqual([RC_ID, RC_ID]) + const result = await run(started.body.importId, 'result', token) + expect(result.body.payload.credits).toMatchObject({ isSupporter: true }) + }) + + it('treats a missing id, or one RevenueCat doesn’t know, as free', async () => { + const session = await devSession() + const token = await iosToken(session) + const started = await kickoff(token, { revenueCatAppUserId: RC_ID }) + expect(started.body.credits).toMatchObject({ isSupporter: false, limit: 3 }) + expect(revenueCatIds()).toEqual([RC_ID]) + }) + + it('never asks RevenueCat about the account id', async () => { + fetchMock.mockImplementation(async () => Response.json(LIFETIME)) + const session = await devSession() + const started = await kickoff(await iosToken(session)) + expect(started.body.credits.isSupporter).toBe(false) + expect(revenueCatIds()).toEqual([]) + }) +}) + +describe('dev sessions', () => { + it('get the dev bypass’s allowances on the dev worker, never Supporter status', async () => { + fetchMock.mockImplementation(async () => Response.json(LIFETIME)) + const session = await devSession() + const started = await kickoff(session.accessToken, { + revenueCatAppUserId: RC_ID, + }) + expect(started.body.credits).toMatchObject({ + isSupporter: false, + limit: null, + remaining: null, + }) + expect(revenueCatIds()).toEqual([]) + }) + + it('get ordinary allowances without the bypass token configured', async () => { + const session = await devSession() + delete (h.env as Partial).NOTES_IMPORT_DEV_BYPASS_TOKEN + const started = await kickoff(session.accessToken) + expect(started.body.credits).toMatchObject({ limit: 3 }) + }) + + it('ignore the bypass header on an iOS session', async () => { + const session = await devSession() + const started = await call('POST', '/notes-import/kickoff', { + headers: { ...bearer(await iosToken(session)), ...legacyHeaders }, + body: { notesText: 'Field service 1 hour', context: CONTEXT }, + }) + expect(started.body.credits).toMatchObject({ limit: 3 }) + }) +}) + +describe('run routes on a bearer import', () => { + it('streams, reads, and deletes the account’s own import', async () => { + const session = await devSession() + const importId = await importNotes(session.accessToken) + + const events = await run(importId, 'events', session.accessToken) + expect(events.status).toBe(200) + expect(events.headers.get('content-type')).toContain('text/event-stream') + expect(events.text).toContain('event: done') + + const result = await run(importId, 'result', session.accessToken) + expect(result.body.status).toBe('done') + const cancelled = await run(importId, 'cancel', session.accessToken) + expect(cancelled.body.status).toBe('done') + + const destroyed = await run(importId, 'destroy', session.accessToken) + expect(destroyed.status).toBe(200) + expect(destroyed.body).toEqual({ ok: true }) + const gone = await run(importId, 'result', session.accessToken) + expect(gone.status).toBe(404) + }) + + it('can run the same notes again after deleting them', async () => { + const session = await devSession() + const importId = await importNotes(session.accessToken) + await run(importId, 'destroy', session.accessToken) + // The same Durable Object instance, its tables dropped by deleteAll(). + expect(await importNotes(session.accessToken)).toBe(importId) + const result = await run(importId, 'result', session.accessToken) + expect(result.body.status).toBe('done') + }) + + it('answers another account the same 404 as an unknown import', async () => { + const owner = await devSession() + const stranger = await devSession() + const importId = await importNotes(owner.accessToken) + const unknown = `imp_${'0'.repeat(40)}` + + for (const op of ['events', 'result', 'cancel', 'destroy']) { + const foreign = await run(importId, op, stranger.accessToken) + const missing = await run(unknown, op, stranger.accessToken) + expect(foreign.status, op).toBe(404) + expect(foreign.body, op).toEqual({ + ok: false, + error: 'Import not found', + code: 'not_found', + }) + expect(missing.body, op).toEqual(foreign.body) + } + // The stranger's destroy touched nothing. + const still = await run(importId, 'result', owner.accessToken) + expect(still.body.status).toBe('done') + // Asking about an unknown import stored nothing. + expect(runs.storage(unknown).tables()).toEqual([]) + }) + + it('refuses a malformed id without waking a run', async () => { + const session = await devSession() + const response = await run('not-an-import', 'result', session.accessToken) + expect(response.status).toBe(404) + expect(response.body.code).toBe('not_found') + }) + + it('keeps legacy runs out of reach of bearer tokens, and the reverse', async () => { + const session = await devSession() + const legacy = await call('POST', '/notes-import/kickoff', { + headers: legacyHeaders, + body: legacyBody(), + }) + const { importId, subscribeToken } = legacy.body + expect((await run(importId, 'result', session.accessToken)).status).toBe(404) + const viaToken = await call( + 'GET', + `/notes-import/${importId}/result?token=${subscribeToken}` + ) + expect(viaToken.status).toBe(200) + + // A bearer request never reads `?token=`. + const mine = await importNotes(session.accessToken) + const withToken = await call( + 'GET', + `/notes-import/${mine}/result?token=${subscribeToken}`, + { headers: bearer('forged.token.value') } + ) + expect(withToken.status).toBe(401) + }) + + it('stores the owner as a hash, never the account id', async () => { + const session = await devSession() + const importId = await importNotes(session.accessToken) + const meta = runs.storage(importId).query('SELECT k, v FROM meta') + expect(meta).toContainEqual({ + k: 'owner', + v: await importOwnerKey(session.accountId), + }) + expect(JSON.stringify(meta)).not.toContain(session.accountId) + }) +}) + +describe('bearer transcribe', () => { + it('reads a page metered by the account, with no hashes or attestation', async () => { + mocks.transcribe.mockResolvedValue({ + text: 'Ana Silva | 12 Oak St', + usage: { inputTokens: 900, outputTokens: 20 }, + finishReason: 'stop', + }) + const session = await devSession() + const response = await call('POST', '/notes-import/transcribe', { + headers: bearer(await iosToken(session)), + body: { image: JPEG, mimeType: 'image/jpeg' }, + }) + expect(response.status).toBe(200) + expect(response.body).toEqual({ + ok: true, + text: 'Ana Silva | 12 Oak St', + truncated: false, + pagesRemaining: 39, + }) + expect( + indexes.storage(session.accountId).query('SELECT ts FROM transcribed_page') + ).toHaveLength(1) + }) + + it('uses the Supporter page limit for a named Supporter id', async () => { + fetchMock.mockImplementation(async () => Response.json(LIFETIME)) + mocks.transcribe.mockResolvedValue({ + text: 'x', + usage: {}, + finishReason: 'stop', + }) + const session = await devSession() + const response = await call('POST', '/notes-import/transcribe', { + headers: bearer(await iosToken(session)), + body: { image: JPEG, mimeType: 'image/jpeg', revenueCatAppUserId: RC_ID }, + }) + expect(response.body.pagesRemaining).toBe(199) + expect(revenueCatIds()).toEqual([RC_ID]) + }) + + it('needs only image and mimeType', async () => { + const session = await devSession() + const response = await call('POST', '/notes-import/transcribe', { + headers: bearer(session.accessToken), + body: { image: JPEG }, + }) + expect(response.status).toBe(400) + expect(response.body.error).toBe('Missing image or a supported mimeType') + }) +}) + +describe('admin usage reset', () => { + it('resets an account’s window by its account id', async () => { + ;(h.env as Environment & Record).ADMIN_API_TOKEN = + 'admin-token' + const session = await devSession() + const token = await iosToken(session) + await importNotes(token) + // Kickoff's `credits` previews the balance after this import succeeds. + expect((await kickoff(token, { notesText: 'More notes' })).body.credits) + .toMatchObject({ remaining: 1 }) + + const reset = await call('POST', '/admin/notes-import/reset', { + headers: { 'x-ww-admin-token': 'admin-token' }, + body: { meterId: session.accountId }, + }) + expect(reset.status).toBe(200) + expect((await kickoff(token, { notesText: 'Even more notes' })).body.credits) + .toMatchObject({ remaining: 2 }) + }) +}) + +describe('analytics', () => { + it('identifies a bearer import by its hashed account id', async () => { + const session = await devSession() + await importNotes(await iosToken(session)) + const started = mocks.capture.mock.calls + .map((args) => (args as unknown[])[1] as { event: string; distinctId: string; properties: Record }) + .find((event) => event.event === 'api_notes_import_started')! + expect(started.distinctId).toBe(await analyticsDistinctId(session.accountId)) + expect(started.distinctId).not.toContain(session.accountId) + expect(started.properties).toMatchObject({ + platform: 'ios', + has_account: true, + transport: 'stream', + }) + }) + + it('reports a dev session as platform dev', async () => { + const session = await devSession() + await importNotes(session.accessToken) + const events = mocks.capture.mock.calls.map( + (args) => (args as unknown[])[1] as { properties: Record } + ) + expect(events.length).toBeGreaterThan(0) + for (const event of events) expect(event.properties.platform).toBe('dev') + }) +}) + +describe('legacy requests', () => { + it('keep their contract while the legacy switch is on', async () => { + const response = await call('POST', '/notes-import/kickoff', { + headers: legacyHeaders, + body: legacyBody(), + }) + expect(response.status).toBe(200) + expect(Object.keys(response.body)).toEqual([ + 'importId', + 'subscribeToken', + 'refinement', + 'credits', + ]) + }) + + it('get 426 in Notes Import’s shape once the switch is off', async () => { + const session = await devSession() + const legacy = await call('POST', '/notes-import/kickoff', { + headers: legacyHeaders, + body: legacyBody(), + }) + await h.kv.put('auth:legacy', 'off') + + const refused = [ + await call('POST', '/notes-import/kickoff', { + headers: legacyHeaders, + body: legacyBody({ notesText: 'Other notes' }), + }), + await call('POST', '/notes-import', { + headers: legacyHeaders, + body: legacyBody(), + }), + await call('POST', '/notes-import/transcribe', { + headers: legacyHeaders, + body: { uuid: legacyBody().uuid, image: JPEG, mimeType: 'image/jpeg' }, + }), + await call( + 'GET', + `/notes-import/${legacy.body.importId}/result?token=${legacy.body.subscribeToken}` + ), + await call( + 'GET', + `/notes-import/${legacy.body.importId}/events?token=${legacy.body.subscribeToken}` + ), + ] + for (const response of refused) { + expect(response.status).toBe(426) + expect(response.body).toEqual({ + ok: false, + error: 'Update WitnessWork to keep using Notes Import', + code: 'upgrade_required', + }) + expect(response.headers.get('retry-after')).toBeNull() + } + expect(mocks.runModel).not.toHaveBeenCalled() + + // Bearer requests carry on, and status stays public. + expect((await kickoff(session.accessToken)).status).toBe(200) + expect((await call('GET', '/notes-import/status')).status).toBe(200) + }) +}) + +describe('GET /notes-import/status', () => { + it('answers a bearer request with the same public body', async () => { + const session = await devSession() + const anonymous = await call('GET', '/notes-import/status') + const withToken = await call('GET', '/notes-import/status', { + headers: bearer(session.accessToken), + }) + expect(withToken.status).toBe(200) + expect(withToken.body).toEqual(anonymous.body) + expect(JSON.stringify(withToken.body)).not.toContain('credits') + }) +}) diff --git a/src/notesImport/route.ts b/src/notesImport/route.ts index a08a8b5..8943d7a 100644 --- a/src/notesImport/route.ts +++ b/src/notesImport/route.ts @@ -12,7 +12,12 @@ import { } from './config' import { isSupporter, RevenueCatError } from '../revenuecat' import type { CreditDecision, CreditsSnapshot } from '../credits' -import { runNotesImportModel } from './llm' +import { + runNotesImportModel, + type RunModelArgs, + type RunModelOutput, +} from './llm' +import { SYNC_IMPORT_DEADLINE_MS } from './cap' import { base64DecodedBytes, isTranscribeMimeType, @@ -50,9 +55,20 @@ import { verifyV2Assertion, } from '../attestation/http' import { isEmptyImportResult, type NotesImportContext } from './schema' +import { + authenticateAccount, + hasAuthorization, + type AccountAuth, + type AccountAuthError, + type RequireAccountOptions, +} from '../accounts/auth' +import { isLegacyAuthEnabled } from '../accounts/legacySwitch' +import { isAccountSupporter } from '../accounts/supporter' +import { BEARER_CHALLENGE } from '../buddies/errorResponse' import { handleAdminResetRequest } from './admin' import { buildAllowanceDenial, + type NotesImportAccountKickoffResponse, type NotesImportKickoffResponse, } from './contracts' import type { NotesImportSuccess } from './events' @@ -106,6 +122,8 @@ const UNAVAILABLE_RETRY_AFTER_SECONDS = 30 const KILL_SWITCH_RETRY_AFTER_SECONDS = 60 /** A run usually settles within a minute; poll for a free slot meanwhile. */ const ACTIVE_CAP_RETRY_AFTER_SECONDS = 15 +/** A legacy request once the legacy switch is off. */ +const UPGRADE_REQUIRED = 426 /** * Notes Import's error response: the shared envelope (src/errors.ts) with the @@ -153,10 +171,122 @@ const errorDetail = (e: unknown): string => { const subKey = (token: string) => `notes-import:sub:${token}` +// --- Who's calling -------------------------------------------------------- + +/** + * Notes Import's caller (witness-work ADR 0027). A request with an + * `Authorization` header is a bearer request, always: its access token names + * the account, and the account is the meter. One without it is a legacy + * request (per-request attestation on the metered routes, the subscribe + * token on the run routes), served while the legacy switch is on and refused + * with 426 `upgrade_required` once it's off. + * + * Every refusal keeps Notes Import's error shape (`error` a message, `code` + * the stable code), which both kinds of client read; a 401 also sends + * `WWW-Authenticate`. + */ +type Caller = { account: AccountAuth | null } | { response: Response } + +const ACCOUNT_REFUSALS: Record< + AccountAuthError, + { status: number; message: string; code: string; retryAfter?: number } +> = { + unauthorized: { + status: HTTP_STATUS.UNAUTHORIZED, + message: 'The access token is missing, expired, or invalid', + code: 'unauthorized', + }, + suspended: { + status: HTTP_STATUS.FORBIDDEN, + message: 'This account is suspended', + code: 'suspended', + }, + disabled: { + status: HTTP_STATUS.SERVICE_UNAVAILABLE, + message: 'Accounts are temporarily unavailable', + code: 'disabled', + retryAfter: KILL_SWITCH_RETRY_AFTER_SECONDS, + }, + internal: { + status: HTTP_STATUS.INTERNAL_SERVER_ERROR, + message: 'Auth error', + code: 'server_error', + }, +} + +async function resolveCaller( + ctx: AppContext, + options: RequireAccountOptions = {} +): Promise { + if (hasAuthorization(ctx)) { + const result = await authenticateAccount(ctx, options) + if (result.ok) return { account: result.account } + const refusal = ACCOUNT_REFUSALS[result.error] + const response = err( + ctx, + refusal.status, + refusal.message, + refusal.code, + undefined, + undefined, + undefined, + undefined, + refusal.retryAfter + ) + if (result.error === 'unauthorized') + response.headers.set('WWW-Authenticate', BEARER_CHALLENGE) + return { response } + } + if (await isLegacyAuthEnabled(ctx.env)) return { account: null } + // No Retry-After: only updating the app helps. + return { + response: err( + ctx, + UPGRADE_REQUIRED, + 'Update WitnessWork to keep using Notes Import', + 'upgrade_required' + ), + } +} + +/** + * A `dev` session (opened with the dev bypass) stands in for the + * `x-ww-dev-bypass` header: unlimited allowances and debug detail, never + * Supporter status. Dev worker only: production never verifies a `dev` + * token, and this also needs the bypass token configured. + */ +const isDevSession = (config: NotesImportConfig, account: AccountAuth) => + account.platform === 'dev' && config.devBypassToken != null + +/** + * Names the account that started a bearer import. Stored on its run + * (`NotesImportRun.isOwnedBy`) for as long as the run is kept; a hash, so + * the run holds no account id once its input is dropped. + */ +export const importOwnerKey = (accountId: string): Promise => + sha256Hex(`ww-api:notes-import:owner:${accountId}`) + +/** + * A bearer request's optional `revenueCatAppUserId`: the id, null when + * absent, or undefined when malformed. The same shape as the legacy + * `accountId` it replaces: it becomes a RevenueCat URL path segment. + */ +const parseRevenueCatAppUserId = ( + record: Record +): string | null | undefined => { + const value = record.revenueCatAppUserId + if (value == null) return null + return typeof value === 'string' && isValidAccountId(value) + ? value + : undefined +} + /** * GET /notes-import/status — cheap availability probe for the app's import * entry points. No App Attest (it's a public health check) and no inference: * a manual KV kill-switch plus OpenRouter's free provider-health metadata. + * Public and the same for everyone: it never reads `Authorization` or + * reports an account's allowance. */ export async function handleNotesImportStatusRequest(ctx: AppContext) { const status = await getPublicNotesImportStatus(ctx.env, waitUntilOf(ctx)) @@ -165,9 +295,12 @@ export async function handleNotesImportStatusRequest(ctx: AppContext) { } interface NotesImportBody { + /** Legacy requests only. */ uuid: string - /** Shared account id (ADR 0011); absent on pre-account clients. */ + /** Shared account id (ADR 0011); absent on pre-account clients. Legacy only. */ accountId?: string + /** Bearer requests: the app's RevenueCat app user id, for the Supporter check. */ + revenueCatAppUserId?: string notesText: string contentHash: string requestHash?: string @@ -183,21 +316,32 @@ interface NotesImportBody { refinement?: { previousResultJSON: string; instruction: string } } +/** A bearer request's account, as the gate resolved it. */ +interface GateAccount { + accountId: string + /** `importOwnerKey(accountId)`. */ + ownerKey: string + /** What the request named for the Supporter check (`isAccountSupporter`). */ + revenueCatAppUserId: string | null +} + /** A request that passed authentication + the credit gate, ready to spend inference. */ interface GateOk { ok: true body: NotesImportBody - uuid: string /** - * The identity everything per-user keys on: the shared account id when the - * client sent one, else the per-device install uuid (old clients). Names - * the per-user index DO (credits + concurrency cap) and the RevenueCat - * subscriber — so a Supporter's second device, which adopted the purchasing - * device's account id, is recognized as that same RevenueCat customer - * instead of being metered as a free user. App Attest key pinning stays on - * `uuid`. + * The identity everything per-user keys on. A bearer request's is the + * session's account id. A legacy request's is the shared account id when + * the client sent one, else the per-device install uuid (old clients), and + * it's also the RevenueCat subscriber — so a Supporter's second device, + * which adopted the purchasing device's account id, is recognized as that + * same RevenueCat customer instead of being metered as a free user. Names + * the per-user index DO (credits + concurrency cap). App Attest key pinning + * stays on the install uuid. */ meterId: string + /** The bearer request's account; null on legacy requests. */ + account: GateAccount | null notesText: string contentHash: string supporter: boolean @@ -208,7 +352,10 @@ interface GateOk { windowDurationMs: number decision: CreditDecision isRefinement: boolean - /** True when the dev-bypass token was used (gates dev-only error detail). */ + /** + * The dev-bypass token was used, or the bearer session is `dev`: unlimited + * allowances, and gates dev-only error detail. + */ devBypass: boolean /** Usage-event context; the caller stamps `startedAt` when a run starts. */ analytics: Omit @@ -217,17 +364,21 @@ type GateResult = GateOk | { ok: false; response: Response } /** * The shared security boundary for the metered model call: parse + validate the - * body, recompute the authoritative content and request hashes, verify App - * Attest (or the dev bypass), resolve supporter status, and run the credit - * pre-flight. Both the streaming kickoff and legacy synchronous path use this - * boundary; v1 signs the content hash, while v2 also signs the canonical - * request hash. Returns either a ready-to-spend `GateOk` or a fully-formed - * error `Response`. + * body, authenticate, resolve supporter status, and run the credit pre-flight. + * Both the streaming kickoff and legacy synchronous path use this boundary. + * + * A bearer request (`account`) was authenticated by its access token: the + * session attested once, so the body carries no attestation fields and no + * hashes. A legacy request recomputes the authoritative content and request + * hashes and verifies App Attest, Play Integrity, or the dev bypass; v1 signs + * the content hash, while v2 also signs the canonical request hash. Returns + * either a ready-to-spend `GateOk` or a fully-formed error `Response`. */ async function authenticateAndGate( ctx: AppContext, config: NotesImportConfig, - assertionPurpose: AppAttestAssertionPurpose | null + assertionPurpose: AppAttestAssertionPurpose | null, + account: AccountAuth | null ): Promise { // Kill-switch / provider-health enforcement. `GET /notes-import/status` // advertises this so the app can disable its entry points BEFORE attesting, @@ -285,37 +436,43 @@ async function authenticateAndGate( } } const body = record as unknown as NotesImportBody - - const uuid = asString(body.uuid) const notesText = typeof body.notesText === 'string' ? body.notesText : null - if (!uuid || notesText == null || !body.context) { - return { - ok: false, - response: err( - ctx, - HTTP_STATUS.BAD_REQUEST, - 'Missing uuid, notesText, or context', - 'bad_request' - ), + const badRequest = (message: string): GateResult => ({ + ok: false, + response: err(ctx, HTTP_STATUS.BAD_REQUEST, message, 'bad_request'), + }) + + let uuid: string | null = null + let accountId: string | null = null + let revenueCatAppUserId: string | null = null + if (account) { + if (notesText == null || !body.context) { + return badRequest('Missing notesText or context') + } + const named = parseRevenueCatAppUserId(record) + if (named === undefined) return badRequest('Invalid revenueCatAppUserId') + revenueCatAppUserId = named + } else { + uuid = asString(body.uuid) + if (!uuid || notesText == null || !body.context) { + return badRequest('Missing uuid, notesText, or context') } - } - // Optional shared account id. Reject a malformed one outright rather than - // silently falling back to the uuid — a well-behaved client never sends one, - // and letting garbage through would put it in a DO name and a signed field. - const accountId = body.accountId != null ? asString(body.accountId) : null - if (body.accountId != null && (!accountId || !isValidAccountId(accountId))) { - return { - ok: false, - response: err( - ctx, - HTTP_STATUS.BAD_REQUEST, - 'Invalid accountId', - 'bad_request' - ), + // Optional shared account id. Reject a malformed one outright rather than + // silently falling back to the uuid — a well-behaved client never sends one, + // and letting garbage through would put it in a DO name and a signed field. + accountId = body.accountId != null ? asString(body.accountId) : null + if ( + body.accountId != null && + (!accountId || !isValidAccountId(accountId)) + ) { + return badRequest('Invalid accountId') } } + // Nothing to import: refuse before any attestation, credit, or model work. + if (notesText.trim() === '') return badRequest('notesText is empty') + if (notesText.length > config.maxChars) { return { ok: false, @@ -328,13 +485,158 @@ async function authenticateAndGate( } } - // The content hash is authoritative: recompute it and require the client's - // claimed hash to match, so the signed assertion provably covers exactly the - // text being parsed (and the meter keys on real content). + // The content hash is authoritative: the meter keys on real content, and a + // legacy request's signed assertion must cover exactly the text parsed. const contentHash = await sha256Hex(notesText) - if (asString(body.contentHash) && body.contentHash !== contentHash) { + + // --- Request authentication (the security boundary) ------------------- + let devBypass: boolean + if (account) { + devBypass = isDevSession(config, account) + } else { + const failure = await verifyLegacyImportRequest(ctx, config, { + record, + body, + uuid: uuid!, + accountId, + notesText, + contentHash, + assertionPurpose, + }) + if ('response' in failure) return { ok: false, response: failure.response } + devBypass = failure.devBypass + } + + // --- Supporter status + credit gate ----------------------------------- + // Everything below keys on the meter: the session's account, or on legacy + // requests the account id when the client sent one (per-person Supporter + // check, credits, caps — ADR 0011), else the install uuid. + const meterId = account ? account.accountId : (accountId ?? uuid!) + const gateAccount: GateAccount | null = account + ? { + accountId: account.accountId, + ownerKey: await importOwnerKey(account.accountId), + revenueCatAppUserId, + } + : null + + // Keep real entitlement distinct from dev bypass: bypass overrides allowance + // values only, while only an actual Supporter suppresses Supporter CTAs. + let supporter = false + if (!devBypass) { + try { + supporter = gateAccount + ? await isAccountSupporter(ctx.env, gateAccount) + : await isSupporter({ + apiKey: ctx.env.REVENUECAT_API_KEY, + appUserId: meterId, + entitlementId: config.entitlementId, + }) + } catch (e) { + if (!(e instanceof RevenueCatError)) Sentry.captureException(e) + // Fail closed to non-Supporter — the free meter still lets them import. + supporter = false + } + } + const supporterConcurrency = supporter || devBypass + const isRefinement = !!body.refinement + const analytics: GateOk['analytics'] = { + distinctId: await analyticsDistinctId(meterId), + platform: account + ? account.platform + : devBypass + ? 'dev' + : isPlayIntegrityRequest(record) + ? 'android' + : 'ios', + transport: assertionPurpose == null ? 'legacy' : 'stream', + refinement: isRefinement, + supporter, + hasAccount: account != null || accountId != null, + notesChars: notesText.length, + } + const limits = await resolveNotesImportLimits(ctx.env, ctx.env.NOTES_KV) + const allowances = selectEffectiveAllowances(limits, supporter, devBypass) + const windowDurationMs = limitsWindowDurationMs(limits) + + // Pre-flight the credit gate in the per-user index DO (single-threaded → + // strongly consistent, unlike the old KV read). The commit after a successful + // run goes through the same DO, so check and charge can't race. + const idxId = ctx.env.NOTES_IMPORT_INDEX.idFromName(meterId) + const checked = await ctx.env.NOTES_IMPORT_INDEX.get(idxId).checkCredit({ + hash: contentHash, + isSupporter: supporter, + isRefinement, + importLimit: allowances.imports, + refinementLimit: allowances.refinements, + windowDurationMs, + }) + if (!checked.decision.allowed) { + const denial = buildAllowanceDenial( + checked.decision.reason!, + checked.credits + ) + track( + ctx, + notesImportLimitReachedEvent( + analytics, + checked.decision.reason === 'refinement_limit' + ? 'refinements' + : 'imports' + ) + ) + // No Retry-After: neither denial frees with a short wait (the import + // window resets at `credits.resetsAt`; refinements never reset). return { ok: false, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + response: ctx.json({ ok: false, ...denial.body }, denial.status as any), + } + } + + return { + ok: true, + body, + meterId, + account: gateAccount, + notesText, + contentHash, + supporter, + supporterConcurrency, + importLimit: allowances.imports, + refinementLimit: allowances.refinements, + windowDurationMs, + decision: checked.decision, + isRefinement, + devBypass, + analytics, + } +} + +/** + * A legacy import request's own authentication: its claimed hashes, then App + * Attest, Play Integrity, or the dev-bypass header. Unchanged from before + * account sessions. + */ +async function verifyLegacyImportRequest( + ctx: AppContext, + config: NotesImportConfig, + request: { + record: Record + body: NotesImportBody + uuid: string + accountId: string | null + notesText: string + contentHash: string + assertionPurpose: AppAttestAssertionPurpose | null + } +): Promise<{ devBypass: boolean } | { response: Response }> { + const { record, body, uuid, accountId, notesText, contentHash } = request + const { assertionPurpose } = request + // Require the client's claimed hash to match, so the signed assertion + // provably covers exactly the text being parsed. + if (asString(body.contentHash) && body.contentHash !== contentHash) { + return { response: err( ctx, HTTP_STATUS.BAD_REQUEST, @@ -359,7 +661,6 @@ async function authenticateAndGate( requestHash !== expectedRequestHash ) { return { - ok: false, response: appAttestBadRequest( ctx, 'requestHash does not match the Notes Import payload' @@ -368,7 +669,6 @@ async function authenticateAndGate( } } - // --- Request authentication (the security boundary) ------------------- const bypassHeader = ctx.req.header('x-ww-dev-bypass') ?? '' const devBypass = config.devBypassToken != null && @@ -377,7 +677,6 @@ async function authenticateAndGate( if (!devBypass && isPlayIntegrityRequest(record)) { if (assertionPurpose == null) { return { - ok: false, response: err( ctx, HTTP_STATUS.BAD_REQUEST, @@ -398,7 +697,6 @@ async function authenticateAndGate( }) if (body.requestHash !== expectedRequestHash) { return { - ok: false, response: appAttestBadRequest( ctx, 'requestHash does not match the Notes Import payload' @@ -410,14 +708,13 @@ async function authenticateAndGate( record, assertionPurpose ) - if (failure) return { ok: false, response: failure } + if (failure) return { response: failure } } else if (!devBypass) { if ( assertionPurpose == null && appAttestProtocolVersion(body) === 2 ) { return { - ok: false, response: err( ctx, HTTP_STATUS.BAD_REQUEST, @@ -435,7 +732,6 @@ async function authenticateAndGate( const assertion = asString(body.assertion) if (!keyId || !challenge || !assertion) { return { - ok: false, response: err( ctx, HTTP_STATUS.UNAUTHORIZED, @@ -457,7 +753,6 @@ async function authenticateAndGate( : null if (protocolVersion === 2 && !v2Request) { return { - ok: false, response: appAttestBadRequest( ctx, 'Invalid App Attest v2 protected assertion' @@ -477,14 +772,10 @@ async function authenticateAndGate( } } catch (e) { if (e instanceof AppAttestError) { - return { - ok: false, - response: appAttestFailureResponse(ctx, e, body), - } + return { response: appAttestFailureResponse(ctx, e, body) } } Sentry.captureException(e) return { - ok: false, response: err( ctx, HTTP_STATUS.INTERNAL_SERVER_ERROR, @@ -494,99 +785,7 @@ async function authenticateAndGate( } } } - - // --- Supporter status + credit gate ----------------------------------- - // Everything below keys on the account id when the client sent one (per- - // person Supporter check, credits, caps — ADR 0011), else the install uuid. - const meterId = accountId ?? uuid - - // Keep real entitlement distinct from dev bypass: bypass overrides allowance - // values only, while only an actual Supporter suppresses Supporter CTAs. - let supporter = false - if (!devBypass) { - try { - supporter = await isSupporter({ - apiKey: ctx.env.REVENUECAT_API_KEY, - appUserId: meterId, - entitlementId: config.entitlementId, - }) - } catch (e) { - if (!(e instanceof RevenueCatError)) Sentry.captureException(e) - // Fail closed to non-Supporter — the free meter still lets them import. - supporter = false - } - } - const supporterConcurrency = supporter || devBypass - const isRefinement = !!body.refinement - const analytics: GateOk['analytics'] = { - distinctId: await analyticsDistinctId(meterId), - platform: devBypass - ? 'dev' - : isPlayIntegrityRequest(record) - ? 'android' - : 'ios', - transport: assertionPurpose == null ? 'legacy' : 'stream', - refinement: isRefinement, - supporter, - hasAccount: accountId != null, - notesChars: notesText.length, - } - const limits = await resolveNotesImportLimits(ctx.env, ctx.env.NOTES_KV) - const allowances = selectEffectiveAllowances(limits, supporter, devBypass) - const windowDurationMs = limitsWindowDurationMs(limits) - - // Pre-flight the credit gate in the per-user index DO (single-threaded → - // strongly consistent, unlike the old KV read). The commit after a successful - // run goes through the same DO, so check and charge can't race. - const idxId = ctx.env.NOTES_IMPORT_INDEX.idFromName(meterId) - const checked = await ctx.env.NOTES_IMPORT_INDEX.get(idxId).checkCredit({ - hash: contentHash, - isSupporter: supporter, - isRefinement, - importLimit: allowances.imports, - refinementLimit: allowances.refinements, - windowDurationMs, - }) - if (!checked.decision.allowed) { - const denial = buildAllowanceDenial( - checked.decision.reason!, - checked.credits - ) - track( - ctx, - notesImportLimitReachedEvent( - analytics, - checked.decision.reason === 'refinement_limit' - ? 'refinements' - : 'imports' - ) - ) - // No Retry-After: neither denial frees with a short wait (the import - // window resets at `credits.resetsAt`; refinements never reset). - return { - ok: false, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - response: ctx.json({ ok: false, ...denial.body }, denial.status as any), - } - } - - return { - ok: true, - body, - uuid, - meterId, - notesText, - contentHash, - supporter, - supporterConcurrency, - importLimit: allowances.imports, - refinementLimit: allowances.refinements, - windowDurationMs, - decision: checked.decision, - isRefinement, - devBypass, - analytics, - } + return { devBypass } } /** @@ -596,15 +795,27 @@ async function authenticateAndGate( * Keyed on the meter identity (account id when present) so a person's devices * share replay idempotency, and so an import's slot always lives in the same * index DO that acquired it. + * + * A bearer import's basis is `account::` (plus + * `:r:`), which never contains `|`. Every legacy + * basis does, and a legacy uuid can be any string, so this keeps a legacy + * request from ever landing on (and getting a subscribe token for) an + * account's run. */ const deriveImportId = async ( - meterId: string, - contentHash: string, - refinement?: { instruction: string } + gate: Pick ): Promise => { - const basis = refinement - ? `${meterId}|${contentHash}|r|${refinement.instruction}` - : `${meterId}|${contentHash}` + const { meterId, account, contentHash } = gate + const refinement = gate.body.refinement + let basis: string + if (account) { + basis = `account:${account.accountId}:${contentHash}` + if (refinement) basis += `:r:${await sha256Hex(refinement.instruction)}` + } else { + basis = refinement + ? `${meterId}|${contentHash}|r|${refinement.instruction}` + : `${meterId}|${contentHash}` + } return `imp_${(await sha256Hex(basis)).slice(0, 40)}` } @@ -612,15 +823,20 @@ const deriveImportId = async ( * POST /notes-import/kickoff — attested, metered entry point for the STREAMING * import. Authenticates + gates exactly like the legacy path, enforces the * per-user concurrency cap, starts a background run in a per-import Durable - * Object, and returns immediately with `{ importId, subscribeToken }`. The - * client then opens the SSE stream at `/notes-import/:importId/events`. + * Object, and returns immediately with `{ importId, subscribeToken }` (a + * bearer request gets no subscribe token: its run routes take the access + * token). The client then opens the SSE stream at + * `/notes-import/:importId/events`. */ export async function handleNotesImportKickoffRequest(ctx: AppContext) { + const caller = await resolveCaller(ctx, { requireActive: true }) + if ('response' in caller) return caller.response const config = getNotesImportConfig(ctx.env) const gate = await authenticateAndGate( ctx, config, - NOTES_IMPORT_KICKOFF_PURPOSE + NOTES_IMPORT_KICKOFF_PURPOSE, + caller.account ) if (!gate.ok) return gate.response const { @@ -637,7 +853,7 @@ export async function handleNotesImportKickoffRequest(ctx: AppContext) { isRefinement, } = gate - const importId = await deriveImportId(meterId, contentHash, body.refinement) + const importId = await deriveImportId(gate) // Per-user concurrency cap, enforced race-free in the single-threaded index DO. const cap = supporterConcurrency @@ -681,6 +897,12 @@ export async function handleNotesImportKickoffRequest(ctx: AppContext) { devBypass: gate.devBypass, decision, analytics, + ...(gate.account && { + account: { + ownerKey: gate.account.ownerKey, + revenueCatAppUserId: gate.account.revenueCatAppUserId, + }, + }), }) } catch (e) { // Couldn't start → release the slot so it isn't leaked. @@ -713,11 +935,15 @@ export async function handleNotesImportKickoffRequest(ctx: AppContext) { // Short-lived capability for the stream. App Attest can't sign a long-lived // SSE connection; it doesn't need to — the abusable operation (inference) was - // already gated above, so the stream just reads an authorized resource. - const subscribeToken = randomToken() - await ctx.env.NOTES_KV.put(subKey(subscribeToken), importId, { - expirationTtl: config.subscribeTokenTtlSeconds, - }) + // already gated above, so the stream just reads an authorized resource. A + // bearer request needs none: its run routes check the run's owner instead. + let subscribeToken: string | null = null + if (!gate.account) { + subscribeToken = randomToken() + await ctx.env.NOTES_KV.put(subKey(subscribeToken), importId, { + expirationTtl: config.subscribeTokenTtlSeconds, + }) + } // Point-in-time usage preview. It writes nothing and can differ from terminal // state after success, time/config/entitlement changes, or another commit. @@ -730,6 +956,14 @@ export async function handleNotesImportKickoffRequest(ctx: AppContext) { windowDurationMs, }) + if (subscribeToken == null) { + const response = { + importId, + refinement: isRefinement, + credits, + } satisfies NotesImportAccountKickoffResponse + return ctx.json(response) + } const response = { importId, subscribeToken, @@ -739,7 +973,39 @@ export async function handleNotesImportKickoffRequest(ctx: AppContext) { return ctx.json(response) } -/** Resolve + authorize a stream request to its run DO, or return an error Response. */ +/** Every import id kickoff issues. */ +const IMPORT_ID_PATTERN = /^imp_[a-f0-9]{40}$/ + +/** + * Resolve + authorize a run route's import, or return an error Response. A + * bearer request may use only an import its own account started; anything + * else (another account's, a legacy one, unknown, or past retention) is the + * same 404, so a response never says whether an import exists. A legacy + * request shows its subscribe token, as before. + */ +async function authorizeRun( + ctx: AppContext +): Promise<{ importId: string } | { response: Response }> { + const caller = await resolveCaller(ctx) + if ('response' in caller) return caller + if (!caller.account) return authorizeStream(ctx) + + const importId = ctx.req.param('importId') ?? '' + const notFound = () => ({ + response: err(ctx, HTTP_STATUS.NOT_FOUND, 'Import not found', 'not_found'), + }) + // Don't wake a Durable Object for a name kickoff never issues. + if (!IMPORT_ID_PATTERN.test(importId)) return notFound() + const run = ctx.env.NOTES_IMPORT_RUN.get( + ctx.env.NOTES_IMPORT_RUN.idFromName(importId) + ) + const owned = await run.isOwnedBy( + await importOwnerKey(caller.account.accountId) + ) + return owned ? { importId } : notFound() +} + +/** Resolve + authorize a legacy stream request (subscribe token) to its run DO. */ async function authorizeStream( ctx: AppContext ): Promise<{ importId: string } | { response: Response }> { @@ -775,7 +1041,7 @@ async function authorizeStream( * event log since `Last-Event-ID` then tails live (lossless resume). */ export async function handleNotesImportEventsRequest(ctx: AppContext) { - const authed = await authorizeStream(ctx) + const authed = await authorizeRun(ctx) if ('response' in authed) return authed.response const lastEventId = @@ -797,7 +1063,7 @@ export async function handleNotesImportEventsRequest(ctx: AppContext) { * the client's "edit & resend" flow before kicking off the edited import. */ export async function handleNotesImportCancelRequest(ctx: AppContext) { - const authed = await authorizeStream(ctx) + const authed = await authorizeRun(ctx) if ('response' in authed) return authed.response const runId = ctx.env.NOTES_IMPORT_RUN.idFromName(authed.importId) @@ -813,7 +1079,7 @@ export async function handleNotesImportCancelRequest(ctx: AppContext) { * waiting for the retention alarm. Backs the history view's per-row delete. */ export async function handleNotesImportDestroyRequest(ctx: AppContext) { - const authed = await authorizeStream(ctx) + const authed = await authorizeRun(ctx) if ('response' in authed) return authed.response const runId = ctx.env.NOTES_IMPORT_RUN.idFromName(authed.importId) @@ -827,7 +1093,7 @@ export async function handleNotesImportDestroyRequest(ctx: AppContext) { * capability auth as the stream. */ export async function handleNotesImportResultRequest(ctx: AppContext) { - const authed = await authorizeStream(ctx) + const authed = await authorizeRun(ctx) if ('response' in authed) return authed.response const runId = ctx.env.NOTES_IMPORT_RUN.idFromName(authed.importId) @@ -850,6 +1116,41 @@ export function handleNotesImportAdminResetRequest(ctx: AppContext) { }) } +/** The synchronous path's model call ran past `SYNC_IMPORT_DEADLINE_MS`. */ +export class ModelDeadlineError extends Error { + override name = 'ModelDeadlineError' +} + +/** + * The synchronous path's model call, under `SYNC_IMPORT_DEADLINE_MS`. At the + * deadline it aborts the call (ending the upstream request) and rejects with + * a `ModelDeadlineError` at once, whether or not the call honours the + * signal, so the request answers and releases its slot before the slot can + * age out. + */ +async function runModelBeforeDeadline( + args: Omit +): Promise { + const aborter = new AbortController() + let passDeadline!: (error: ModelDeadlineError) => void + const deadline = new Promise((_, reject) => (passDeadline = reject)) + const timer = setTimeout(() => { + const error = new ModelDeadlineError( + `model call passed its ${SYNC_IMPORT_DEADLINE_MS / 1000}s deadline` + ) + aborter.abort(error) + passDeadline(error) + }, SYNC_IMPORT_DEADLINE_MS) + const model = runNotesImportModel({ ...args, abortSignal: aborter.signal }) + // Past the deadline nothing waits on the call, so its own rejection is moot. + model.catch(() => undefined) + try { + return await Promise.race([model, deadline]) + } finally { + clearTimeout(timer) + } +} + /** * POST /notes-import — LEGACY synchronous path. Held open for the full model run * and returns the result in one response. Kept as a fallback during the @@ -857,8 +1158,10 @@ export function handleNotesImportAdminResetRequest(ctx: AppContext) { * auth/credit/ZDR semantics as kickoff via the shared gate. */ export async function handleNotesImportRequest(ctx: AppContext) { + const caller = await resolveCaller(ctx, { requireActive: true }) + if ('response' in caller) return caller.response const config = getNotesImportConfig(ctx.env) - const gate = await authenticateAndGate(ctx, config, null) + const gate = await authenticateAndGate(ctx, config, null, caller.account) if (!gate.ok) return gate.response const { meterId, @@ -871,23 +1174,22 @@ export async function handleNotesImportRequest(ctx: AppContext) { isRefinement, } = gate - // The legacy path has no run DO, so it must enforce the same per-user - // concurrency cap itself — without it a single-second burst of DISTINCT-hash - // requests races the meter (the very bypass this fix closes). Namespace the - // slot key ('legacy:') so it can NEVER collide with a streaming run's - // importId: a run DO releases by the bare importId, so a shared key would let - // this path's finally-release drop a live streaming run's slot. Distinct keys - // keep the two paths independent; a duplicate-content legacy burst still - // collapses to one slot (acquire is idempotent for a held id), while the - // distinct-hash attack takes one slot each and is capped. Synchronous path → - // a plain try/finally release (no run DO to hand the slot off to). + // The synchronous path has no run DO, so it enforces the same per-user + // concurrency cap itself, and every request runs the model, so every + // request takes a slot of its own (`acquireSync`): a burst of requests, + // identical or not, is capped. Its keys ('legacy:') never collide + // with a streaming run's importId, which a run DO releases by. Charging is + // per content hash in recordUsage, so identical requests still charge once. + // A plain try/finally release (no run DO to hand the slot off to); a + // disconnected client's slot ages out instead (SYNC_SLOT_MAX_AGE_MS). The + // model call ends at SYNC_IMPORT_DEADLINE_MS, before that, so a request + // still running never loses its slot to the age limit. const cap = supporterConcurrency ? config.activeImportCapSupporter : config.activeImportCap const idxId = ctx.env.NOTES_IMPORT_INDEX.idFromName(meterId) const idx = ctx.env.NOTES_IMPORT_INDEX.get(idxId) - const slotKey = `legacy:${await deriveImportId(meterId, contentHash, body.refinement)}` - const acquired = await idx.acquire(slotKey, cap) + const acquired = await idx.acquireSync(cap) if (!acquired.ok) { track(ctx, notesImportLimitReachedEvent(gate.analytics, 'active_cap')) return err( @@ -912,7 +1214,7 @@ export async function handleNotesImportRequest(ctx: AppContext) { try { let output try { - output = await runNotesImportModel({ + output = await runModelBeforeDeadline({ apiKey: ctx.env.OPENROUTER_API_KEY, config, notesText, @@ -920,6 +1222,8 @@ export async function handleNotesImportRequest(ctx: AppContext) { refinement: body.refinement, }) } catch (e) { + // A call past its deadline is a model error like any other: nothing + // is charged, and the slot is released below. Sentry.captureException(e) console.error('notes-import model_error', errorDetail(e)) track( @@ -943,6 +1247,7 @@ export async function handleNotesImportRequest(ctx: AppContext) { apiKey: ctx.env.REVENUECAT_API_KEY, appUserId: meterId, entitlementId: config.entitlementId, + account: gate.account ?? undefined, }) const terminalLimits = await resolveNotesImportLimits( ctx.env, @@ -986,7 +1291,7 @@ export async function handleNotesImportRequest(ctx: AppContext) { } satisfies NotesImportSuccess return ctx.json(response) } finally { - await idx.release(slotKey) + await idx.release(acquired.slot) } } @@ -1001,6 +1306,9 @@ const TRANSCRIBE_WINDOW_MS = 24 * 60 * 60 * 1000 * and a free user with no imports left is refused before any page is read. */ export async function handleNotesImportTranscribeRequest(ctx: AppContext) { + const caller = await resolveCaller(ctx, { requireActive: true }) + if ('response' in caller) return caller.response + const { account } = caller const config = getNotesImportConfig(ctx.env) const status = await getNotesImportStatus({ kv: ctx.env.NOTES_KV, @@ -1035,20 +1343,47 @@ export async function handleNotesImportTranscribeRequest(ctx: AppContext) { return err(ctx, HTTP_STATUS.BAD_REQUEST, 'Invalid JSON body', 'bad_request') } - const uuid = asString(record.uuid) const image = asString(record.image) const mimeType = record.mimeType - if (!uuid || !image || !isTranscribeMimeType(mimeType)) { - return err( - ctx, - HTTP_STATUS.BAD_REQUEST, - 'Missing uuid, image, or a supported mimeType', - 'bad_request' - ) - } - const accountId = record.accountId != null ? asString(record.accountId) : null - if (record.accountId != null && (!accountId || !isValidAccountId(accountId))) { - return err(ctx, HTTP_STATUS.BAD_REQUEST, 'Invalid accountId', 'bad_request') + let uuid: string | null = null + let accountId: string | null = null + let revenueCatAppUserId: string | null = null + if (account) { + if (!image || !isTranscribeMimeType(mimeType)) { + return err( + ctx, + HTTP_STATUS.BAD_REQUEST, + 'Missing image or a supported mimeType', + 'bad_request' + ) + } + const named = parseRevenueCatAppUserId(record) + if (named === undefined) { + return err( + ctx, + HTTP_STATUS.BAD_REQUEST, + 'Invalid revenueCatAppUserId', + 'bad_request' + ) + } + revenueCatAppUserId = named + } else { + uuid = asString(record.uuid) + if (!uuid || !image || !isTranscribeMimeType(mimeType)) { + return err( + ctx, + HTTP_STATUS.BAD_REQUEST, + 'Missing uuid, image, or a supported mimeType', + 'bad_request' + ) + } + accountId = record.accountId != null ? asString(record.accountId) : null + if ( + record.accountId != null && + (!accountId || !isValidAccountId(accountId)) + ) { + return err(ctx, HTTP_STATUS.BAD_REQUEST, 'Invalid accountId', 'bad_request') + } } const bytes = base64DecodedBytes(image) @@ -1069,45 +1404,59 @@ export async function handleNotesImportTranscribeRequest(ctx: AppContext) { ) } - // Both hashes are authoritative: the signed assertion must cover exactly - // this image and mime type. const contentHash = await sha256Hex(image) - const requestHash = await computeNotesImportTranscribeRequestHash({ - imageHash: contentHash, - mimeType, - }) - if (record.contentHash !== contentHash || record.requestHash !== requestHash) { - return appAttestBadRequest( - ctx, - 'contentHash or requestHash does not match the transcription payload' - ) - } + let devBypass: boolean + if (account) { + // The session attested once; a bearer request carries no hashes. + devBypass = isDevSession(config, account) + } else { + // Both hashes are authoritative: the signed assertion must cover exactly + // this image and mime type. + const requestHash = await computeNotesImportTranscribeRequestHash({ + imageHash: contentHash, + mimeType, + }) + if ( + record.contentHash !== contentHash || + record.requestHash !== requestHash + ) { + return appAttestBadRequest( + ctx, + 'contentHash or requestHash does not match the transcription payload' + ) + } - // --- Request authentication (v2 App Attest or Play Integrity only) ---- - const bypassHeader = ctx.req.header('x-ww-dev-bypass') ?? '' - const devBypass = - config.devBypassToken != null && - timingSafeEqual(bypassHeader, config.devBypassToken) - if (!devBypass) { - const failure = await requireAttestation( - ctx, - record, - NOTES_IMPORT_TRANSCRIBE_PURPOSE, - 'Page transcription requires App Attest v2 or Play Integrity' - ) - if (failure) return failure + // --- Request authentication (v2 App Attest or Play Integrity only) -- + const bypassHeader = ctx.req.header('x-ww-dev-bypass') ?? '' + devBypass = + config.devBypassToken != null && + timingSafeEqual(bypassHeader, config.devBypassToken) + if (!devBypass) { + const failure = await requireAttestation( + ctx, + record, + NOTES_IMPORT_TRANSCRIBE_PURPOSE, + 'Page transcription requires App Attest v2 or Play Integrity' + ) + if (failure) return failure + } } // --- Entitlement, import allowance, and page limit -------------------- - const meterId = accountId ?? uuid + const meterId = account ? account.accountId : (accountId ?? uuid!) let supporter = false if (!devBypass) { try { - supporter = await isSupporter({ - apiKey: ctx.env.REVENUECAT_API_KEY, - appUserId: meterId, - entitlementId: config.entitlementId, - }) + supporter = account + ? await isAccountSupporter(ctx.env, { + accountId: account.accountId, + revenueCatAppUserId, + }) + : await isSupporter({ + apiKey: ctx.env.REVENUECAT_API_KEY, + appUserId: meterId, + entitlementId: config.entitlementId, + }) } catch (e) { if (!(e instanceof RevenueCatError)) Sentry.captureException(e) supporter = false diff --git a/src/notesImport/runDO.test.ts b/src/notesImport/runDO.test.ts index 3b28093..9b193f8 100644 --- a/src/notesImport/runDO.test.ts +++ b/src/notesImport/runDO.test.ts @@ -89,6 +89,8 @@ interface FakeRunState { meta: Map events: Array<{ seq: number; type: string; data: string }> alarms: number[] + /** Whether `meta` and `events` exist (created on the first write). */ + tables: boolean } const fakeContext = (state: FakeRunState): DurableObjectState => @@ -97,7 +99,15 @@ const fakeContext = (state: FakeRunState): DurableObjectState => storage: { sql: { exec: (query: string, ...args: unknown[]) => { - if (query.startsWith('CREATE TABLE')) return cursor([]) + if (query.startsWith('CREATE TABLE')) { + state.tables = true + return cursor([]) + } + if (query.startsWith('SELECT name FROM sqlite_master')) { + return cursor(state.tables ? [{ name: 'meta' }] : []) + } + // Like SQLite: deleteAll() drops the tables with the rows. + if (!state.tables) throw new Error('no such table') if (query === 'DELETE FROM events') { state.events = [] return cursor([]) @@ -139,6 +149,7 @@ const fakeContext = (state: FakeRunState): DurableObjectState => deleteAll: vi.fn(async () => { state.meta.clear() state.events = [] + state.tables = false }), }, waitUntil: vi.fn(), @@ -148,7 +159,12 @@ const makeRun = async ( recordUsage: ReturnType, extraEnv: Partial = {} ) => { - const state: FakeRunState = { meta: new Map(), events: [], alarms: [] } + const state: FakeRunState = { + meta: new Map(), + events: [], + alarms: [], + tables: false, + } const index = { recordUsage, release: vi.fn(async () => undefined), @@ -279,6 +295,47 @@ describe('NotesImportRun cancellation and destruction settlement', () => { }) }) +describe('NotesImportRun storage', () => { + it('stores nothing when asked about an import that never started', async () => { + const { run, state } = await makeRun(vi.fn()) + expect(run.getResult()).toEqual({ status: null }) + await expect(run.cancel()).resolves.toEqual({ status: null }) + await run.alarm() + expect(state.tables).toBe(false) + }) + + it('starts the same import again on the instance that destroyed it', async () => { + const recordUsage = vi.fn(async () => ({ + credits: CREDITS, + emptyCharged: false, + })) + mocks.resolveTerminalSupporter.mockResolvedValue(false) + const { run, state } = await makeRun(recordUsage) + await run.start(INPUT) + await run.destroy() + expect(state.tables).toBe(false) + + await expect(run.start(INPUT)).resolves.toBe('started') + await run.alarm() + expect(run.getResult()).toMatchObject({ status: 'done' }) + }) + + it('starts again after the retention alarm wiped a finished run', async () => { + const recordUsage = vi.fn(async () => ({ + credits: CREDITS, + emptyCharged: false, + })) + mocks.resolveTerminalSupporter.mockResolvedValue(false) + const { run } = await makeRun(recordUsage) + await run.start(INPUT) + await run.alarm() + await run.alarm() + expect(run.getResult()).toEqual({ status: null }) + + await expect(run.start(INPUT)).resolves.toBe('started') + }) +}) + describe('NotesImportRun analytics', () => { const TRACKED = { POSTHOG_PROJECT_TOKEN: 'phc_test' } const ANALYTICS = { diff --git a/src/notesImport/runDO.ts b/src/notesImport/runDO.ts index 6b34fb2..dacc6ca 100644 --- a/src/notesImport/runDO.ts +++ b/src/notesImport/runDO.ts @@ -38,10 +38,11 @@ import { export interface StartImportInput { importId: string /** - * The METER identity, not necessarily the device's install uuid: the shared - * account id when the client sent one (ADR 0011), else the install uuid. - * Only ever used to name the per-user index DO (slot release + credit - * commit), so it must match whatever identity the kickoff acquired under. + * The METER identity, not necessarily the device's install uuid: the + * session's account id on a bearer run, else the shared account id when the + * client sent one (ADR 0011), else the install uuid. Names the per-user + * index DO (slot release + credit commit), so it must match whatever + * identity the kickoff acquired under. */ uuid: string contentHash: string @@ -55,6 +56,16 @@ export interface StartImportInput { decision: CreditDecision /** Usage-event context; absent on runs kicked off before analytics shipped. */ analytics?: NotesImportAnalyticsContext + /** + * Set on runs started with an account session's bearer token; absent on + * legacy runs. `uuid` is then the account id. + */ + account?: { + /** `importOwnerKey(accountId)`, kept as the run's owner until it's deleted. */ + ownerKey: string + /** The RevenueCat app user id the kickoff named, or null (not a Supporter). */ + revenueCatAppUserId: string | null + } } export interface RunResultSnapshot { @@ -123,19 +134,12 @@ export class NotesImportRun extends DurableObject { */ #settlement: Promise | null = null - constructor(ctx: DurableObjectState, env: Environment) { - super(ctx, env) - ctx.storage.sql.exec( - `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)` - ) - ctx.storage.sql.exec( - `CREATE TABLE IF NOT EXISTS events ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - data TEXT NOT NULL - )` - ) - } + /** + * The tables exist. They're created on the first write, not in the + * constructor, so a read of an unknown import stores nothing, and reset + * after `deleteAll()`, which drops them. + */ + #schemaReady = false // --- RPC surface -------------------------------------------------------- @@ -160,8 +164,13 @@ export class NotesImportRun extends DurableObject { // Fresh, or retrying a prior failure → reset and schedule the run. Awaiting // setAlarm means the kickoff's `await start()` guarantees the run is durably // scheduled before it mints the subscribe token and replies. + this.#ensureSchema() this.ctx.storage.sql.exec('DELETE FROM events') this.#metaSet('input', JSON.stringify(input)) + // The owner outlives `input` (dropped once terminal): bearer routes check + // it for as long as the result is kept. + if (input.account) this.#metaSet('owner', input.account.ownerKey) + else this.#metaDel('owner') this.#metaDel('result') this.#metaDel('error') this.#setStatus('queued') @@ -169,6 +178,16 @@ export class NotesImportRun extends DurableObject { return 'started' } + /** + * Whether a bearer request for `ownerKey`'s account may use this import: + * only a run that account started, until the run is deleted. A read, so + * asking about an unknown import stores nothing. + */ + isOwnedBy(ownerKey: string): boolean { + const owner = this.#metaGet('owner') + return owner != null && owner === ownerKey + } + /** Snapshot for a client that reconnects after the live stream has closed. */ getResult(): RunResultSnapshot { const status = this.#status() @@ -261,7 +280,7 @@ export class NotesImportRun extends DurableObject { for (const s of this.#subscribers) s.close() this.#subscribers.clear() this.#stopHeartbeat() - await this.ctx.storage.deleteAll() + await this.#deleteAll() } // --- Background execution ---------------------------------------------- @@ -271,7 +290,7 @@ export class NotesImportRun extends DurableObject { if (status === null) return if (status === 'done' || status === 'error' || status === 'cancelled') { // Retention window elapsed → evict all state. - await this.ctx.storage.deleteAll() + await this.#deleteAll() return } if (status !== 'queued') { @@ -345,6 +364,10 @@ export class NotesImportRun extends DurableObject { apiKey: this.env.REVENUECAT_API_KEY, appUserId: input.uuid, entitlementId: config.entitlementId, + account: input.account && { + accountId: input.uuid, + revenueCatAppUserId: input.account.revenueCatAppUserId, + }, }) const limits = await resolveNotesImportLimits(this.env, this.env.NOTES_KV) const allowances = selectEffectiveAllowances( @@ -586,12 +609,14 @@ export class NotesImportRun extends DurableObject { // Replay everything since the client's cursor (lossless resume). Guarded // because the client may disconnect mid-replay. try { - const rows = this.ctx.storage.sql - .exec<{ seq: number; data: string }>( - 'SELECT seq, data FROM events WHERE seq > ? ORDER BY seq', - cursor - ) - .toArray() + const rows = this.#hasSchema() + ? this.ctx.storage.sql + .exec<{ seq: number; data: string }>( + 'SELECT seq, data FROM events WHERE seq > ? ORDER BY seq', + cursor + ) + .toArray() + : [] for (const r of rows) { const ev = { ...JSON.parse(r.data), seq: r.seq } as ImportEvent sub.send(encoder.encode(formatSSE(ev))) @@ -634,6 +659,7 @@ export class NotesImportRun extends DurableObject { /** Persist an event to the replay log, then broadcast it with its resume seq. */ #emit(ev: UnsequencedEvent): void { + this.#ensureSchema() const row = this.ctx.storage.sql .exec<{ seq: number }>( 'INSERT INTO events (type, data) VALUES (?, ?) RETURNING seq', @@ -688,6 +714,39 @@ export class NotesImportRun extends DurableObject { this.#heartbeatTimer = null } + // --- storage helpers ---------------------------------------------------- + + #ensureSchema(): void { + if (this.#schemaReady) return + this.ctx.storage.sql.exec( + `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)` + ) + this.ctx.storage.sql.exec( + `CREATE TABLE IF NOT EXISTS events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + data TEXT NOT NULL + )` + ) + this.#schemaReady = true + } + + /** Whether the tables exist, without creating them. */ + #hasSchema(): boolean { + if (this.#schemaReady) return true + const tables = this.ctx.storage.sql.exec( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'` + ) + this.#schemaReady = tables.toArray().length > 0 + return this.#schemaReady + } + + /** Wipes all state, tables included; the next write recreates them. */ + async #deleteAll(): Promise { + await this.ctx.storage.deleteAll() + this.#schemaReady = false + } + // --- tiny meta helpers -------------------------------------------------- #status(): ImportStatus | null { @@ -699,6 +758,7 @@ export class NotesImportRun extends DurableObject { } #metaGet(k: string): string | null { + if (!this.#hasSchema()) return null const rows = this.ctx.storage.sql .exec<{ v: string }>('SELECT v FROM meta WHERE k = ?', k) .toArray() @@ -706,6 +766,7 @@ export class NotesImportRun extends DurableObject { } #metaSet(k: string, v: string): void { + this.#ensureSchema() this.ctx.storage.sql.exec( 'INSERT OR REPLACE INTO meta (k, v) VALUES (?, ?)', k, @@ -714,6 +775,7 @@ export class NotesImportRun extends DurableObject { } #metaDel(k: string): void { + if (!this.#hasSchema()) return this.ctx.storage.sql.exec('DELETE FROM meta WHERE k = ?', k) } } diff --git a/src/notesImport/settlement.test.ts b/src/notesImport/settlement.test.ts index 672f9f0..e6cd2ee 100644 --- a/src/notesImport/settlement.test.ts +++ b/src/notesImport/settlement.test.ts @@ -67,4 +67,33 @@ describe('resolveTerminalSupporter', () => { ).resolves.toBe(true) expect(captureException).toHaveBeenCalledWith(error) }) + + it('asks about a bearer run’s account, never its meter id', async () => { + const checkSupporter = vi.fn(async () => true) + await expect( + resolveTerminalSupporter({ + ...BASE, + kickoffSupporter: false, + account: { accountId: 'account-id', revenueCatAppUserId: 'rc-user' }, + checkSupporter, + }) + ).resolves.toBe(true) + expect(checkSupporter).toHaveBeenCalledWith({ + apiKey: 'rc-key', + appUserId: 'rc-user', + }) + }) + + it('confirms a bearer run without a RevenueCat id as free, asking no one', async () => { + const checkSupporter = vi.fn(async () => true) + await expect( + resolveTerminalSupporter({ + ...BASE, + kickoffSupporter: false, + account: { accountId: 'account-id', revenueCatAppUserId: null }, + checkSupporter, + }) + ).resolves.toBe(false) + expect(checkSupporter).not.toHaveBeenCalled() + }) }) diff --git a/src/notesImport/settlement.ts b/src/notesImport/settlement.ts index 776f64a..e12376e 100644 --- a/src/notesImport/settlement.ts +++ b/src/notesImport/settlement.ts @@ -1,5 +1,9 @@ import { isSupporter, RevenueCatError } from '../revenuecat' import { Sentry } from '../sentry' +import { + isAccountSupporter, + type AccountSupporterLookup, +} from '../accounts/supporter' export interface ResolveTerminalSupporterArgs { kickoffSupporter: boolean @@ -7,6 +11,11 @@ export interface ResolveTerminalSupporterArgs { apiKey: string appUserId: string entitlementId: string + /** + * A bearer run's account: its Supporter status (`isAccountSupporter`) + * replaces the lookup by `appUserId`. + */ + account?: AccountSupporterLookup /** Injectable for the smallest public regression seam. */ checkSupporter?: typeof isSupporter captureException?: (error: unknown) => void @@ -23,13 +32,20 @@ export const resolveTerminalSupporter = async ({ apiKey, appUserId, entitlementId, + account, checkSupporter = isSupporter, captureException = Sentry.captureException, }: ResolveTerminalSupporterArgs): Promise => { if (devBypass) return kickoffSupporter try { - return await checkSupporter({ apiKey, appUserId, entitlementId }) + return account + ? await isAccountSupporter( + { REVENUECAT_API_KEY: apiKey }, + account, + checkSupporter + ) + : await checkSupporter({ apiKey, appUserId, entitlementId }) } catch (error) { if (!(error instanceof RevenueCatError)) captureException(error) return kickoffSupporter diff --git a/src/openapi/coreDocs.ts b/src/openapi/coreDocs.ts index 9864b56..5c11ef5 100644 --- a/src/openapi/coreDocs.ts +++ b/src/openapi/coreDocs.ts @@ -52,6 +52,30 @@ export const healthDocs = describeRoute({ }, }) +export const appConfigDocs = describeRoute({ + tags: ['Service'], + summary: 'App configuration', + description: + 'What the app reads at launch and when it returns to the foreground. Below `minAppVersion` it shows a blocking Update Required screen with a store link. From KV `app:min-version` (`{"minVersion":"1.42.0"}`), then the `APP_MIN_VERSION` var; edge-cached for 60 seconds. No authentication.', + responses: { + 200: jsonResponse( + 'The configuration.', + z.object({ + ok: z.literal(true), + minAppVersion: z + .string() + .regex(/^\d+\.\d+\.\d+$/) + .optional() + .meta({ + description: + 'The oldest app version (`major.minor.patch`) the API still supports. Absent without a floor.', + }), + }) + ), + ...rateLimitedResponse, + }, +}) + const hereQuery = (extra: string) => [ { name: 'q', diff --git a/src/openapi/route.ts b/src/openapi/route.ts index d4dfc53..bf6b8d3 100644 --- a/src/openapi/route.ts +++ b/src/openapi/route.ts @@ -1,7 +1,7 @@ import type { Hono } from 'hono' import { Scalar } from '@scalar/hono-api-reference' import { describeRoute, generateSpecs } from 'hono-openapi' -import type { Environment } from '../types' +import type { AppEnv } from '../types' import { componentSchemaObjects } from './schemas' export const OPENAPI_PATH = '/openapi.json' @@ -13,7 +13,8 @@ const REPO_URL = 'https://github.com/leviFrosty/ww-api' export const OPENAPI_TAGS = [ { name: 'Service', - description: 'Liveness, the server clock, and these docs.', + description: + 'Liveness, the server clock, the app’s minimum version, and these docs.', }, { name: 'Places', @@ -42,7 +43,7 @@ export const OPENAPI_TAGS = [ { name: 'Accounts', description: - 'Anonymous accounts: a random id and a public key, nothing else. Signed like Buddies ops under the `ww-account/v1` context.', + 'Anonymous accounts: a random id and a public key, nothing else. An attested session (`POST /accounts/v1/sessions`) gives bearer tokens; before sessions, ops were signed like Buddies ops under the `ww-account/v1` context, which still works while the legacy switch is on.', }, { name: 'Route planning', @@ -75,7 +76,7 @@ const DOCUMENTATION = { **Signed requests.** Buddies and accounts ops send \`{ "payload", "signature" }\`: \`payload\` is the request’s fields as base64url-encoded UTF-8 JSON, and \`signature\` is an Ed25519 signature over the op’s context line followed by those exact bytes. Each op lists its decoded \`payload\` fields. Older app builds send the same values as \`p\` and \`s\` (headers \`x-buddies-p\`/\`x-buddies-s\`, \`x-ww-p\`/\`x-ww-s\`), which are still accepted. -**Rate limits.** Places, Notes Import, attestation, route planning, and admin routes each count in their own per-IP bucket of 60 requests a minute. Buddies has its own per-caller tiers, documented on each op.`, +**Rate limits.** Places, Notes Import, attestation, route planning, app config, and admin routes each count in their own per-IP bucket of 60 requests a minute. Buddies has its own per-caller tiers, documented on each op.`, license: { name: 'See repository', url: REPO_URL }, }, servers: [ @@ -94,11 +95,18 @@ const DOCUMENTATION = { name: 'x-ww-admin-token', description: 'The environment’s `ADMIN_API_TOKEN` secret.', }, + bearerAuth: { + type: 'http' as const, + scheme: 'bearer', + bearerFormat: 'JWT', + description: + 'An access token from `POST /accounts/v1/sessions` or `POST /accounts/v1/sessions/refresh` (a JWT, 15 minutes). A missing, expired, or invalid one is 401 `unauthorized` with `WWW-Authenticate: Bearer error="invalid_token"`; refresh once and retry.', + }, }, }, } -type App = Hono<{ Bindings: Environment }> +type App = Hono /** The OpenAPI document for `app`. */ /** diff --git a/src/rateLimit.ts b/src/rateLimit.ts index f1dfe29..b8b3a01 100644 --- a/src/rateLimit.ts +++ b/src/rateLimit.ts @@ -34,6 +34,7 @@ export type RateLimitFamily = | 'attestation' | 'route-planning' | 'admin' + | 'app' /** The bucket key for one family and caller. */ export const rateLimitKey = ( diff --git a/src/routePlanning/route.ts b/src/routePlanning/route.ts index 96a9bf5..a98d05f 100644 --- a/src/routePlanning/route.ts +++ b/src/routePlanning/route.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' import type { ContentfulStatusCode } from 'hono/utils/http-status' -import type { AppContext, Environment } from '../types' +import type { AppContext, AppEnv, Environment } from '../types' import { HTTP_STATUS } from '../config' import { Sentry } from '../sentry' import { retryAfterHeaders } from '../errors' @@ -74,7 +74,7 @@ export const createRoutePlanningRoutes = ( overrides: Partial = {} ) => { const deps = { ...defaultDependencies, ...overrides } - const routes = new Hono<{ Bindings: Environment }>() + const routes = new Hono() routes.post('/optimize', routeOptimizeDocs, async (ctx) => { if (!(await isRoutePlanningEnabled(ctx.env))) { diff --git a/src/sentry.request.test.ts b/src/sentry.request.test.ts new file mode 100644 index 0000000..cc791b5 --- /dev/null +++ b/src/sentry.request.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest' +import { Hono } from 'hono' +import { + createTransport, + wrapRequestHandler, + type CloudflareOptions, +} from '@sentry/cloudflare' +import { Sentry, createSentryConfig } from './sentry' +import type { Environment } from './types' + +/** + * A legacy Notes Import run request through the SDK's own request wrapper + * (as `Sentry.withSentry` runs every request), with the worker's config and a + * transport that keeps every envelope: the error, the transaction (Notes + * Import traces at 100%), a child span, and a breadcrumb, all as Sentry + * would receive them. Its own file, since the SDK's `init` sets global state. + */ + +const SECRET = 'legacy-subscribe-token-0123456789abcdef' +const IMPORT_ID = `imp_${'a'.repeat(40)}` +const URL_WITH_TOKEN = `https://ww-proxy.test/notes-import/${IMPORT_ID}/events?token=${SECRET}&lastEventId=3` +const UPSTREAM = `https://upstream.test/echo?token=${SECRET}` +const REDACTED_UPSTREAM = 'https://upstream.test/echo?token=[redacted]' + +interface Sent { + /** Every envelope the transport got, joined. */ + sent: string + error: Record + transaction: Record + /** What the route read as its `token` query parameter. */ + token: string | undefined +} + +/** + * Runs one request to the legacy events route through `wrapRequestHandler`. + * The route adds the breadcrumb and span the fetch integration records for + * an outbound call to `upstream`, then captures an error. + */ +async function sendThroughSdk(url: string, upstream: string): Promise { + const envelopes: string[] = [] + const options: CloudflareOptions = { + ...createSentryConfig({ + SENTRY_DSN: 'https://public@o0.ingest.sentry.io/0', + APP_ATTEST_ENVIRONMENT: 'production', + } as Environment), + transport: (transportOptions) => + createTransport(transportOptions, async ({ body }) => { + envelopes.push( + typeof body === 'string' ? body : new TextDecoder().decode(body) + ) + return {} + }), + } + let token: string | undefined + const app = new Hono().get('/notes-import/:importId/events', async (c) => { + token = c.req.query('token') + Sentry.addBreadcrumb({ + type: 'http', + category: 'fetch', + data: { method: 'GET', url: upstream, status_code: 200 }, + }) + await Sentry.startSpan( + { + name: `GET ${upstream}`, + op: 'http.client', + attributes: { 'url.full': upstream }, + }, + async () => undefined + ) + Sentry.captureException(new Error('stream failed')) + return c.text('ok') + }) + const request = new Request(url, { + headers: { referer: url, accept: 'text/event-stream' }, + }) + const flushes: Promise[] = [] + + const response = await wrapRequestHandler( + { + options, + request: request as never, + context: { + waitUntil: (promise: Promise) => flushes.push(promise), + } as unknown as ExecutionContext, + }, + () => app.fetch(request) + ) + expect(response.status).toBe(200) + await Promise.all(flushes) + await Sentry.flush(2_000) + + // Each envelope is a header line, then an item header and payload per + // item; only payloads carry an `event_id`. + const items = envelopes.flatMap((envelope) => + envelope + .split('\n') + .filter((line) => line.startsWith('{')) + .map((line) => JSON.parse(line) as Record) + .filter((item) => 'event_id' in item && 'timestamp' in item) + ) + const error = items.find( + (item) => + (item.exception as { values?: { value?: string }[] } | undefined) + ?.values?.[0]?.value === 'stream failed' + ) + const transaction = items.find((item) => item.type === 'transaction') + expect(error).toBeDefined() + expect(transaction).toBeDefined() + return { + sent: envelopes.join('\n'), + error: error!, + transaction: transaction!, + token, + } +} + +const traceData = (transaction: Record) => + (transaction.contexts as { trace: { data: Record } }).trace + .data + +describe('Sentry on a request with a subscribe token in its URL', () => { + it('sends the error and the transaction without the token', async () => { + const { sent, error, transaction, token } = await sendThroughSdk( + URL_WITH_TOKEN, + UPSTREAM + ) + expect(token).toBe(SECRET) + + expect(sent).not.toContain(SECRET) + // The rest of the URL survives, so the trace is still useful. + const redacted = `https://ww-proxy.test/notes-import/${IMPORT_ID}/events?token=[redacted]&lastEventId=3` + expect(error.request).toMatchObject({ + url: redacted, + query_string: 'token=[redacted]&lastEventId=3', + headers: { referer: redacted }, + }) + expect(transaction.transaction).toBe( + `GET /notes-import/${IMPORT_ID}/events` + ) + expect(traceData(transaction)['url.full']).toBe(redacted) + expect(error.breadcrumbs).toEqual([ + expect.objectContaining({ + data: { method: 'GET', url: REDACTED_UPSTREAM, status_code: 200 }, + }), + ]) + expect(transaction.spans).toEqual([ + expect.objectContaining({ + description: `GET ${REDACTED_UPSTREAM}`, + data: expect.objectContaining({ 'url.full': REDACTED_UPSTREAM }), + }), + ]) + }) + + it('sends neither without the token when its name is percent-encoded', async () => { + // Hono decodes the name, so the route reads `to%6Ben` as `token`. + const url = `https://ww-proxy.test/notes-import/${IMPORT_ID}/events?lastEventId=3&to%6Ben=${SECRET}` + const upstream = `https://upstream.test/echo?%74oken=${SECRET}&api%4Bey=${SECRET}` + const { sent, error, transaction, token } = await sendThroughSdk( + url, + upstream + ) + expect(token).toBe(SECRET) + + expect(sent).not.toContain(SECRET) + const redacted = `https://ww-proxy.test/notes-import/${IMPORT_ID}/events?lastEventId=3&to%6Ben=[redacted]` + const redactedUpstream = + 'https://upstream.test/echo?%74oken=[redacted]&api%4Bey=[redacted]' + expect(error.request).toMatchObject({ + url: redacted, + query_string: 'lastEventId=3&to%6Ben=[redacted]', + headers: { referer: redacted }, + }) + expect(traceData(transaction)).toMatchObject({ + 'url.full': redacted, + }) + // The SDK keeps the previous test's breadcrumb as well. + expect(error.breadcrumbs).toContainEqual( + expect.objectContaining({ + data: { method: 'GET', url: redactedUpstream, status_code: 200 }, + }) + ) + expect(transaction.spans).toEqual([ + expect.objectContaining({ + description: `GET ${redactedUpstream}`, + data: expect.objectContaining({ 'url.full': redactedUpstream }), + }), + ]) + }) +}) diff --git a/src/sentry.test.ts b/src/sentry.test.ts index af06165..007753e 100644 --- a/src/sentry.test.ts +++ b/src/sentry.test.ts @@ -1,10 +1,23 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Hono } from 'hono' import type { Breadcrumb, CloudflareOptions, ErrorEvent, } from '@sentry/cloudflare' -import { createSentryConfig, redactContactLinkPayloads } from './sentry' +import { DrizzleQueryError } from 'drizzle-orm' +import { + DatabaseError, + Sentry, + captureFailure, + createSentryConfig, + isDatabaseError, + redactContactLinkPayloads, + redactDatabaseErrors, + redactSecretQueryParameters, + reportableError, +} from './sentry' +import { recordSentryEvents, type SentryRecorder } from './test/sentry' import type { Environment } from './types' type TransactionEvent = Parameters< @@ -48,6 +61,125 @@ describe('redactContactLinkPayloads', () => { }) }) +describe('redactSecretQueryParameters', () => { + const EVENTS = 'https://ww-proxy.test/notes-import/imp_0123/events' + it.each([ + [`${EVENTS}?token=s3cr3t`, `${EVENTS}?token=[redacted]`], + [ + `${EVENTS}?lastEventId=3&token=s3cr3t&x=1`, + `${EVENTS}?lastEventId=3&token=[redacted]&x=1`, + ], + [`${EVENTS}?token=s3cr3t#top`, `${EVENTS}?token=[redacted]#top`], + [`${EVENTS}?TOKEN=s3cr3t`, `${EVENTS}?TOKEN=[redacted]`], + [`GET ${EVENTS}?token=s3cr3t 200`, `GET ${EVENTS}?token=[redacted] 200`], + [`"${EVENTS}?token=s3cr3t"`, `"${EVENTS}?token=[redacted]"`], + // The SDK's `query_string` and `url.query` drop the `?`. + ['token=s3cr3t&lastEventId=3', 'token=[redacted]&lastEventId=3'], + [ + 'https://ww-proxy.test/autocomplete?q=Main%20St&apiKey=k3y', + 'https://ww-proxy.test/autocomplete?q=Main%20St&apiKey=[redacted]', + ], + // Hono and URLSearchParams decode names, so these are `token` too. The + // raw name stays. + [`${EVENTS}?to%6ben=s3cr3t`, `${EVENTS}?to%6ben=[redacted]`], + [ + `${EVENTS}?lastEventId=3&%74%6F%6B%65%6E=s3cr3t#top`, + `${EVENTS}?lastEventId=3&%74%6F%6B%65%6E=[redacted]#top`, + ], + ['TO%4Ben=s3cr3t&lastEventId=3', 'TO%4Ben=[redacted]&lastEventId=3'], + ['?to%6ben=s3cr3t', '?to%6ben=[redacted]'], + [ + 'https://ww-proxy.test/autocomplete?q=Main&api%4Bey=k3y', + 'https://ww-proxy.test/autocomplete?q=Main&api%4Bey=[redacted]', + ], + // A secret's value runs to the next `&`, whatever it holds. + [`${EVENTS}?token=s3c?r3t=x&a=1`, `${EVENTS}?token=[redacted]&a=1`], + [`${EVENTS}?token=s3c%26r3t&a=1`, `${EVENTS}?token=[redacted]&a=1`], + [`${EVENTS}?token=&a=1`, `${EVENTS}?token=[redacted]&a=1`], + // A parameter inside another one's value, as in a nested URL. + [ + `${EVENTS}?next=/x?to%6ben=s3cr3t&a=1`, + `${EVENTS}?next=/x?to%6ben=[redacted]&a=1`, + ], + [`${EVENTS}??token=s3cr3t`, `${EVENTS}??token=[redacted]`], + // Every secret in a string, not only the first. + [ + `${EVENTS}?token=a&to%6ben=b and ${EVENTS}?apikey=c`, + `${EVENTS}?token=[redacted]&to%6ben=[redacted] and ${EVENTS}?apikey=[redacted]`, + ], + ])('redacts %s', (input, expected) => { + expect(redactSecretQueryParameters(input)).toBe(expected) + }) + + it.each([ + `${EVENTS}?lastEventId=3`, + 'GET /notes-import/:importId/events', + 'https://ww-proxy.test/autocomplete?q=token', + 'https://ww-proxy.test/geocode?q=a%3Dtoken%3D1', + 'a token=s3cr3t in prose', + // Names that decode to something else, malformed escapes included. + `${EVENTS}?to%6ben%=x&tok%ZZen=x&to%256ben=x&tokens=x&to+ken=x`, + `${EVENTS}?%E0token=x&token%00=x&mytoken=x&api_key=x`, + ])('leaves %s alone', (input) => { + expect(redactSecretQueryParameters(input)).toBe(input) + }) + + // Whatever spelling of a name the worker reads as `token` (Hono, on the + // legacy run routes) or `apiKey` (URLSearchParams, in the HERE proxy), the + // scrubbed URL keeps none of its value. + const app = new Hono().get('*', (c) => + c.json({ token: c.req.query('token') ?? null }) + ) + /** `decodeURIComponent` with `+` as a space, or the name if malformed. */ + const decodeName = (name: string): string => { + try { + return decodeURIComponent(name.replace(/\+/g, ' ')) + } catch { + return name + } + } + it.each([ + 'token', + 'to%6ben', + 'to%6Ben', + '%74oken', + '%74%6f%6b%65%6e', + 'TOKEN', + 'To%4Ben', + 'apiKey', + 'api%4Bey', + '%61%70%69%4b%65%79', + 'APIKEY', + 'to%6ben%', + 'tok%ZZen', + 'to%256ben', + 'to+ken', + '%E0token', + ])('cuts the value of %s whenever the worker reads it', async (name) => { + const SECRET = 'value-the-worker-reads-0123' + const decoded = decodeName(name) + for (const query of [ + `${name}=${SECRET}`, + `lastEventId=3&${name}=${SECRET}&x=%20`, + `${name}=${SECRET}&q=a+b`, + ]) { + const url = `${EVENTS}?${query}` + const { token } = (await (await app.request(url)).json()) as { + token: string | null + } + const read = [token, new URL(url).searchParams.get('apiKey')] + const scrubbed = redactSecretQueryParameters(url) + expect(read.includes(SECRET)).toBe( + decoded === 'token' || decoded === 'apiKey' + ) + // Cut whenever the name decodes to a secret one, in any case. + expect(scrubbed.includes(SECRET)).toBe( + !/^(token|apikey)$/i.test(decoded) + ) + } + }) +}) + describe('Sentry scrubbers', () => { it('beforeSend scrubs URLs, request data and messages', () => { const event: ErrorEvent = { @@ -156,17 +288,162 @@ describe('Sentry scrubbers', () => { 'X-WW-Signature': 'c2lnbmF0dXJl', 'x-ww-p': 'eyJhY2NvdW50UHViIjoiQUFBQSJ9', 'X-WW-S': 'c2lnbmF0dXJl', - 'x-ww-dev-bypass': 'kept', + 'x-ww-sha256': 'kept', 'content-type': 'application/octet-stream', }, }, } expect((config.beforeSend!(error, {}) as ErrorEvent).request?.headers).toEqual({ - 'x-ww-dev-bypass': 'kept', + 'x-ww-sha256': 'kept', 'content-type': 'application/octet-stream', }) }) + it('drops the bearer backup upload’s slot id', () => { + const error: ErrorEvent = { + type: undefined, + request: { + url: 'https://ww-proxy.test/accounts/v1/backup/put', + headers: { + Authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.e30.c2ln', + 'X-WW-Slot-Id': 'AAAAAAAAAAAAAAAAAAAAAA', + 'x-ww-sha256': 'kept', + 'content-length': '42', + }, + }, + } + expect((config.beforeSend!(error, {}) as ErrorEvent).request?.headers).toEqual({ + 'x-ww-sha256': 'kept', + 'content-length': '42', + }) + }) + + it('drops bearer, admin, and dev bypass tokens from events and transactions', () => { + const headers = () => ({ + Authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.e30.c2ln', + 'x-ww-admin-token': 'admin-secret', + 'X-WW-Dev-Bypass': 'bypass-secret', + 'x-authorization-hint': 'kept', + accept: 'application/json', + }) + const kept = { 'x-authorization-hint': 'kept', accept: 'application/json' } + const error: ErrorEvent = { + type: undefined, + request: { url: 'https://ww-proxy.test/accounts/v1/account/info', headers: headers() }, + } + const transaction: TransactionEvent = { + type: 'transaction', + request: { url: 'https://ww-proxy.test/admin/accounts/status', headers: headers() }, + } + + expect((config.beforeSend!(error, {}) as ErrorEvent).request?.headers).toEqual(kept) + expect( + (config.beforeSendTransaction!(transaction, {}) as TransactionEvent).request?.headers + ).toEqual(kept) + }) + + it('cuts subscribe tokens from URLs, query strings, spans, and breadcrumbs', () => { + const SECRET = 'legacy-subscribe-token-0123456789' + const url = `https://ww-proxy.test/notes-import/imp_0123/events?token=${SECRET}&lastEventId=3` + const redactedUrl = + 'https://ww-proxy.test/notes-import/imp_0123/events?token=[redacted]&lastEventId=3' + const request = (queryString: unknown) => + ({ + url, + query_string: queryString, + headers: { referer: url, accept: 'text/event-stream' }, + }) as ErrorEvent['request'] + const trace = { + trace_id: 't', + span_id: 's', + data: { 'url.full': url, 'url.query': `token=${SECRET}&lastEventId=3` }, + } + + const error = config.beforeSend!( + { + type: undefined, + message: `stream failed for ${url}`, + request: request(`token=${SECRET}&lastEventId=3`), + breadcrumbs: [{ category: 'fetch', data: { url } }], + contexts: { trace }, + }, + {} + ) as ErrorEvent + expect(JSON.stringify(error)).not.toContain(SECRET) + expect(error.request).toEqual({ + url: redactedUrl, + query_string: 'token=[redacted]&lastEventId=3', + headers: { referer: redactedUrl, accept: 'text/event-stream' }, + }) + expect(error.contexts?.trace?.data).toEqual({ + 'url.full': redactedUrl, + 'url.query': 'token=[redacted]&lastEventId=3', + }) + + // The event format also allows the query as an object or pairs. + for (const [queryString, expected] of [ + [ + { token: SECRET, lastEventId: '3' }, + { token: '[redacted]', lastEventId: '3' }, + ], + [ + [['Token', SECRET], ['lastEventId', '3']], + [['Token', '[redacted]'], ['lastEventId', '3']], + ], + // Keys as they were sent, still encoded. + [ + { 'to%6Ben': SECRET, 'api%4Bey': SECRET, lastEventId: '3' }, + { 'to%6Ben': '[redacted]', 'api%4Bey': '[redacted]', lastEventId: '3' }, + ], + [ + [['%74oken', SECRET], ['lastEventId', '3']], + [['%74oken', '[redacted]'], ['lastEventId', '3']], + ], + ]) { + const scrubbed = config.beforeSend!( + { type: undefined, request: request(queryString) }, + {} + ) as ErrorEvent + expect(scrubbed.request?.query_string).toEqual(expected) + } + + const transaction = config.beforeSendTransaction!( + { + type: 'transaction', + transaction: 'GET /notes-import/imp_0123/events', + request: request(`token=${SECRET}`), + contexts: { trace }, + spans: [ + { + span_id: 'c', + trace_id: 't', + start_timestamp: 0, + description: `GET ${url}`, + data: { 'url.full': url, 'http.query': `?token=${SECRET}` }, + }, + ], + }, + {} + ) as TransactionEvent + expect(JSON.stringify(transaction)).not.toContain(SECRET) + expect(transaction.spans?.[0]).toMatchObject({ + description: `GET ${redactedUrl}`, + data: { 'url.full': redactedUrl, 'http.query': '?token=[redacted]' }, + }) + + expect( + config.beforeBreadcrumb!({ + category: 'fetch', + message: `GET ${url}`, + data: { url, status_code: 200 }, + }) + ).toEqual({ + category: 'fetch', + message: `GET ${redactedUrl}`, + data: { url: redactedUrl, status_code: 200 }, + }) + }) + it('leaves events without contact links untouched', () => { const event: TransactionEvent = { type: 'transaction', @@ -202,3 +479,133 @@ describe('trace sampling', () => { expect(config.beforeSendTransaction).toBeTypeOf('function') }) }) + +describe('database errors', () => { + // What Drizzle puts in a failed query's message: ids, token hashes. + const ACCOUNT_ID = 'AcctAcctAcctAcctAcct0w' + const TOKEN_HASH = 'HashHashHashHashHashHashHashHashHashHashHa0' + const QUERY = + 'select "id", "account_id" from "account_sessions" where "account_sessions"."refresh_hash" = ? and "account_sessions"."account_id" = ?' + const queryError = () => + new DrizzleQueryError( + QUERY, + [TOKEN_HASH, ACCOUNT_ID], + new Error(`D1_TYPE_ERROR: Type 'object' not supported for value '${ACCOUNT_ID}'`) + ) + + let recorder: SentryRecorder + beforeEach(() => { + recorder = recordSentryEvents() + }) + afterEach(() => recorder.stop()) + + const leaks = (value: unknown) => { + const text = JSON.stringify(value) + return text.includes(ACCOUNT_ID) || text.includes(TOKEN_HASH) + } + + it('reports a database error, or one it caused, as the operation alone', () => { + const error = queryError() + expect(isDatabaseError(error)).toBe(true) + const reported = reportableError(error, 'session/refresh') as Error + expect(reported).toBeInstanceOf(DatabaseError) + expect(reported.name).toBe('DatabaseError') + expect(reported.message).toBe('database query failed: session/refresh') + expect(reported.cause).toBeUndefined() + expect(Object.keys(reported)).toEqual(['name']) + // The stack keeps the frames and nothing from the query. + expect(reported.stack).toMatch( + /^DatabaseError: database query failed: session\/refresh\n\s+at / + ) + expect(reported.stack).not.toContain('Failed query') + expect(leaks(reported.stack)).toBe(false) + + const wrapped = new Error('kickoff failed', { cause: queryError() }) + expect(reportableError(wrapped, 'kickoff')).toBeInstanceOf(DatabaseError) + const d1 = new Error(`D1_ERROR: no such column: ${ACCOUNT_ID}`) + expect(reportableError(d1, 'account status')).toBeInstanceOf(DatabaseError) + }) + + it('reports anything else unchanged', () => { + const plain = new Error('RPC reply lost', { cause: new Error('socket') }) + expect(isDatabaseError(plain)).toBe(false) + expect(reportableError(plain, 'inbox/sync')).toBe(plain) + expect(reportableError('text', 'inbox/sync')).toBe('text') + const loop = new Error('loop') as Error & { cause?: unknown } + loop.cause = loop + expect(reportableError(loop, 'inbox/sync')).toBe(loop) + }) + + it('captureFailure sends the operation, never the query', async () => { + captureFailure(queryError(), 'session/refresh') + const events = await recorder.events() + expect(events).toHaveLength(1) + expect(events[0].exception?.values).toEqual([ + expect.objectContaining({ + type: 'DatabaseError', + value: 'database query failed: session/refresh', + }), + ]) + expect(leaks(events)).toBe(false) + }) + + it('beforeSend redacts a raw query error and drops its cause chain', async () => { + Sentry.captureException(queryError()) + const [event] = await recorder.events() + expect(event.exception?.values).toEqual([ + expect.objectContaining({ type: 'Error', value: '[redacted database query]' }), + ]) + expect(leaks(event)).toBe(false) + }) + + it('beforeSend keeps the errors above one and drops only those below', async () => { + const deeper = queryError() + ;(deeper.cause as Error).cause = new Error(`D1 detail ${ACCOUNT_ID}`) + Sentry.captureException(new Error('settling failed', { cause: deeper })) + const [event] = await recorder.events() + expect(event.exception?.values?.map((value) => value.value)).toEqual([ + '[redacted database query]', + 'settling failed', + ]) + expect(leaks(event)).toBe(false) + + // Chains without a database error keep every cause. + Sentry.captureException( + new Error('outer', { cause: new Error('middle', { cause: new Error('inner') }) }) + ) + const [, plain] = await recorder.events() + expect(plain.exception?.values?.map((value) => value.value)).toEqual([ + 'inner', + 'middle', + 'outer', + ]) + }) + + it('redacts query text in messages, extras, and breadcrumbs', async () => { + const text = queryError().message + Sentry.captureMessage(`accounts: ${text}`) + const [event] = await recorder.events() + expect(event.message).toBe('accounts: [redacted database query]') + + const scrubbed = config.beforeSend!( + { type: undefined, extra: { detail: { reason: text } } }, + {} + ) as ErrorEvent + expect(scrubbed.extra).toEqual({ + detail: { reason: '[redacted database query]' }, + }) + expect( + config.beforeBreadcrumb!({ message: `failed: ${text}` }, {}) + ).toEqual({ message: 'failed: [redacted database query]' }) + // Truncated before `params:`, the query alone still goes. + expect(redactDatabaseErrors(`Failed query: ${QUERY.slice(0, 40)}…`)).toBe( + '[redacted database query]' + ) + expect(redactDatabaseErrors(`x\nparams: ${ACCOUNT_ID}`)).toBe( + 'x[redacted database query]' + ) + expect(redactDatabaseErrors('No query here; params are fine')).toBe( + 'No query here; params are fine' + ) + }) +}) diff --git a/src/sentry.ts b/src/sentry.ts index 5096cd1..baeac4f 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -1,5 +1,6 @@ import * as Sentry from '@sentry/cloudflare'; import type { Breadcrumb, CloudflareOptions, Event } from '@sentry/cloudflare'; +import { DrizzleQueryError } from 'drizzle-orm'; import type { Context } from 'hono'; import { routePath } from 'hono/route'; import type { Environment } from './types'; @@ -19,12 +20,135 @@ export function redactContactLinkPayloads(value: string): string { return value.replace(CONTACT_LINK_PAYLOAD_PATH, REDACTED_CONTACT_LINK_PATH); } +/** + * Query parameters that carry a credential: Notes Import's legacy run routes + * (`/notes-import/:importId/events|result|cancel|destroy`) take their + * subscribe token as `?token=`, and the HERE proxy drops a client-sent + * `apiKey` for its own. The SDK records the request URL whole + * (`event.request.url` and `query_string`, `url.full` span data, the Referer + * header, breadcrumbs), and Notes Import traces at 100%, so their values are + * cut from every string in an event, like contact links. Names are lowercase + * and match without regard to case. + * + * A name is compared as the worker reads it: Hono and `URLSearchParams` + * decode percent-escapes (and `+`) in names, so `?to%6Ben=` is a `token` + * too. The raw name stays in the string; only the value is cut. + */ +const SECRET_QUERY_PARAMETERS = ['token', 'apikey']; +/** + * A query parameter's `name=` at the start of a string or after `?` or `&`. + * It matches only up to the `=`, so a parameter inside another one's value + * (`?next=/x?token=…`) is found as well. + */ +const QUERY_PARAMETER_NAME = /(?:^|[?&])([^&#=?\s"'<>]*)=/g; +/** + * A value runs to the next `&` or the fragment, or to whatever ends the URL + * in the string (whitespace, a quote, an angle bracket, the string's end). + */ +const QUERY_PARAMETER_VALUE = /[^&#\s"'<>]*/y; +const REDACTED_QUERY_VALUE = '[redacted]'; + +/** + * A raw query parameter name decoded as `URLSearchParams` decodes it: `+` is + * a space, and percent-escapes are decoded, a malformed one left as it is + * (never an error). + */ +function decodeQueryParameterName(name: string): string { + if (!/[%+]/.test(name)) return name; + for (const [decoded] of new URLSearchParams(`${name}=`)) return decoded; + return name; +} + +const isSecretQueryParameter = (name: string): boolean => + SECRET_QUERY_PARAMETERS.includes( + decodeQueryParameterName(name).toLowerCase() + ); + +export function redactSecretQueryParameters(value: string): string { + let redacted = ''; + let copied = 0; + for (const match of value.matchAll(QUERY_PARAMETER_NAME)) { + // A name inside a value that was just cut is gone with it. + if (match.index < copied || !isSecretQueryParameter(match[1])) continue; + const valueStart = match.index + match[0].length; + QUERY_PARAMETER_VALUE.lastIndex = valueStart; + QUERY_PARAMETER_VALUE.exec(value); + redacted += value.slice(copied, valueStart) + REDACTED_QUERY_VALUE; + copied = QUERY_PARAMETER_VALUE.lastIndex; + } + return copied === 0 ? value : redacted + value.slice(copied); +} + +/** + * Drizzle's query errors carry the SQL and its bound parameters (account ids, + * session ids, refresh token hashes, inbox ids) in their message, as + * `Failed query: \nparams: `, and D1's own error in `cause`. D1's + * messages (`D1_ERROR: …`, `D1_TYPE_ERROR: …`) can quote a value too. + */ +const DATABASE_ERROR_TEXT = /Failed query:|\nparams:|\bD1_[A-Z_]+:/; +const REDACTED_DATABASE_ERROR = '[redacted database query]'; + +/** Cuts everything from a database error's text onwards. */ +export function redactDatabaseErrors(value: string): string { + const match = DATABASE_ERROR_TEXT.exec(value); + return match + ? `${value.slice(0, match.index)}${REDACTED_DATABASE_ERROR}` + : value; +} + +const isDatabaseErrorItself = (error: unknown): boolean => + error instanceof DrizzleQueryError || + (error instanceof Error && DATABASE_ERROR_TEXT.test(error.message)); + +/** A database error, or an error caused (at any depth) by one. */ +export function isDatabaseError(error: unknown): boolean { + const seen = new Set(); + for (let at = error; at != null && !seen.has(at); ) { + if (isDatabaseErrorItself(at)) return true; + seen.add(at); + at = (at as { cause?: unknown }).cause; + } + return false; +} + +/** A database failure as it's reported: which operation, nothing from the query. */ +export class DatabaseError extends Error { + override name = 'DatabaseError'; +} + +/** + * What to report for `error`, caught around `operation` (an op name or a + * fixed label, never a value). A database error, or one caused by it, + * becomes a `DatabaseError` naming only the operation: the original's stack + * frames, but no message, query, parameters, or cause. Anything else is + * reported as is. + */ +export function reportableError(error: unknown, operation: string): unknown { + if (!isDatabaseError(error)) return error; + const reported = new DatabaseError(`database query failed: ${operation}`); + const stack = error instanceof Error ? (error.stack ?? '') : ''; + const frames = stack.split('\n').filter((line) => /^\s+at /.test(line)); + reported.stack = [`${reported.name}: ${reported.message}`, ...frames].join( + '\n' + ); + return reported; +} + +/** `Sentry.captureException` for a failure caught around `operation`. */ +export function captureFailure(error: unknown, operation: string): void { + Sentry.captureException(reportableError(error, operation)); +} + /** * Rewrites strings in place through plain objects and arrays. Anything else * (SDK class instances such as scopes) is left alone. */ function scrubStrings(value: unknown, seen = new WeakSet()): unknown { - if (typeof value === 'string') return redactContactLinkPayloads(value); + if (typeof value === 'string') { + return redactDatabaseErrors( + redactSecretQueryParameters(redactContactLinkPayloads(value)) + ); + } if (typeof value !== 'object' || value === null || seen.has(value)) { return value; } @@ -46,25 +170,91 @@ function scrubStrings(value: unknown, seen = new WeakSet()): unknown { } /** - * The Buddies live socket sends its signed envelope (which holds the inbox - * id) in `x-buddies-*` request headers, and accounts' `backup/put` sends its - * envelope (account key and slot id) in `x-ww-payload`/`x-ww-signature` (or - * the legacy `x-ww-p`/`x-ww-s`). The SDK copies request headers into events, - * so drop them outright. + * The SDK copies request headers into events (with `sendDefaultPii: false` + * it leaves out only cookies), so drop every header that carries a + * credential or an id outright: + * - `authorization`: bearer tokens. + * - `x-ww-admin-token` and `x-ww-dev-bypass`: the admin and dev bypass tokens. + * - `x-buddies-*`: the Buddies live socket's signed envelope (holds the inbox + * id). + * - `x-ww-payload`/`x-ww-signature` (or the legacy `x-ww-p`/`x-ww-s`): + * accounts' legacy `backup/put` envelope (account key and slot id). + * - `x-ww-slot-id`: the bearer `backup/put`'s slot id. */ -const BUDDIES_HEADER = /^(x-buddies-|x-ww-(p|s|payload|signature)$)/i; +const SECRET_HEADER = + /^(authorization$|x-ww-admin-token$|x-ww-dev-bypass$|x-buddies-|x-ww-(p|s|payload|signature|slot-id)$)/i; -function dropBuddiesHeaders(event: Event): void { +function dropSecretHeaders(event: Event): void { const headers = event.request?.headers; if (!headers) return; for (const name of Object.keys(headers)) { - if (BUDDIES_HEADER.test(name)) delete headers[name]; + if (SECRET_HEADER.test(name)) delete headers[name]; + } +} + +/** + * `event.request.query_string` is a string from this SDK, which + * `scrubStrings` covers, but the event format also allows an object or a + * list of pairs, whose values only their keys name. + */ +function redactQueryStringPairs(event: Event): void { + const request = event.request; + const query = request?.query_string; + if (!request || query == null || typeof query === 'string') return; + if (Array.isArray(query)) { + request.query_string = query.map(([name, value]) => [ + name, + isSecretQueryParameter(name) ? REDACTED_QUERY_VALUE : value, + ]); + return; + } + for (const name of Object.keys(query)) { + if (isSecretQueryParameter(name)) query[name] = REDACTED_QUERY_VALUE; + } +} + +/** + * Drops every exception chained under a redacted database error (its + * `cause`, which for Drizzle is D1's own error, and so on down). The + * linked-errors integration lists each cause as another exception whose + * `mechanism.parent_id` names the one it caused. + */ +function dropDatabaseErrorCauses(event: Event): void { + const values = event.exception?.values; + if (!values) return; + const redacted = new Set(); + for (const value of values) { + const id = value.mechanism?.exception_id; + if (id != null && value.value?.includes(REDACTED_DATABASE_ERROR)) { + redacted.add(id); + } + } + if (redacted.size === 0) return; + const below = new Set(); + let grew = true; + while (grew) { + grew = false; + for (const value of values) { + const { exception_id: id, parent_id: parent } = value.mechanism ?? {}; + if (id == null || parent == null || below.has(id)) continue; + if (redacted.has(parent) || below.has(parent)) { + below.add(id); + grew = true; + } + } } + event.exception!.values = values.filter((value) => { + const id = value.mechanism?.exception_id; + return id == null || !below.has(id); + }); } function scrubEvent(event: T): T { - dropBuddiesHeaders(event); - return scrubStrings(event) as T; + dropSecretHeaders(event); + redactQueryStringPairs(event); + const scrubbed = scrubStrings(event) as T; + dropDatabaseErrorCauses(scrubbed); + return scrubbed; } function scrubBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb { diff --git a/src/test/accounts.ts b/src/test/accounts.ts new file mode 100644 index 0000000..aded41c --- /dev/null +++ b/src/test/accounts.ts @@ -0,0 +1,117 @@ +import type { Hono } from 'hono' +import { accountSignedMessage, type AccountsOp } from '../accounts/contracts' +import type { Environment } from '../types' +import { SigningKey, b64u, randomId } from './buddiesClient' +import { TEST_DEV_BYPASS, type ApiResponse } from './buddies' + +/** + * Client helpers for account tests: self-signed envelopes, bearer headers, + * and sessions opened with the harness's dev bypass. + */ + +const encoder = new TextEncoder() + +/** A self-signed account envelope for `op`; `signer` defaults to `key`. */ +export const accountEnvelope = async ( + op: AccountsOp, + key: SigningKey, + fields: Record = {}, + signer: SigningKey = key +) => { + const bytes = encoder.encode( + JSON.stringify({ + accountPub: key.publicKey, + ts: Date.now(), + nonce: randomId(), + ...fields, + }) + ) + return { + payload: b64u(bytes), + signature: b64u(await signer.sign(accountSignedMessage(op, bytes))), + } +} + +export const bearer = (accessToken: string): Record => ({ + authorization: `Bearer ${accessToken}`, +}) + +export interface TestSession { + key: SigningKey + accountId: string + sessionId: string + accessToken: string + refreshToken: string + accessExpiresAt: number + sessionExpiresAt: number +} + +let ipCounter = 0 +/** A fresh caller IP so per-caller edge limits never couple tests. */ +export const nextTestIp = () => `198.19.${(++ipCounter >> 8) & 255}.${ipCounter & 255}` + +/** Sends a JSON request to an accounts app and reads the JSON answer. */ +export const requestJson = async ( + app: Hono, + env: Environment, + method: string, + path: string, + body?: unknown, + headers: Record = {} +): Promise => { + const response = await app.request( + path, + { + method, + headers: { + 'content-type': 'application/json', + 'cf-connecting-ip': nextTestIp(), + ...headers, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }, + env + ) + const text = await response.text() + let parsed: unknown = text + try { + parsed = JSON.parse(text) + } catch { + // Raw bytes (backup/get) or empty. + } + return { status: response.status, headers: response.headers, body: parsed } +} + +/** POSTs JSON to an accounts app and reads the JSON answer. */ +export const postJson = ( + app: Hono, + env: Environment, + path: string, + body: unknown, + headers: Record = {} +) => requestJson(app, env, 'POST', path, body, headers) + +/** + * Opens a session (`POST /sessions` on the accounts routes) with the dev + * bypass in place of attestation. + */ +export const openTestSession = async ( + app: Hono, + env: Environment, + key?: SigningKey, + fields: Record = {} +): Promise => { + const signingKey = key ?? (await SigningKey.generate()) + const response = await postJson( + app, + env, + '/sessions', + await accountEnvelope('session/open', signingKey, fields), + { 'x-ww-dev-bypass': TEST_DEV_BYPASS } + ) + if (response.status !== 201) + throw new Error( + `opening a session failed: ${response.status} ${JSON.stringify(response.body)}` + ) + return { key: signingKey, ...response.body } +} diff --git a/src/test/buddies.ts b/src/test/buddies.ts index 1b7e6f0..e9f508b 100644 --- a/src/test/buddies.ts +++ b/src/test/buddies.ts @@ -114,8 +114,17 @@ export interface HarnessOptions { accountsDb?: boolean /** Bind the account backup bucket (default true). */ backupBucket?: boolean + /** + * Account sessions as on the dev worker (default true): a token secret, + * `APP_ATTEST_ENVIRONMENT` development, and the dev bypass token. + */ + sessions?: boolean } +/** The harness's `ACCOUNT_TOKEN_SECRET` and dev bypass token. */ +export const TEST_TOKEN_SECRET = 'test-account-token-secret-0123456789abcdef' +export const TEST_DEV_BYPASS = 'test-dev-bypass-token' + export interface Harness { env: Environment kv: MemoryKv @@ -239,6 +248,13 @@ export const createHarness = async ( ...(options.blobBucket === false ? {} : { BUDDY_BLOBS: r2 }), ...(accountsDb ? { ACCOUNTS_DB: accountsDb } : {}), ...(options.backupBucket === false ? {} : { ACCOUNT_BACKUPS: backups }), + ...(options.sessions === false + ? {} + : { + ACCOUNT_TOKEN_SECRET: TEST_TOKEN_SECRET, + APP_ATTEST_ENVIRONMENT: 'development', + NOTES_IMPORT_DEV_BYPASS_TOKEN: TEST_DEV_BYPASS, + }), ...limiters, ...(options.apnsConfigured === false ? {} diff --git a/src/test/e2e.ts b/src/test/e2e.ts index f2c8cba..6b4e0d0 100644 --- a/src/test/e2e.ts +++ b/src/test/e2e.ts @@ -7,8 +7,10 @@ import type { Socket } from 'node:net' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { BuddiesSignedOp } from '../buddies/contracts' +import { accountSignedMessage, inboxLinkMessage } from '../accounts/contracts' import { SigningKey, + b64u, blobPutHeaders, envelope, liveHeaders, @@ -250,9 +252,15 @@ export const sendSigned = async ( export const sendUnsigned = (op: string, payload: Record) => buddies(op, unsignedEnvelope(payload)) -/** `blob/get`: an unsigned `{payload}` envelope; raw bytes back on 200. */ -export const getBlob = (payload: Record) => - httpBytes('/buddies/v1/blob/get', { json: unsignedEnvelope(payload) }) +/** `blob/get`: an unsigned `{payload}` envelope (plus `headers`, e.g. a token); raw bytes back on 200. */ +export const getBlob = ( + payload: Record, + headers: Record = {} +) => + httpBytes('/buddies/v1/blob/get', { + json: unsignedEnvelope(payload), + headers, + }) /** One synthetic person: an inbox and the owner key that signs for it. */ export class RelayOwner { @@ -347,6 +355,211 @@ export class RelayWriter { } } +// --- Account sessions (bearer requests) -------------------------------------- + +const encoder = new TextEncoder() + +export interface E2ESession { + key: SigningKey + accountId: string + sessionId: string + accessToken: string + refreshToken: string + accessExpiresAt: number + sessionExpiresAt: number +} + +/** + * Opens an account session with the dev bypass standing in for attestation + * (`POST /accounts/v1/sessions`). Needs `DEV_BYPASS_TOKEN`. + */ +export const openSession = async (key?: SigningKey): Promise => { + const signingKey = key ?? (await SigningKey.generate()) + const bytes = encoder.encode( + JSON.stringify({ + accountPub: signingKey.publicKey, + ts: Date.now(), + nonce: randomId(), + }) + ) + const response = await http('POST', '/accounts/v1/sessions', { + headers: { 'x-ww-dev-bypass': DEV_BYPASS_TOKEN ?? '' }, + body: { + payload: b64u(bytes), + signature: b64u( + await signingKey.sign(accountSignedMessage('session/open', bytes)) + ), + }, + }) + if (response.status !== 201) + throw new Error(`session open ${response.status} ${response.text}`) + return { key: signingKey, ...(response.body as Omit) } +} + +export const bearerHeaders = (session: E2ESession): Record => ({ + authorization: `Bearer ${session.accessToken}`, +}) + +/** A bearer `inbox/live` or `blob/put`'s fields, for `x-buddies-payload`. */ +export const fieldsHeader = (fields: Record): string => + b64u(encoder.encode(JSON.stringify(fields))) + +/** + * One synthetic person on an account session: an inbox registered with a + * bearer token and linked to the session's account, and owner ops sent as + * bearer requests (plain JSON fields). + */ +export class BearerRelayOwner { + readonly inboxId = randomId() + private constructor( + readonly session: E2ESession, + readonly key: SigningKey + ) {} + + /** A fresh owner key on `session` (a new session by default), registered unless `register` is false. */ + static async create( + session?: E2ESession, + register = true + ): Promise { + const owner = new BearerRelayOwner( + session ?? (await openSession()), + await SigningKey.generate() + ) + if (register) { + const response = await owner.register() + if (response.status !== 200) + throw new Error(`inbox/register ${response.status} ${response.text}`) + } + return owner + } + + /** Reuses an inbox and owner key registered the legacy way. */ + static adopt( + legacy: RelayOwner, + session: E2ESession + ): BearerRelayOwner { + return Object.assign(new BearerRelayOwner(session, legacy.key), { + inboxId: legacy.inboxId, + }) + } + + get auth(): Record { + return bearerHeaders(this.session) + } + + /** `inbox/register`'s `account` proof for `account` (this session's by default). */ + async proof(account: E2ESession = this.session) { + return { + accountId: account.accountId, + signature: b64u( + await account.key.sign( + inboxLinkMessage(account.accountId, this.inboxId, this.key.publicKey) + ) + ), + } + } + + /** A bearer `inbox/register`: the token plus the self-signed envelope with a proof. */ + async register( + fields: Record = {}, + headers = this.auth + ): Promise { + const payload = { + inboxId: this.inboxId, + ownerPub: this.key.publicKey, + account: await this.proof(), + ts: Date.now(), + nonce: randomId(), + ...fields, + } + return http('POST', '/buddies/v1/inbox/register', { + headers, + body: await envelope('inbox/register', payload, this.key), + }) + } + + /** A bearer owner op: plain JSON fields, no envelope. */ + op( + op: BuddiesSignedOp, + fields: Record = {}, + headers = this.auth + ): Promise { + return http('POST', `/buddies/v1/${op}`, { + headers, + body: { inboxId: this.inboxId, ...fields }, + }) + } + + sync(since = 0, headers = this.auth) { + return this.op('inbox/sync', { since }, headers) + } + + /** Adds a buddy's slot (bearer); the writer signs its own ops. */ + async addWriter(slotId = randomId()): Promise { + const key = await SigningKey.generate() + const response = await this.op('slot/add', { + slotId, + writerPub: key.publicKey, + }) + if (response.status !== 200) + throw new Error(`slot/add ${response.status} ${response.text}`) + return new RelayWriter(this.inboxId, slotId, key) + } + + /** A bearer `blob/put`: the fields in `x-buddies-payload`, the bytes raw. */ + putBlob( + blob: PhotoBlob, + headers = this.auth, + fields: Record = {} + ) { + return httpBytes('/buddies/v1/blob/put', { + bytes: blob.bytes, + headers: { + 'x-buddies-payload': fieldsHeader({ + inboxId: this.inboxId, + blobId: blob.blobId, + bytes: blob.bytes.byteLength, + expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, + readTokenHash: blob.readTokenHash, + ...fields, + }), + ...headers, + }, + }) + } + + /** Bearer `inbox/live` upgrade headers. */ + liveHeaders(headers = this.auth): Record { + return { + upgrade: 'websocket', + 'x-buddies-payload': fieldsHeader({ inboxId: this.inboxId }), + ...headers, + } + } +} + +/** A writer op as a bearer request: the writer-signed envelope plus a token. */ +export const sendWriter = async ( + writer: RelayWriter, + op: BuddiesSignedOp, + fields: Record, + headers: Record +) => + http('POST', `/buddies/v1/${op}`, { + headers, + body: await envelope( + op, + { + inboxId: writer.inboxId, + slotId: writer.slotId, + ts: Date.now(), + nonce: randomId(), + ...fields, + }, + writer.key + ), + }) + // --- Buddies live socket ---------------------------------------------------- export interface LiveConnection { diff --git a/src/test/memoryD1.ts b/src/test/memoryD1.ts index 7809490..8c8cb78 100644 --- a/src/test/memoryD1.ts +++ b/src/test/memoryD1.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { DatabaseSync, type SQLInputValue } from 'node:sqlite' +import { vi } from 'vitest' /** * A D1 test double backed by a real in-memory SQLite database, with the @@ -92,3 +93,25 @@ export class MemoryD1 { export const makeMemoryD1 = (): MemoryD1 & D1Database => new MemoryD1() as MemoryD1 & D1Database + +/** + * Makes statements whose SQL matches `pattern` fail when they run, the way + * D1 does (so Drizzle wraps the error, with the query's parameters, in a + * `DrizzleQueryError`). Restore it with `mockRestore()`. + */ +export const failQueries = ( + d1: MemoryD1, + pattern: RegExp, + message = 'D1_ERROR: database is locked: SQLITE_BUSY' +) => { + const prepare = d1.prepare.bind(d1) + const fail = async () => { + throw new Error(message) + } + const failing = { run: fail, all: fail, raw: fail, first: fail, rows: fail } + return vi.spyOn(d1, 'prepare').mockImplementation((query: string) => + pattern.test(query) + ? ({ ...failing, bind: () => failing } as unknown as MemoryD1Statement) + : prepare(query) + ) +} diff --git a/src/test/memoryKv.ts b/src/test/memoryKv.ts index 1114aac..ccc499e 100644 --- a/src/test/memoryKv.ts +++ b/src/test/memoryKv.ts @@ -1,9 +1,14 @@ -/** In-memory KV stand-in for tests. Implements the get/put/delete subset our - * modules use; cast to the needed `Pick` at the call site. */ +/** In-memory KV stand-in for tests. Implements the get/put/delete/list subset + * our modules use; cast to the needed `Pick` at the call site. */ export interface MemoryKv { get(key: string): Promise put(key: string, value: string): Promise delete(key: string): Promise + /** One page holding every key under `prefix`. */ + list(options?: { prefix?: string }): Promise<{ + keys: { name: string }[] + list_complete: true + }> store: Map } @@ -18,5 +23,12 @@ export const makeMemoryKv = (): MemoryKv => { delete: async (key: string) => { store.delete(key) }, + list: async ({ prefix = '' } = {}) => ({ + keys: [...store.keys()] + .filter((name) => name.startsWith(prefix)) + .sort() + .map((name) => ({ name })), + list_complete: true, + }), } } diff --git a/src/test/sentry.ts b/src/test/sentry.ts new file mode 100644 index 0000000..487abdf --- /dev/null +++ b/src/test/sentry.ts @@ -0,0 +1,52 @@ +import { + CloudflareClient, + createTransport, + getCurrentScope, + linkedErrorsIntegration, + type ErrorEvent, +} from '@sentry/cloudflare' +import { createSentryConfig } from '../sentry' +import type { Environment } from '../types' + +/** What Sentry would send, after the worker's own `beforeSend`. */ +export interface SentryRecorder { + /** Waits for captured events to finish processing, then lists them. */ + events(): Promise + /** Detaches the client, so later captures go nowhere. */ + stop(): void +} + +/** + * Installs a real Sentry client as the current one, configured like the + * worker's (`createSentryConfig`) plus the linked-errors integration that + * reports each `cause`, which records every event its `beforeSend` lets + * through instead of sending it. + */ +export const recordSentryEvents = (): SentryRecorder => { + const config = createSentryConfig({ + SENTRY_DSN: 'https://public@o0.ingest.sentry.io/0', + } as Environment) + const recorded: ErrorEvent[] = [] + const client = new CloudflareClient({ + ...config, + dsn: config.dsn, + defaultIntegrations: false, + integrations: [linkedErrorsIntegration()], + stackParser: () => [], + transport: (options) => createTransport(options, async () => ({})), + beforeSend: (event, hint) => { + const scrubbed = config.beforeSend!(event, hint) as ErrorEvent | null + if (scrubbed) recorded.push(scrubbed) + return null + }, + }) + getCurrentScope().setClient(client) + client.init() + return { + events: async () => { + await client.flush(1_000) + return recorded + }, + stop: () => getCurrentScope().setClient(undefined), + } +} diff --git a/src/types.ts b/src/types.ts index 8e65f4f..753ff6e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -258,6 +258,33 @@ export interface Environment { */ ACCOUNT_BACKUPS?: R2Bucket; + /** + * Signs session access tokens (HS256, witness-work ADR 0027). Secret, at + * least 32 characters, distinct per prod/dev worker. Without it, the + * session routes (`/accounts/v1/sessions*`) answer `disabled` and bearer + * tokens never pass. + */ + ACCOUNT_TOKEN_SECRET?: string; + + /** + * The secret `ACCOUNT_TOKEN_SECRET` replaced, while its tokens (15 minutes) + * run out. Secret; optional. Verifies only, never signs. + */ + ACCOUNT_TOKEN_SECRET_PREVIOUS?: string; + + /** + * Legacy-auth fallback when KV `auth:legacy` is absent: `"off"` refuses the + * pre-session ways of authenticating with 426 `upgrade_required`; anything + * else (and unset) accepts them. + */ + AUTH_LEGACY?: string; + + /** + * Minimum app version (`major.minor.patch`) for the whole app, served by + * GET /app/v1/config. KV `app:min-version` overrides. Unset = no floor. + */ + APP_MIN_VERSION?: string; + // --- Buddies relay ------------------------------------------------------- /** @@ -335,7 +362,20 @@ export interface Environment { >; } -export type AppContext = Context<{ Bindings: Environment }>; +/** Request-scoped values routes share through `c.var`. */ +export interface AppVariables { + /** + * The caller's account and session, set by `requireAccount()` and + * `bearerOrLegacy()` (src/accounts/auth.ts) on bearer requests only. + * Read it with `accountOf(c)`. + */ + account?: import('./accounts/auth').AccountAuth; +} + +/** The Hono environment every app and route group uses. */ +export type AppEnv = { Bindings: Environment; Variables: AppVariables }; + +export type AppContext = Context; export interface HealthCheckResponse { status: 'ok'; diff --git a/wrangler.toml b/wrangler.toml index 823fdbd..25f4238 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -72,6 +72,13 @@ NOTES_IMPORT_WINDOW_DAYS = "30" BUDDIES_ENABLED = "true" # Buddies photo blobs fallback when KV `buddies:photos` is absent. On in dev. BUDDIES_PHOTOS = "on" +# Legacy-auth fallback when KV `auth:legacy` is absent (witness-work ADR 0027): +# "off" refuses pre-session auth with 426 upgrade_required. Named environments +# don't inherit [vars], so it's repeated here. +AUTH_LEGACY = "on" +# App-wide minimum version fallback when KV `app:min-version` is absent +# (GET /app/v1/config). Unset = no floor. +# APP_MIN_VERSION = "1.42.0" # Android Notes Import (witness-work ADR 0017). Stays off until the package, # the Cloud project number, and the PLAY_INTEGRITY_SERVICE_ACCOUNT_JSON secret # are all set. Dev builds (com.leviwilkerson.jwtimedev) aren't on Play, so test @@ -291,6 +298,13 @@ NOTES_IMPORT_WINDOW_DAYS = "30" BUDDIES_ENABLED = "false" # Buddies photo blobs stay off in prod unless KV `buddies:photos` is "on" # (no BUDDIES_PHOTOS fallback here). Not inherited by [env.dev]. +# Legacy-auth fallback when KV `auth:legacy` is absent (witness-work ADR 0027): +# "off" refuses the pre-session ways of authenticating (account envelopes) with +# 426 upgrade_required. Repeated under [env.dev.vars]. +AUTH_LEGACY = "on" +# App-wide minimum version fallback when KV `app:min-version` is absent +# (GET /app/v1/config). Unset = no floor. Repeated under [env.dev.vars]. +# APP_MIN_VERSION = "1.42.0" # Android Notes Import via Play Integrity (witness-work ADR 0017; details in # AGENTS.md). Android stays off until the package, the Cloud project number, # and the PLAY_INTEGRITY_SERVICE_ACCOUNT_JSON secret are all set; the status @@ -359,6 +373,19 @@ POSTHOG_PROJECT_TOKEN = "phc_rTisLfzXzn6yTmrKVKU8dnHNeyd7HNi4Sq6HDhAayt25" # wrangler kv key delete --binding NOTES_KV buddies:photos # use the var # (add `--env dev` for the dev worker). # +# App-wide minimum version (GET /app/v1/config, cacheTtl 60; KV > +# APP_MIN_VERSION var > unset). Below it the app shows Update Required. +# wrangler kv key put --binding NOTES_KV app:min-version '{"minVersion":"1.42.0"}' +# wrangler kv key delete --binding NOTES_KV app:min-version # remove the floor +# +# Legacy auth switch (cacheTtl 60): "off" refuses every request authenticated +# the way it was before sessions (account envelopes, account/register) with +# 426 upgrade_required; an absent key falls back to AUTH_LEGACY, then on. The +# runbook is AGENTS.md, "Turning off backward compatibility". +# wrangler kv key put --binding NOTES_KV auth:legacy off +# wrangler kv key delete --binding NOTES_KV auth:legacy # use the var +# (add `--env dev` for the dev worker). +# # Route planning kill switch (cacheTtl 60): off iff the value is "false"; an # absent key (or a failed read) leaves it on. Per-account limits always apply. # wrangler kv key put --binding NOTES_KV route-planning:enabled false # off @@ -574,3 +601,9 @@ logs.persist = true # jq -c . buddies-fcm-sender.json | wrangler secret put FCM_SERVICE_ACCOUNT_JSON [--env dev] # Android Notes Import (ADR 0017): the Play Integrity service-account JSON key: # wrangler secret put PLAY_INTEGRITY_SERVICE_ACCOUNT_JSON [--env dev] < play-integrity-key.json +# Account session access tokens (ADR 0027); without it, /accounts/v1/sessions* is disabled. +# A different value per worker, at least 32 characters (openssl rand -base64 32): +# wrangler secret put ACCOUNT_TOKEN_SECRET [--env dev] +# Rotation: the old value goes to ACCOUNT_TOKEN_SECRET_PREVIOUS first (verify only; +# refresh tokens issued under it refresh only while it's set, see AGENTS.md). +# wrangler secret put ACCOUNT_TOKEN_SECRET_PREVIOUS [--env dev]