From ca8bcd618fb77dedce4b3bd68be747c14f9fbf34 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Thu, 15 Oct 2026 23:53:21 -0400 Subject: [PATCH 01/25] fix: keep bearer, admin, and dev bypass tokens out of Sentry events Sentry 8 drops only cookies with sendDefaultPii off, so every other request header reached events, the admin token included. Also notes the account as the source of truth for who a User is (witness-work ADR 0027). --- AGENTS.md | 14 ++++++++++++++ docs/buddies-protocol.md | 2 ++ src/sentry.test.ts | 28 ++++++++++++++++++++++++++-- src/sentry.ts | 23 ++++++++++++++--------- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 22ce714..862c29f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -417,6 +417,20 @@ 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). +**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. + - `POST /accounts/v1/account/register`: self-signed under the `ww-account/v1` context; idempotent per key. Counts against the Buddies register edge tier and the per-caller daily quota. diff --git a/docs/buddies-protocol.md b/docs/buddies-protocol.md index 1fb4907..9c34a09 100644 --- a/docs/buddies-protocol.md +++ b/docs/buddies-protocol.md @@ -44,6 +44,8 @@ Since the MVP: shared Plans and Follow-up invitations, requests to join, and bad Ed25519 public keys are 32 raw bytes, `b64u`-encoded (43 chars). +`inbox/register`, `invite/create`, and `blob/put` will also need `Authorization: Bearer` from an attested account session whose account is the one the inbox is linked to (or, for `inbox/register`, the one its `account` proof names), on top of the signature above ([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)). Writer ops, invites, and `blob/get` never carry a token: it would tell the relay which account writes into which inbox. + ### Responses Success: HTTP 200 with `{ "ok": true, ... }`. Failure: `{ "ok": false, "error": "", "code": "" }` (`code` repeats `error`; older relays sent `error` alone) with: diff --git a/src/sentry.test.ts b/src/sentry.test.ts index af06165..46e1bad 100644 --- a/src/sentry.test.ts +++ b/src/sentry.test.ts @@ -156,17 +156,41 @@ 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 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('leaves events without contact links untouched', () => { const event: TransactionEvent = { type: 'transaction', diff --git a/src/sentry.ts b/src/sentry.ts index 5096cd1..d7c7a5f 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -46,24 +46,29 @@ 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' `backup/put` envelope (account key and 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)$)/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]; } } function scrubEvent(event: T): T { - dropBuddiesHeaders(event); + dropSecretHeaders(event); return scrubStrings(event) as T; } From f6fa67a396d07d9491987a61f486fbfcc82a2832 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 00:36:04 -0400 Subject: [PATCH 02/25] feat: GET /app/v1/config serves the app's minimum version The app-wide successor to Notes Import's own floor (witness-work ADR 0027, "Updates and the legacy switch"): every app version from sessions on reads it at launch and on returning to the foreground, and below `minAppVersion` shows a blocking Update Required screen. KV `app:min-version`, then the `APP_MIN_VERSION` var, then no floor; edge-cached for 60 seconds, served with `Cache-Control: no-store`, and rate-limited per IP under its own `app` family. The worker never rejects a request for its version. --- README.md | 3 +- src/app.ts | 5 +++ src/appConfig.test.ts | 90 +++++++++++++++++++++++++++++++++++++++++ src/appConfig.ts | 54 +++++++++++++++++++++++++ src/openapi/coreDocs.ts | 24 +++++++++++ src/openapi/route.ts | 5 ++- src/rateLimit.ts | 1 + src/types.ts | 6 +++ wrangler.toml | 12 ++++++ 9 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 src/appConfig.test.ts create mode 100644 src/appConfig.ts diff --git a/README.md b/README.md index f2d3406..63d9a4b 100644 --- a/README.md +++ b/README.md @@ -459,7 +459,8 @@ 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 +`attestation`, `route-planning`, `admin`, `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`. diff --git a/src/app.ts b/src/app.ts index 80e304b..e99475d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -52,6 +52,7 @@ import { verifyDocs as attestationVerifyDocs, } from "./attestation/openapi"; import { handleAppStoreRatingsRequest } from "./appStoreRatings/route"; +import { handleAppConfigRequest } from "./appConfig"; import { Sentry } from "./sentry"; import { createBuddiesRoutes } from "./buddies/route"; import { createAccountsRoutes } from "./accounts/route"; @@ -62,6 +63,7 @@ import { createRoutePlanningRoutes } from "./routePlanning/route"; import { mountApiDocs } from "./openapi/route"; import { aasaDocs, + appConfigDocs, appStoreRatingsDocs, autocompleteDocs, buddyInvitePageDocs, @@ -126,10 +128,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", 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/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..3f52165 100644 --- a/src/openapi/route.ts +++ b/src/openapi/route.ts @@ -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', @@ -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: [ 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/types.ts b/src/types.ts index 8e65f4f..66201d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -258,6 +258,12 @@ export interface Environment { */ ACCOUNT_BACKUPS?: R2Bucket; + /** + * 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 ------------------------------------------------------- /** diff --git a/wrangler.toml b/wrangler.toml index 823fdbd..1f18b4d 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -72,6 +72,9 @@ NOTES_IMPORT_WINDOW_DAYS = "30" BUDDIES_ENABLED = "true" # Buddies photo blobs fallback when KV `buddies:photos` is absent. On in dev. BUDDIES_PHOTOS = "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 +294,9 @@ 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]. +# 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 +365,12 @@ 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 +# (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 From d8c52dc453dd9e979507300744aff6986b0b8754 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 00:36:46 -0400 Subject: [PATCH 03/25] feat: attested account sessions and bearer access tokens An account proves it's the app once and then calls with short-lived bearer tokens (witness-work ADR 0027; "Sessions" in docs/accounts-protocol.md). - Sessions attest through the shared registry: `account-session` joins ATTESTATION_PURPOSES, so POST /attestation/v1/challenge issues its App Attest v2 or Play Integrity challenge, with contentHash = requestHash = hex(SHA-256("ww-account/v1/session\n" + accountPub)). verifyAttestation (src/attestation/http.ts) is requireAttestation's check for routes with their own error format. - POST /accounts/v1/sessions takes an envelope self-signed by the account key (op `session/open`) plus that attestation (or, on development only, the dev bypass from src/devBypass.ts), creates the account when the key has none, and answers 201 with a 90-day session and `Location: /accounts/v1/sessions/current`. Optional sealed device label (512 chars). At most 10 live sessions an account (the oldest is ended) and 20 opens per key a rolling day, on top of the register edge tier. A refused attestation is 403 `attestation_failed` with a `reason`. - POST /accounts/v1/sessions/refresh rotates the refresh token every time. The replaced token still works for a minute; after that, presenting it ends the session. - DELETE /accounts/v1/sessions/current (Bearer) ends the session, idempotently. GET /accounts/v1/account (Bearer) reads the account. - Access tokens are HS256 JWTs (15 minutes, never past the session's end) with claims iss, aud, sub, sid, platform, iat, and exp, under ACCOUNT_TOKEN_SECRET, with ACCOUNT_TOKEN_SECRET_PREVIOUS verifying through a rotation. Without a secret, the session routes answer `disabled`. - account_sessions (migration 0001_account-sessions) stores only SHA-256 hashes of the current and previous refresh token: no install id, key, or IP. The hourly cron deletes sessions a week after they end. - requireAccount() and bearerOrLegacy() (src/accounts/auth.ts) put the verified caller on c.var.account; a bad token is 401 `unauthorized` with WWW-Authenticate. account/info, handoff's old-phone ops, and backup put/list/get accept a bearer token or, as before, the envelope; backup/delete takes both (step-up). account/register and account/info are documented as legacy. Handoff and backup stay POST /{op} so their ids never reach a URL. - The legacy switch (KV `auth:legacy`, then AUTH_LEGACY, then on) refuses the pre-session ways with 426 `upgrade_required` once it's off. - Sentry drops the bearer backup put's x-ww-slot-id header. --- .dev.vars.example | 5 + migrations/accounts/0001_account-sessions.sql | 17 + migrations/accounts/meta/0001_snapshot.json | 301 +++++++ migrations/accounts/meta/_journal.json | 7 + src/accounts/admin.ts | 4 +- src/accounts/attestation.ts | 110 +++ src/accounts/auth.test.ts | 183 ++++ src/accounts/auth.ts | 195 +++++ src/accounts/backup.ts | 254 +++--- src/accounts/bearerRoutes.test.ts | 510 +++++++++++ src/accounts/contracts.ts | 66 +- src/accounts/handoff.ts | 156 ++-- src/accounts/legacySwitch.ts | 54 ++ src/accounts/openapi.ts | 500 +++++++++-- src/accounts/route.ts | 170 ++-- src/accounts/schema.ts | 37 + src/accounts/session.test.ts | 789 ++++++++++++++++++ src/accounts/session.ts | 163 ++++ src/accounts/sessionStore.ts | 249 ++++++ src/accounts/signed.ts | 58 +- src/accounts/store.ts | 18 +- src/accounts/tokens.test.ts | 207 +++++ src/accounts/tokens.ts | 251 ++++++ src/app.ts | 13 +- src/attestation/http.ts | 48 ++ src/attestation/openapi.ts | 2 +- src/attestation/purposes.ts | 4 + src/buddies/contracts.ts | 18 +- src/buddies/errorResponse.ts | 21 +- src/buddies/route.ts | 4 +- src/index.test.ts | 33 + src/index.ts | 14 + src/openapi/route.ts | 13 +- src/routePlanning/route.ts | 4 +- src/sentry.test.ts | 19 + src/sentry.ts | 5 +- src/test/accounts.ts | 117 +++ src/test/buddies.ts | 16 + src/test/memoryKv.ts | 16 +- src/types.ts | 36 +- wrangler.toml | 20 + 41 files changed, 4368 insertions(+), 339 deletions(-) create mode 100644 migrations/accounts/0001_account-sessions.sql create mode 100644 migrations/accounts/meta/0001_snapshot.json create mode 100644 src/accounts/attestation.ts create mode 100644 src/accounts/auth.test.ts create mode 100644 src/accounts/auth.ts create mode 100644 src/accounts/bearerRoutes.test.ts create mode 100644 src/accounts/legacySwitch.ts create mode 100644 src/accounts/session.test.ts create mode 100644 src/accounts/session.ts create mode 100644 src/accounts/sessionStore.ts create mode 100644 src/accounts/tokens.test.ts create mode 100644 src/accounts/tokens.ts create mode 100644 src/test/accounts.ts 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/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/src/accounts/admin.ts b/src/accounts/admin.ts index e92d7e7..d93c7ff 100644 --- a/src/accounts/admin.ts +++ b/src/accounts/admin.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono' -import type { AppContext, Environment } from '../types' +import type { AppContext, AppEnv } from '../types' import { Sentry } from '../sentry' import { timingSafeEqual } from '../crypto' import { apiError } from '../errors' @@ -128,7 +128,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..5170dad --- /dev/null +++ b/src/accounts/attestation.ts @@ -0,0 +1,110 @@ +import type { AppContext } from '../types' +import { Sentry } 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': + Sentry.captureException(cause) + 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..7f40561 --- /dev/null +++ b/src/accounts/auth.ts @@ -0,0 +1,195 @@ +import type { MiddlewareHandler } from 'hono' +import type { AppContext, AppEnv, Environment } from '../types' +import { Sentry } 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') + Sentry.captureException(error) + 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..88a190c 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,39 @@ 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: b64u of 32 random bytes, in its canonical spelling. */ +export const parseRefreshToken = (value: unknown): Uint8Array | null => { + if (typeof value !== 'string') return null + const bytes = decodeB64u(value) + return bytes && bytes.length === 32 && bytesToBase64Url(bytes) === value + ? bytes + : null +} 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..f27cd6d 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} -${readTier} ${backupNotes}`, - requestBody: keySignedWith('backup/list', {}), +${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,228 @@ 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`.', + }) + +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: b64u( + 'The session’s refresh token, 32 bytes. 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) gets a new pair; sending 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: b64u('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( + 'Unknown, replaced, ended, or expired: open a new session.', + ['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..12190a3 --- /dev/null +++ b/src/accounts/session.test.ts @@ -0,0 +1,789 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SigningKey, + TEST_DEV_BYPASS, + 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) + +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(/^[A-Za-z0-9_-]{43}$/), + sessionExpiresAt: START + 90 * DAY, + }) + + // 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(/^[A-Za-z0-9_-]{43}$/), + sessionExpiresAt: session.sessionExpiresAt, + }) + 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) + advance(59 * SECOND) + const retried = await refresh(session.refreshToken) + expect(retried.status).toBe(200) + // The pair issued in between is gone; the retried one works. + expect((await refresh(rotated.body.refreshToken)).status).toBe(401) + 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(b64u(randomBytes(32))) + 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) + }) +}) + +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..cf62bb8 --- /dev/null +++ b/src/accounts/session.ts @@ -0,0 +1,163 @@ +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, 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, { + 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. */ +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), 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..e083aba --- /dev/null +++ b/src/accounts/sessionStore.ts @@ -0,0 +1,249 @@ +import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' +import { bytesToBase64Url, sha256Bytes } from '../crypto' +import { ACCOUNT_LIMITS } from './contracts' +import { accountSessions, type SessionPlatform } from './schema' +import type { AccountsDb } from './store' + +/** + * Session rows in the accounts database (`account_sessions`). A session is + * live until it's revoked or reaches `expiresAt`, 90 days after it opened. + * Only the SHA-256 of a refresh 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 other + * replaced token, ends the session. + */ + +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 refreshHash = async (token: Uint8Array): Promise => + bytesToBase64Url(await sha256Bytes(token)) + +const newRefreshToken = async () => { + const bytes = randomBytes(32) + return { token: bytesToBase64Url(bytes), hash: await refreshHash(bytes) } +} + +/** 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, + expiresAt: accountSessions.expiresAt, + revokedAt: accountSessions.revokedAt, + rotatedAt: accountSessions.rotatedAt, +} + +type SessionRow = { + id: string + accountId: string + platform: SessionPlatform + 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. + */ +export const createSession = async ( + db: AccountsDb, + input: { + accountId: string + platform: SessionPlatform + device: string | null + }, + now: number = Date.now() +): Promise => { + const id = bytesToBase64Url(randomBytes(16)) + const refresh = await newRefreshToken() + 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)) + ) + +/** + * Replaces the session's refresh token. Null means 401 `unauthorized`: an + * unknown token, an ended or expired session, or a replaced token presented + * more than a minute after it was replaced, which also ends the session. + * + * Within that minute the replaced token gets another new token, and the one + * issued in between stops working; the minute still counts from the first + * rotation, so retries can't stretch it. + */ +export const refreshSession = async ( + db: AccountsDb, + token: Uint8Array, + now: number = Date.now() +): Promise => { + const hash = await refreshHash(token) + + const current = (await db + .select(sessionColumns) + .from(accountSessions) + .where(eq(accountSessions.refreshHash, hash)) + .get()) as SessionRow | undefined + if (current) { + if (!isLive(current, now)) return null + const next = await newRefreshToken() + const rotated = await db + .update(accountSessions) + .set({ + refreshHash: next.hash, + previousRefreshHash: hash, + rotatedAt: new Date(now), + }) + .where( + and( + eq(accountSessions.id, current.id), + eq(accountSessions.refreshHash, hash), + isNull(accountSessions.revokedAt) + ) + ) + if (changes(rotated) === 1) + return { session: record(current), refreshToken: next.token } + // A concurrent refresh with the same token won; this one is now the + // replaced token, answered below. + } + + const replaced = (await db + .select(sessionColumns) + .from(accountSessions) + .where(eq(accountSessions.previousRefreshHash, hash)) + .get()) as SessionRow | undefined + if (!replaced) return null + const inGrace = + replaced.rotatedAt != null && + now - replaced.rotatedAt.getTime() <= refreshGraceMs + if (!isLive(replaced, now) || !inGrace) { + await revoke(db, replaced.id, now) + return null + } + const next = await newRefreshToken() + const reissued = await db + .update(accountSessions) + .set({ refreshHash: next.hash }) + .where( + and( + eq(accountSessions.id, replaced.id), + eq(accountSessions.previousRefreshHash, hash), + isNull(accountSessions.revokedAt) + ) + ) + return changes(reissued) === 1 + ? { session: record(replaced), refreshToken: next.token } + : 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..8d86214 100644 --- a/src/accounts/signed.ts +++ b/src/accounts/signed.ts @@ -4,6 +4,7 @@ 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. diff --git a/src/accounts/store.ts b/src/accounts/store.ts index 5d3e565..0cbf175 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 @@ -124,8 +129,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 +141,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/tokens.test.ts b/src/accounts/tokens.test.ts new file mode 100644 index 0000000..225bfdc --- /dev/null +++ b/src/accounts/tokens.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' +import { bytesToBase64Url } from '../crypto' +import { + ACCESS_TOKEN_AUDIENCE, + accessTokenKeys, + 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() + }) +}) diff --git a/src/accounts/tokens.ts b/src/accounts/tokens.ts new file mode 100644 index 0000000..10bfdcf --- /dev/null +++ b/src/accounts/tokens.ts @@ -0,0 +1,251 @@ +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. + * + * 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 +} + +interface TokenKey { + 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 key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'] + ) + return { kid: bytesToBase64Url(digest).slice(0, 16), key } + })() + 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 } +} + +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 e99475d..1327eca 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,7 +3,7 @@ * with Sentry and the cron handler, and re-exports the Durable Objects. */ import { Hono } from "hono"; -import type { Environment, AppContext, HealthCheckResponse } from "./types"; +import type { AppEnv, AppContext, HealthCheckResponse } from "./types"; import { HERE_API, HTTP_STATUS } from "./config"; import { apiError, dateHeader } from "./errors"; import { rateLimit } from "./rateLimit"; @@ -78,7 +78,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); @@ -244,9 +244,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/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..01f5e3f 100644 --- a/src/buddies/contracts.ts +++ b/src/buddies/contracts.ts @@ -132,7 +132,11 @@ 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 once the legacy switch (`auth:legacy`) is + * off. 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 +148,18 @@ 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, or a `backup/delete` signed by + * another account than the bearer token's. + */ forbidden: 403, + /** + * 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 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/route.ts b/src/buddies/route.ts index 8d151b0..e4bfa56 100644 --- a/src/buddies/route.ts +++ b/src/buddies/route.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono' -import type { AppContext, Environment } from '../types' +import type { AppContext, AppEnv, Environment } from '../types' import { Sentry } from '../sentry' import { bytesToBase64Url, sha256Bytes } from '../crypto' import { @@ -507,7 +507,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/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..f66cce8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,19 @@ import type { Environment } from "./types"; import { runRatingsSweepStep } from "./appStoreRatings/ratings"; import { Sentry, 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"); + Sentry.captureException(error); + } +} // 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/openapi/route.ts b/src/openapi/route.ts index 3f52165..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' @@ -43,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', @@ -95,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/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.test.ts b/src/sentry.test.ts index 46e1bad..96eb607 100644 --- a/src/sentry.test.ts +++ b/src/sentry.test.ts @@ -167,6 +167,25 @@ describe('Sentry scrubbers', () => { }) }) + 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', diff --git a/src/sentry.ts b/src/sentry.ts index d7c7a5f..82ab279 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -54,10 +54,11 @@ function scrubStrings(value: unknown, seen = new WeakSet()): unknown { * - `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' `backup/put` envelope (account key and slot id). + * accounts' legacy `backup/put` envelope (account key and slot id). + * - `x-ww-slot-id`: the bearer `backup/put`'s slot id. */ const SECRET_HEADER = - /^(authorization$|x-ww-admin-token$|x-ww-dev-bypass$|x-buddies-|x-ww-(p|s|payload|signature)$)/i; + /^(authorization$|x-ww-admin-token$|x-ww-dev-bypass$|x-buddies-|x-ww-(p|s|payload|signature|slot-id)$)/i; function dropSecretHeaders(event: Event): void { const headers = event.request?.headers; 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/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/types.ts b/src/types.ts index 66201d2..753ff6e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -258,6 +258,27 @@ 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. @@ -341,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 1f18b4d..2b8729f 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -72,6 +72,10 @@ 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" @@ -294,6 +298,10 @@ 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" @@ -369,6 +377,13 @@ POSTHOG_PROJECT_TOKEN = "phc_rTisLfzXzn6yTmrKVKU8dnHNeyd7HNi4Sq6HDhAayt25" # 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 @@ -586,3 +601,8 @@ 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). +# wrangler secret put ACCOUNT_TOKEN_SECRET_PREVIOUS [--env dev] From 29c55f1f45b38bd6e1f915497a2d2919c4ad05f4 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 00:36:57 -0400 Subject: [PATCH 04/25] test: drive account sessions through the verify loop The launcher writes a generated ACCOUNT_TOKEN_SECRET into the local worker's vars, and doctor checks that sessions are on (a zero refresh token gets 401 `unauthorized` from POST /accounts/v1/sessions/refresh). The accounts e2e suite opens a session through the dev bypass (201 with Location), reads GET /accounts/v1/account and the legacy account/info with the bearer token, refreshes, closes with DELETE /accounts/v1/sessions/current, uses bearer backup and handoff, turns the legacy switch off in local KV to see 426 `upgrade_required`, and reads /app/v1/config. The verify map gets an accounts feature file. --- .agents/skills/verify-ww-api/SKILL.md | 4 +- .../skills/verify-ww-api/features/README.md | 1 + .../skills/verify-ww-api/features/accounts.md | 36 +++ scripts/verify/dev.mjs | 21 +- src/e2e/accounts.e2e.test.ts | 205 +++++++++++++++++- 5 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 .agents/skills/verify-ww-api/features/accounts.md diff --git a/.agents/skills/verify-ww-api/SKILL.md b/.agents/skills/verify-ww-api/SKILL.md index 607af5a..820924f 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. @@ -116,6 +117,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 | diff --git a/.agents/skills/verify-ww-api/features/README.md b/.agents/skills/verify-ww-api/features/README.md index 05d4671..8e302ad 100644 --- a/.agents/skills/verify-ww-api/features/README.md +++ b/.agents/skills/verify-ww-api/features/README.md @@ -44,5 +44,6 @@ Keep implementation details out of the map. Name only user paths, stable handles - [Buddies relay](./buddies-relay.md) covers pairing end to end, sync read-backs, auth failures, caps, the kill switch, and fuzzing. - [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/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/src/e2e/accounts.e2e.test.ts b/src/e2e/accounts.e2e.test.ts index 4eec027..a3ab0b8 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,169 @@ 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_-]{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('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) From 8ac961ec1f95abe77d8fc2a394669a80c1aa2922 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 00:36:57 -0400 Subject: [PATCH 05/25] docs: account sessions, bearer routes, and the legacy switch runbook AGENTS.md covers the session routes (POST /accounts/v1/sessions, its refresh, DELETE /accounts/v1/sessions/current, GET /accounts/v1/account), attesting through /attestation/v1 under `account-session`, storage, tokens, the dev bypass, secret rotation, the helpers new routes build on, /app/v1/config, and "Turning off backward compatibility": raise app:min-version and notes-import:min-version, then turn auth:legacy off, and how to undo it. README gets the same routes in brief, and the 401/426 answers beside the shared error envelope. --- AGENTS.md | 217 +++++++++++++++++++++++++++++++++++++++++++++++------- README.md | 48 ++++++++++-- 2 files changed, 232 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 862c29f..b7565c7 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) @@ -423,22 +428,26 @@ 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 +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. -- `POST /accounts/v1/account/register`: self-signed under the +- `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. + 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 }`, the account key's signature over the inbox id and owner key. The link (`account_inboxes`) is written only after the inbox accepted the @@ -448,7 +457,7 @@ versions from before sessions. 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`. @@ -476,21 +485,170 @@ 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 }`. Each refresh replaces the + token. The one just replaced still works for 60 seconds from its rotation + (a lost response; retries don't extend it). Presenting it later ends the + session. An unknown, ended, or expired token is 401 `unauthorized`. + 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 +# After 15 minutes: +wrangler secret delete ACCOUNT_TOKEN_SECRET_PREVIOUS +``` + +Skipping the previous slot only costs each device one extra refresh. 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`. + +### 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. + +#### Turning off backward compatibility + +Do these in order. Each step takes up to 60 seconds to reach every colo. + +```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 @@ -506,9 +664,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` diff --git a/README.md b/README.md index 63d9a4b..2fc2ce3 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,36 @@ 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). +- **`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. @@ -447,6 +479,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,10 +495,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`, `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`. +`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. From acd38527296496d33c15f21afa26bf00b7d4090d Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 01:18:14 -0400 Subject: [PATCH 06/25] feat: Buddies relay on account sessions (bearer tokens) A Buddies request with an Authorization header is a bearer request (witness-work ADR 0027): its access token must verify (401 unauthorized, never a fallback to the signature). 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. - Owner ops send their fields as plain JSON (no envelope, ts, or nonce); inbox/live and blob/put send them in x-buddies-payload with no signature header. The inbox DO authorizes them against its stored owner_account: another account is 403 forbidden, an inbox registered before accounts 409 not_linked (new code). No nonce is checked or recorded for them; the owner's request budget is shared by both forms. - inbox/register keeps its self-signed envelope; in a bearer request the account proof is required (400) and must name the token's account (403 forbidden), checked before the signature and quota. - Writer ops keep the writer-signed envelope as the slot capability; the token's session is verified but its account is never compared with the slot or stored. Invite and blob reads keep today's body. - The live upgrade reaches the inbox DO with the token's account in an internal header the Worker always strips from client requests. - Bearer requests count the per-caller edge tiers and the inbox/register daily quota against a hash of the account instead of the client IP; budgets are unchanged. - Payload parsing splits into BUDDIES_FIELD_PARSERS (shared by bearer bodies) and BUDDIES_PAYLOAD_PARSERS (adds ts and nonce), unchanged for signed requests. - OpenAPI documents both forms (bearer first), the bearerAuth scheme, not_linked, forbidden, unauthorized, and 426 on every route. --- src/buddies/contracts.ts | 91 +++- src/buddies/envelope.ts | 29 ++ src/buddies/inboxDO.ts | 133 +++-- src/buddies/limits.ts | 29 +- src/buddies/openapi.ts | 287 ++++++----- src/buddies/registrationQuota.ts | 3 +- src/buddies/route.ts | 305 +++++++++--- src/buddies/sessions.test.ts | 812 +++++++++++++++++++++++++++++++ 8 files changed, 1449 insertions(+), 240 deletions(-) create mode 100644 src/buddies/sessions.test.ts diff --git a/src/buddies/contracts.ts b/src/buddies/contracts.ts index 01f5e3f..3946d74 100644 --- a/src/buddies/contracts.ts +++ b/src/buddies/contracts.ts @@ -54,6 +54,12 @@ 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' /** * Photo blobs (encrypted note photos, too big for an event). `blob/put` is @@ -134,8 +140,9 @@ export const BUDDIES_ERROR_STATUS = { disabled: 503, /** * `inbox/live` without a WebSocket upgrade, and any request authenticated - * the way it was before sessions once the legacy switch (`auth:legacy`) is - * off. Never carries `Retry-After`: updating the app is the fix. + * the way it was before sessions (no `Authorization` header) once the + * legacy switch (`auth:legacy`) is off. Never carries `Retry-After`: + * updating the app is the fix. */ upgrade_required: 426, /** A `blob/put` body (declared or streamed) over `blobBytes`. */ @@ -149,10 +156,17 @@ export const BUDDIES_ERROR_STATUS = { /** `inbox/register` named an account the accounts database doesn't have. */ unknown_account: 404, /** - * A handoff channel another account opened, or a `backup/delete` signed by - * another account than the bearer token's. + * 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 registered before accounts + * (linked to none). 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"`. @@ -412,6 +426,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 } @@ -592,14 +609,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 } @@ -613,6 +626,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, @@ -621,11 +641,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) @@ -734,6 +757,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. */ @@ -745,6 +791,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/inboxDO.ts b/src/buddies/inboxDO.ts index bba9e33..e70c522 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,7 +319,9 @@ 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`: another + * account is `forbidden`, and an inbox without the row is `not_linked`. */ const OWNER_ACCOUNT_SCHEMA = `CREATE TABLE IF NOT EXISTS owner_account ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), @@ -366,6 +375,11 @@ 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 @@ -424,7 +438,9 @@ export class BuddyInbox extends DurableObject { return result } - async sync(call: Signed): Promise> { + async sync( + call: OwnerCall + ): Promise> { const auth = await this.#authenticate('inbox/sync', call, 'owner') if (!auth.ok) return auth return this.#commit(call, 'owner', auth.value, (now) => @@ -432,7 +448,9 @@ export class BuddyInbox extends DurableObject { ) } - 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 +462,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 +522,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 +535,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 +562,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 +575,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 +594,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 +684,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 +813,7 @@ export class BuddyInbox extends DurableObject { * uploads. */ async putBlob( - call: Signed, + call: OwnerCall, body: Uint8Array ): Promise> { const bucket = this.env.BUDDY_BLOBS @@ -914,7 +932,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 +957,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 +994,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 {} @@ -1141,24 +1172,31 @@ 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. */ async #authenticate( op: BuddiesSigningOp, - call: Signed, + call: AuthCall, ref: KeyRef ): 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 (ref === 'owner' && !SUSPENDED_ALLOWED_OPS.has(op) && this.#suspended()) return fail('suspended') const [budget, limit] = @@ -1179,18 +1217,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 +1241,31 @@ 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 + * linked to. An inbox registered before accounts has no link to compare + * (`not_linked`); the app links it with `inbox/register` and retries. + */ + #refuseAccount(accountId: string): BuddiesFailure | null { + const linked = this.#ownerAccountId() + 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 @@ -1341,6 +1394,18 @@ export class BuddyInbox extends DurableObject { : null } + /** The account this inbox is linked to, or null when none. */ + #ownerAccountId(): 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') + .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..93b2231 100644 --- a/src/buddies/openapi.ts +++ b/src/buddies/openapi.ts @@ -5,6 +5,7 @@ import { apiErrorSchema, staleErrorSchema, b64u, + signedEnvelopeSchema, unsignedEnvelopeSchema, buddiesId, errorResponse, @@ -25,13 +26,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 +73,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 +109,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 +125,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 +137,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 registered before accounts 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 +172,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 was registered before accounts and is linked to none. Link it with a bearer `inbox/register` (with its `account` proof), then retry.', photos_disabled: 'Photo blobs are off (KV `buddies:photos`), or this worker has no blob bucket. `Retry-After: 60`.', } @@ -141,6 +200,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 +215,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 +246,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 +265,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 +296,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 +364,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 +372,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 +388,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 +404,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,13 +418,12 @@ 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.', @@ -351,24 +431,20 @@ export const buddiesOpDocs: Record = { tierLine('read'), KILL_SWITCH_OFF ), - requestBody: signedBody( - signedPayload({ + 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')), }, }), - '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 +452,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 +461,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 +470,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 +508,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 +517,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 +525,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 +540,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 +555,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 +563,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 +572,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 +580,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 +609,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 +622,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 +630,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 +646,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 +656,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 +688,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 +725,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 +747,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 +780,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 +788,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 +796,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 +813,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 +827,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 registered before accounts.', + `**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 +870,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 +893,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 registered before accounts.', + `**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 +906,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 +923,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 +943,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 +953,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 +992,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 e4bfa56..2303056 100644 --- a/src/buddies/route.ts +++ b/src/buddies/route.ts @@ -6,31 +6,41 @@ import { BUDDIES_ALWAYS_ALLOWED_OPS, BUDDIES_BLOB_GET_OP, BUDDIES_BLOB_PUT_OP, + BUDDIES_FIELD_PARSERS, BUDDIES_LIMITS, + BUDDIES_LIVE_ACCOUNT_HEADER, BUDDIES_LIVE_OP, 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, } from './contracts' import { + isEnvelopeBody, isWebSocketUpgrade, parseEnvelope, readBlobBody, readBlobPutCall, readEnvelopeText, + readHeaderFields, readLiveCall, signedCall, verifyBuddiesSignature, @@ -44,11 +54,19 @@ import { findInboxAccount, linkInbox, } from '../accounts/store' +import { + authenticateAccount, + hasAuthorization, + parseJsonOrNull, + type AccountAuth, +} from '../accounts/auth' +import { refuseLegacyAuth } 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 +84,29 @@ 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. + * * 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 +141,61 @@ 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 +} + +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. + */ +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 } + } else { + const refused = await refuseLegacyAuth(c.env) + if (refused) return { ok: false, response: refused } + caller = { + key: buddiesCallerKey(c.req.header('CF-Connecting-IP')), + account: null, + } + } + return (await allowBuddiesCaller(c.env, op, caller.key)) + ? { ok: true, caller } + : { ok: false, response: edgeLimited() } +} + +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,23 +232,42 @@ 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. */ 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) } @@ -179,6 +281,7 @@ const registerInbox = async ( const owner = await linkInbox(db, call.inboxId, check.claim) if (owner !== check.claim) return fail('conflict') } catch (error) { + if (account) throw error console.warn('buddies: linking an inbox failed') Sentry.captureException(error) return ok({ body: {} }) @@ -187,8 +290,8 @@ const registerInbox = async ( return ok({ body: accountId ? { accountId } : {} }) } -const SIGNED_OPS: SignedRunners = { - 'inbox/register': registerInbox, +/** Owner ops, signed (legacy) or by the bearer token's account. */ +const OWNER_OPS: OwnerRunners = { // `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) @@ -214,6 +317,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 +361,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 @@ -305,21 +416,40 @@ 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 (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 +485,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) @@ -381,25 +516,50 @@ const handleBuddiesOp = async ( } /** - * 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) @@ -414,34 +574,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) @@ -464,14 +644,15 @@ const handleBlobPut = async (c: AppContext): Promise => { /** * `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 diff --git a/src/buddies/sessions.test.ts b/src/buddies/sessions.test.ts new file mode 100644 index 0000000..540b652 --- /dev/null +++ b/src/buddies/sessions.test.ts @@ -0,0 +1,812 @@ +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_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'), + }) + }) +}) + +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('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) + }) +}) From 2ca3e5403593bed41314945b64cf3a005709c8fd Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 01:25:37 -0400 Subject: [PATCH 07/25] test: drive the bearer Buddies relay through e2e and the fuzzer The buddies-sessions e2e suite opens sessions through the dev bypass and runs a bearer round trip on the local worker: register with a proof, slot/add, a buddy's event/put with its own token, owner sync (twice, no replay), blob put/get, invite fetch/claim, and the live socket's hello, changed, pong, and 4001 close. It checks not_linked until a legacy inbox links, forbidden for another account's token and proof, 401 for a bad token and 400 for an envelope or a signature header on a bearer request, 426 for every legacy form once auth:legacy is off while bearer requests work, and one account's unsigned budget spent from rotating addresses while another account and a legacy caller from that address still pass. The fuzzer opens sessions with the recorded dev bypass token, keeps a second (bearer) canary that must come through intact, and adds nine bearer generators: bad tokens on every route class, cross-account, not_linked, malformed plain bodies, register proofs, bearer blob/put headers, writer ops with any session, a round trip, and per-account edge limits. Without a bypass token they're skipped and counted as skipped. Report transcripts redact authorization and x-ww-dev-bypass. The wire client gains OWNER_OPS and the account envelope and inbox-link helpers, kept in step with the server by buddies-wire.test.ts. --- .agents/skills/verify-ww-api/SKILL.md | 7 +- .../skills/verify-ww-api/features/README.md | 2 +- .../verify-ww-api/features/buddies-relay.md | 12 +- scripts/verify/buddies-wire.mjs | 42 ++ scripts/verify/buddies-wire.test.ts | 17 + scripts/verify/fuzz-buddies.mjs | 466 +++++++++++++++++- src/e2e/buddiesSessions.e2e.test.ts | 272 ++++++++++ src/test/e2e.ts | 219 +++++++- 8 files changed, 1021 insertions(+), 16 deletions(-) create mode 100644 src/e2e/buddiesSessions.e2e.test.ts diff --git a/.agents/skills/verify-ww-api/SKILL.md b/.agents/skills/verify-ww-api/SKILL.md index 820924f..b72b937 100644 --- a/.agents/skills/verify-ww-api/SKILL.md +++ b/.agents/skills/verify-ww-api/SKILL.md @@ -69,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`. @@ -77,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: @@ -85,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 @@ -133,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 8e302ad..059e1e0 100644 --- a/.agents/skills/verify-ww-api/features/README.md +++ b/.agents/skills/verify-ww-api/features/README.md @@ -41,7 +41,7 @@ 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`. 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/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/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/e2e/buddiesSessions.e2e.test.ts b/src/e2e/buddiesSessions.e2e.test.ts new file mode 100644 index 0000000..8fc117a --- /dev/null +++ b/src/e2e/buddiesSessions.e2e.test.ts @@ -0,0 +1,272 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { + blobOfSize, + photoBlob, + randomBytes, + randomId, + relayError, + unsignedEnvelope, + b64u, +} 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. + */ + +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) + }) +}) + +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/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 { From 674a891d8fc14ca849d53960a0b564bd4e42d700 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 01:26:59 -0400 Subject: [PATCH 08/25] docs: Buddies on account sessions in AGENTS.md and the protocol AGENTS.md's Buddies section gains an Authentication bullet (bearer and legacy forms per op class, forbidden and not_linked, the 426 once auth:legacy is off, the live socket's internal account header), keys the per-caller limits and the registration quota on the hashed account for bearer requests, and replaces the App Attest "not yet built" item: bearer requests are attested once, at session open, and only retiring legacy requests remains. docs/buddies-protocol.md (ww-api's copy) adds "Authentication (sessions)" in readable names, the unauthorized, forbidden, not_linked, and extended upgrade_required codes, the bearer forms of inbox/live and blob/put, 4003 on the live socket, the account proof on inbox/register, and per-account limits. --- AGENTS.md | 51 ++++++++++++++++++++++++++++++---------- docs/buddies-protocol.md | 48 +++++++++++++++++++++++++++---------- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b7565c7..c5ef565 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -266,6 +266,27 @@ 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; + the app links it with `inbox/register` and retries). 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. 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). - **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 @@ -277,8 +298,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`, @@ -292,8 +316,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 @@ -382,15 +407,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. @@ -448,8 +474,9 @@ versions from before sessions. 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 }`, - the account key's signature over the inbox id and owner key. The link +- `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` diff --git a/docs/buddies-protocol.md b/docs/buddies-protocol.md index 9c34a09..185e7eb 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,11 +40,29 @@ 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). -`inbox/register`, `invite/create`, and `blob/put` will also need `Authorization: Bearer` from an attested account session whose account is the one the inbox is linked to (or, for `inbox/register`, the one its `account` proof names), on top of the signature above ([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)). Writer ops, invites, and `blob/get` never carry a token: it would tell the relay which account writes into which inbox. +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, 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)). +- 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 @@ -62,7 +80,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; 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 registered before accounts (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) | @@ -78,7 +99,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. | @@ -119,11 +140,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) @@ -176,7 +197,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. @@ -204,16 +226,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 | | ------------------------------------------ | -------------------------------------------------------------------------------------- | @@ -232,7 +254,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. From 7ddb8bb99822be7c3dcf2d3ac274f8b11f165750 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 01:21:29 -0400 Subject: [PATCH 09/25] fix(notes-import): create run storage on first write and after deleteAll NotesImportRun created its tables in the constructor. deleteAll() drops them with the rows, so the same instance then threw "no such table" on its next start(): re-importing notes after destroy, or after the retention alarm wiped a finished run, failed kickoff with 500 until the object was evicted. Tables are now created on the first write and the flag resets after deleteAll(), as the inbox and handoff objects already do. Reads (getResult, cancel, the SSE replay, a redelivered alarm) no longer create storage for an import that never started. --- src/notesImport/runDO.test.ts | 61 +++++++++++++++++++++++++++- src/notesImport/runDO.ts | 75 +++++++++++++++++++++++++---------- 2 files changed, 113 insertions(+), 23 deletions(-) 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..cbe5aa2 100644 --- a/src/notesImport/runDO.ts +++ b/src/notesImport/runDO.ts @@ -123,19 +123,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,6 +153,7 @@ 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)) this.#metaDel('result') @@ -261,7 +255,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 +265,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') { @@ -586,12 +580,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 +630,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 +685,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 +729,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 +737,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 +746,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) } } From 81aac89f5f7c8a35ff7f3bd1c8bff98c36947647 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 01:22:20 -0400 Subject: [PATCH 10/25] feat(notes-import): meter by the session's account on bearer requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notes Import identifies the caller by an attested session's access token (witness-work ADR 0027 §1, §4). A request with an Authorization header is a bearer request, always: kickoff, the synchronous POST /notes-import, and transcribe take no attestation fields or hashes, meter by the token's account, and refuse a suspended account. Run routes (events, result, cancel, destroy) take the same token and serve only imports that account started; any other import id is the same 404 not_found. Requests without the header keep the pre-session contract while the legacy switch is on and get 426 upgrade_required once it's off. Every refusal keeps Notes Import's error shape (message in `error`, stable `code`). - Supporter status goes through one seam, isAccountSupporter (src/accounts/supporter.ts), at kickoff and at settlement. Until RevenueCat is linked to the account, it checks the request's optional revenueCatAppUserId; absent means free. - A dev session (the dev bypass) gets the bypass's unlimited allowances on the dev worker only; analytics take the platform from the token and identify the import by the hashed account id with has_account true. - The run keeps a hash of its owning account until it's deleted. Bearer import ids hash a basis no legacy request can produce, so a legacy kickoff can never land on an account's run and get a subscribe token for it. - Bearer kickoffs mint no subscribe token. --- src/accounts/supporter.ts | 36 ++ src/notesImport/admin.test.ts | 25 + src/notesImport/analytics.ts | 16 +- src/notesImport/contracts.ts | 9 + src/notesImport/route.bearer.test.ts | 689 +++++++++++++++++++++++++ src/notesImport/route.ts | 739 +++++++++++++++++++-------- src/notesImport/runDO.ts | 37 +- src/notesImport/settlement.test.ts | 29 ++ src/notesImport/settlement.ts | 18 +- 9 files changed, 1373 insertions(+), 225 deletions(-) create mode 100644 src/accounts/supporter.ts create mode 100644 src/notesImport/route.bearer.test.ts 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/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/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/route.bearer.test.ts b/src/notesImport/route.bearer.test.ts new file mode 100644 index 0000000..3e7b6b5 --- /dev/null +++ b/src/notesImport/route.bearer.test.ts @@ -0,0 +1,689 @@ +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 { NotesImportIndex } from './indexDO' +import { NotesImportRun } from './runDO' +import { 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), +})) + +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('./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() +}) + +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('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..a01d26a 100644 --- a/src/notesImport/route.ts +++ b/src/notesImport/route.ts @@ -50,9 +50,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 +117,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 +166,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 +290,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 +311,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 +347,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 +359,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,34 +431,37 @@ 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') } } @@ -328,13 +477,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 +653,6 @@ async function authenticateAndGate( requestHash !== expectedRequestHash ) { return { - ok: false, response: appAttestBadRequest( ctx, 'requestHash does not match the Notes Import payload' @@ -368,7 +661,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 +669,6 @@ async function authenticateAndGate( if (!devBypass && isPlayIntegrityRequest(record)) { if (assertionPurpose == null) { return { - ok: false, response: err( ctx, HTTP_STATUS.BAD_REQUEST, @@ -398,7 +689,6 @@ async function authenticateAndGate( }) if (body.requestHash !== expectedRequestHash) { return { - ok: false, response: appAttestBadRequest( ctx, 'requestHash does not match the Notes Import payload' @@ -410,14 +700,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 +724,6 @@ async function authenticateAndGate( const assertion = asString(body.assertion) if (!keyId || !challenge || !assertion) { return { - ok: false, response: err( ctx, HTTP_STATUS.UNAUTHORIZED, @@ -457,7 +745,6 @@ async function authenticateAndGate( : null if (protocolVersion === 2 && !v2Request) { return { - ok: false, response: appAttestBadRequest( ctx, 'Invalid App Attest v2 protected assertion' @@ -477,14 +764,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 +777,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 +787,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 +815,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 +845,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 +889,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 +927,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 +948,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 +965,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 +1033,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 +1055,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 +1071,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 +1085,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) @@ -857,8 +1115,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, @@ -886,7 +1146,7 @@ export async function handleNotesImportRequest(ctx: AppContext) { : 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 slotKey = `legacy:${await deriveImportId(gate)}` const acquired = await idx.acquire(slotKey, cap) if (!acquired.ok) { track(ctx, notesImportLimitReachedEvent(gate.analytics, 'active_cap')) @@ -943,6 +1203,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, @@ -1001,6 +1262,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 +1299,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 +1360,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.ts b/src/notesImport/runDO.ts index cbe5aa2..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 { @@ -156,6 +167,10 @@ export class NotesImportRun extends DurableObject { 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') @@ -163,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() @@ -339,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( 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 From d0e624d16dfb92921f9de0cb249fec67956b7178 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 01:25:12 -0400 Subject: [PATCH 11/25] docs(notes-import): the bearer contract, ownership, and legacy forms README's Notes Import section gains the bearer wire contract (bodies, responses, revenueCatAppUserId, run-route ownership and its 404, errors, fresh allowances, dev sessions) and marks per-request attestation and the subscribe token as legacy. /docs documents both forms on every route (bearerAuth or none), the 401/403/404/426/503 codes, and the optional subscribeToken. AGENTS.md gets a "Notes Import on account sessions" section, the account id as an admin-reset meter, and the PostHog identity and platform for bearer requests. --- AGENTS.md | 57 ++++++++++-- README.md | 83 +++++++++++++++-- src/notesImport/openapi.ts | 182 ++++++++++++++++++++++++++----------- 3 files changed, 254 insertions(+), 68 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5ef565..f148bb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,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 @@ -761,18 +801,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 @@ -929,7 +972,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 2fc2ce3..fbba9d1 100644 --- a/README.md +++ b/README.md @@ -339,18 +339,19 @@ 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`. @@ -358,11 +359,69 @@ same handlers for deployed apps. - **`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): @@ -403,6 +462,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 diff --git a/src/notesImport/openapi.ts b/src/notesImport/openapi.ts index b588ab2..e6471e8 100644 --- a/src/notesImport/openapi.ts +++ b/src/notesImport/openapi.ts @@ -87,6 +87,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 +183,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, @@ -252,7 +280,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 +303,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 +311,7 @@ const kickoffRequestSchema = z.union([ contentHash: notesContentHash, requestHash: notesRequestHash, }) - .meta({ title: 'Play Integrity' }), + .meta({ title: 'Play Integrity (legacy)' }), ]) const successSchema = z @@ -299,44 +339,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\`, or an invalid \`revenueCatAppUserId\`. Legacy: ${ + 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`).' + }`, ['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 +398,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,25 +408,29 @@ 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.`, 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), @@ -386,10 +439,10 @@ ${GATE_DESCRIPTION} The same concurrency cap as kickoff applies.`, 502: niError('The model failed or returned an invalid result.', [ 'model_error', ]), - 503: niError(`${killSwitchUnavailable}${storageUnavailable}`, [ - 'unavailable', - 'server_error', - ]), + 503: niError( + `${killSwitchUnavailable}${accountsDisabled}${storageUnavailable}`, + ['unavailable', 'disabled', 'server_error'] + ), }, }) @@ -414,22 +467,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 +512,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 +556,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 +635,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 +675,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 +689,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 +700,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) })), From 532e736882b50d941e9f1a3c4cf60f2a9bd55ce9 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 01:27:27 -0400 Subject: [PATCH 12/25] test(e2e): drive Notes Import on account sessions through the verify loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dev-bypass session's bearer token runs kickoff → result → SSE events → destroy, then re-kicks the same notes on the recreated run; another account gets 404 on every run route; a bad token is 401 even with the bypass header; and with the legacy switch off, legacy kickoff, transcribe, and ?token= run routes are 426 while bearer requests work. Without an OpenRouter key the run ends model_error at no cost; with one the lifecycle needs WW_API_E2E_ALLOW_PAID=1. The verify skill's Notes Import recipe lists the new checks. --- .../verify-ww-api/features/notes-import.md | 9 +- src/e2e/notesImportAccounts.e2e.test.ts | 198 ++++++++++++++++++ 2 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 src/e2e/notesImportAccounts.e2e.test.ts 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/src/e2e/notesImportAccounts.e2e.test.ts b/src/e2e/notesImportAccounts.e2e.test.ts new file mode 100644 index 0000000..038fe9e --- /dev/null +++ b/src/e2e/notesImportAccounts.e2e.test.ts @@ -0,0 +1,198 @@ +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, 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('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) + }) +}) From 7b411f5be24ec6e879812d8d3c4984a5478f2998 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 01:41:57 -0400 Subject: [PATCH 13/25] fix(buddies): authorize bearer owners only for links D1 confirmed `inbox/register` wrote the account link into the inbox Durable Object before linking in D1. If the D1 write failed, bearer owner ops already passed the inbox's account check while moderation, which finds an account's inboxes in D1, never reached that inbox. Two accounts claiming the same unlinked inbox at once could also leave D1 and the inbox naming different owners. `owner_account` gains `confirmed`, and bearer owner ops (and the live socket and `blob/put`) need a confirmed link, else 409 `not_linked`. The inbox takes a link as confirmed only from D1: an existing link rides along with the registration, a new one is confirmed with `confirmAccount` after `linkInbox` wrote it (naming D1's winner, so concurrent claims agree and the loser gets `conflict`, and carrying the status read after the write, so a suspension racing the link still applies), and moderation's status changes confirm the inbox they found in D1. A registration that fails between the steps leaves `not_linked` until the app's relink gets through; registering again is idempotent. Rows from before the column are added in place as unconfirmed, so their owner's first bearer owner op gets `not_linked` and the relink confirms them against D1. Legacy requests are unchanged, and the D1 link is still written only after the inbox accepted the owner-signed registration. --- AGENTS.md | 15 ++- docs/buddies-protocol.md | 4 +- src/accounts/store.ts | 14 +-- src/buddies/inboxDO.ts | 93 +++++++++++--- src/buddies/openapi.ts | 8 +- src/buddies/route.ts | 19 ++- src/buddies/sessions.test.ts | 237 +++++++++++++++++++++++++++++++++++ 7 files changed, 351 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f148bb4..f611d86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -315,8 +315,17 @@ Links. Clients are iOS and Android 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; - the app links it with `inbox/register` and retries). Writer ops send the + `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 @@ -326,7 +335,7 @@ Links. Clients are iOS and Android `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). + 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 diff --git a/docs/buddies-protocol.md b/docs/buddies-protocol.md index 185e7eb..1547799 100644 --- a/docs/buddies-protocol.md +++ b/docs/buddies-protocol.md @@ -53,7 +53,7 @@ Every op also takes `Authorization: Bearer ` from an attested acco | 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, which the app links with `inbox/register` and retries. | +| 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. | @@ -83,7 +83,7 @@ Success: HTTP 200 with `{ "ok": true, ... }`. Failure: `{ "ok": false, "error": | 426 | `upgrade_required` | `inbox/live` requested without a WebSocket upgrade, or a legacy request after the legacy switch was turned off; 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 registered before accounts (link it with `inbox/register`) | +| 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) | diff --git a/src/accounts/store.ts b/src/accounts/store.ts index 0cbf175..c711715 100644 --- a/src/accounts/store.ts +++ b/src/accounts/store.ts @@ -84,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 ( diff --git a/src/buddies/inboxDO.ts b/src/buddies/inboxDO.ts index e70c522..f48ca10 100644 --- a/src/buddies/inboxDO.ts +++ b/src/buddies/inboxDO.ts @@ -320,13 +320,22 @@ const TOMBSTONE_SCHEMA = `CREATE TABLE IF NOT EXISTS owner_tombstone ( * 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. Bearer owner requests - * (witness-work ADR 0027) are authorized against `account_id`: another - * account is `forbidden`, and an inbox without the row is `not_linked`. + * (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 )` /** @@ -382,8 +391,9 @@ export class BuddyInbox extends DurableObject { // `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( @@ -424,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 ) } @@ -1108,20 +1119,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 ) @@ -1257,11 +1297,12 @@ export class BuddyInbox extends DurableObject { /** * Whether an account call may act for this inbox: only the account it's - * linked to. An inbox registered before accounts has no link to compare + * 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.#ownerAccountId() + const linked = this.#confirmedAccountId() if (linked == null) return fail('not_linked') return linked === accountId ? null : fail('forbidden') } @@ -1334,10 +1375,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. @@ -1394,14 +1449,16 @@ export class BuddyInbox extends DurableObject { : null } - /** The account this inbox is linked to, or null when none. */ - #ownerAccountId(): string | 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') + }>( + 'SELECT account_id AS accountId FROM owner_account WHERE singleton = 1 AND confirmed = 1' + ) .toArray()[0]?.accountId ?? null ) } diff --git a/src/buddies/openapi.ts b/src/buddies/openapi.ts index 93b2231..fc2c404 100644 --- a/src/buddies/openapi.ts +++ b/src/buddies/openapi.ts @@ -142,7 +142,7 @@ const LEGACY_SWITCH = const SIGNED_BY = { 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 registered before accounts is `not_linked` (link it with a bearer `inbox/register`, then retry).', + '**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( @@ -180,7 +180,7 @@ const DEFAULT_REASONS: Partial> = { '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 was registered before accounts and is linked to none. Link it with a bearer `inbox/register` (with its `account` proof), then retry.', + '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`.', } @@ -854,7 +854,7 @@ 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 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 registered before accounts.', + '**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.', @@ -897,7 +897,7 @@ 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)}, 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 registered before accounts.', + '**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.`, diff --git a/src/buddies/route.ts b/src/buddies/route.ts index 2303056..119236e 100644 --- a/src/buddies/route.ts +++ b/src/buddies/route.ts @@ -254,6 +254,14 @@ const refuseBearerRegistration = ( * 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, @@ -273,20 +281,23 @@ const registerInbox = async ( } } 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) 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 } : {} }) } diff --git a/src/buddies/sessions.test.ts b/src/buddies/sessions.test.ts index 540b652..0894936 100644 --- a/src/buddies/sessions.test.ts +++ b/src/buddies/sessions.test.ts @@ -518,6 +518,243 @@ describe('bearer owner ops', () => { }) }) +/** 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() From a0cd2460023373f9cf18372d94474f6014580e31 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 01:48:21 -0400 Subject: [PATCH 14/25] fix(sentry): report database errors by operation, never their parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drizzle's `DrizzleQueryError.message` quotes the failed query and its bound parameters (account ids, session ids, refresh token hashes, inbox ids), and the accounts, sessions, and Buddies guards passed such errors straight to `Sentry.captureException`. - `captureFailure(error, operation)` in src/sentry.ts reports a database error (Drizzle's, D1's `D1_…:` messages, or any error one caused) as a `DatabaseError` naming only the operation: the original stack frames, no message, query, parameters, or cause. Every accounts, sessions, and Buddies capture site uses it (`guarded`, the quota and status reads, Buddies' ops, live socket, and blob routes, admin, the session prune cron), and the app's `onError` reports and logs unguarded routes the same way, labelled with the route pattern. Notes Import's account path reads D1 only through `authenticateAccount`. - `beforeSend` (and `beforeSendTransaction`/`beforeBreadcrumb`) cut `Failed query:`, `params:`, and `D1_…:` text from every string in an event, and drop the exceptions chained under a redacted one. Tests run the real Sentry client (linked errors on) with the worker's config against a synthetic DrizzleQueryError and against real route failures from a D1 double whose queries fail. --- AGENTS.md | 8 +- src/accounts/admin.ts | 5 +- src/accounts/attestation.ts | 4 +- src/accounts/auth.ts | 4 +- src/accounts/databaseErrors.test.ts | 200 ++++++++++++++++++++++++++++ src/accounts/signed.ts | 10 +- src/app.ts | 17 ++- src/buddies/route.ts | 16 +-- src/index.ts | 4 +- src/sentry.test.ts | 145 +++++++++++++++++++- src/sentry.ts | 105 ++++++++++++++- src/test/memoryD1.ts | 23 ++++ src/test/sentry.ts | 52 ++++++++ 13 files changed, 564 insertions(+), 29 deletions(-) create mode 100644 src/accounts/databaseErrors.test.ts create mode 100644 src/test/sentry.ts diff --git a/AGENTS.md b/AGENTS.md index f611d86..b7de677 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -669,7 +669,13 @@ Building a route on accounts (`src/accounts/auth.ts`): sends any method. **Privacy**: never log or report access or refresh tokens, account ids, -session ids, or device labels. Sentry drops `Authorization`. +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 diff --git a/src/accounts/admin.ts b/src/accounts/admin.ts index d93c7ff..1c6c3b8 100644 --- a/src/accounts/admin.ts +++ b/src/accounts/admin.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' import type { AppContext, AppEnv } from '../types' -import { Sentry } from '../sentry' +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 }), diff --git a/src/accounts/attestation.ts b/src/accounts/attestation.ts index 5170dad..73ee6e5 100644 --- a/src/accounts/attestation.ts +++ b/src/accounts/attestation.ts @@ -1,5 +1,5 @@ import type { AppContext } from '../types' -import { Sentry } from '../sentry' +import { captureFailure } from '../sentry' import { isPlainObject } from '../buddies/contracts' import { buddiesErrorResponse, @@ -52,7 +52,7 @@ const refusal = (reason: Reason, action: Action, cause?: unknown): Response => { extras, }) case 'storage_unavailable': - Sentry.captureException(cause) + captureFailure(cause, 'session/open attestation') return buddiesInternalError() default: return buddiesErrorResponse('attestation_failed', { extras }) diff --git a/src/accounts/auth.ts b/src/accounts/auth.ts index 7f40561..8597771 100644 --- a/src/accounts/auth.ts +++ b/src/accounts/auth.ts @@ -1,6 +1,6 @@ import type { MiddlewareHandler } from 'hono' import type { AppContext, AppEnv, Environment } from '../types' -import { Sentry } from '../sentry' +import { captureFailure } from '../sentry' import { readEnvelopeText } from '../buddies/envelope' import { buddiesErrorResponse, @@ -124,7 +124,7 @@ export const authenticateAccount = async ( status = await accountStatus(c.env, account.accountId, Date.now()) } catch (error) { console.error('accounts: status read failed') - Sentry.captureException(error) + captureFailure(error, 'account status') return refuse('internal') } if (status == null) return refuse('unauthorized') 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/signed.ts b/src/accounts/signed.ts index 8d86214..7ebab73 100644 --- a/src/accounts/signed.ts +++ b/src/accounts/signed.ts @@ -1,5 +1,5 @@ import type { AppContext, Environment } from '../types' -import { Sentry } from '../sentry' +import { captureFailure } from '../sentry' import { bytesToBase64Url, sha256Bytes } from '../crypto' import { BUDDIES_LIMITS, @@ -215,7 +215,7 @@ export const refuseOverDailyQuota = async ( }) } catch (error) { console.warn('accounts: quota unavailable; allowing', { op }) - Sentry.captureException(error) + captureFailure(error, op) return null } } @@ -234,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) => @@ -243,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/app.ts b/src/app.ts index 1327eca..ec1220c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,6 +3,7 @@ * with Sentry and the cron handler, and re-exports the Durable Objects. */ import { Hono } from "hono"; +import { routePath } from "hono/route"; import type { AppEnv, AppContext, HealthCheckResponse } from "./types"; import { HERE_API, HTTP_STATUS } from "./config"; import { apiError, dateHeader } from "./errors"; @@ -53,7 +54,7 @@ import { } from "./attestation/openapi"; import { handleAppStoreRatingsRequest } from "./appStoreRatings/route"; import { handleAppConfigRequest } from "./appConfig"; -import { Sentry } from "./sentry"; +import { Sentry, reportableError } from "./sentry"; import { createBuddiesRoutes } from "./buddies/route"; import { createAccountsRoutes } from "./accounts/route"; import { createAccountsAdminRoutes } from "./accounts/admin"; @@ -114,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"); } diff --git a/src/buddies/route.ts b/src/buddies/route.ts index 119236e..a9d0e1d 100644 --- a/src/buddies/route.ts +++ b/src/buddies/route.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' import type { AppContext, AppEnv, Environment } from '../types' -import { Sentry } from '../sentry' +import { captureFailure } from '../sentry' import { bytesToBase64Url, sha256Bytes } from '../crypto' import { BUDDIES_ALWAYS_ALLOWED_OPS, @@ -277,7 +277,7 @@ const registerInbox = async ( } 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 @@ -291,7 +291,7 @@ const registerInbox = async ( } 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') @@ -411,7 +411,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 @@ -521,7 +521,7 @@ 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() } } @@ -573,7 +573,7 @@ const handleLive = async (c: AppContext): Promise => { ) } catch (error) { console.error('buddies: request failed', { op: BUDDIES_LIVE_OP }) - Sentry.captureException(error) + captureFailure(error, BUDDIES_LIVE_OP) return buddiesInternalError() } } @@ -648,7 +648,7 @@ 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() } } @@ -690,7 +690,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() } } diff --git a/src/index.ts b/src/index.ts index f66cce8..bf924dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ 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"; @@ -12,7 +12,7 @@ async function pruneAccountSessions(environment: Environment): Promise { await pruneEndedSessions(accountsDb(environment.ACCOUNTS_DB)); } catch (error) { console.error("accounts: pruning ended sessions failed"); - Sentry.captureException(error); + captureFailure(error, "session prune"); } } diff --git a/src/sentry.test.ts b/src/sentry.test.ts index 96eb607..7b118ed 100644 --- a/src/sentry.test.ts +++ b/src/sentry.test.ts @@ -1,10 +1,21 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' 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, + reportableError, +} from './sentry' +import { recordSentryEvents, type SentryRecorder } from './test/sentry' import type { Environment } from './types' type TransactionEvent = Parameters< @@ -245,3 +256,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 82ab279..17c1920 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,74 @@ export function redactContactLinkPayloads(value: string): string { return value.replace(CONTACT_LINK_PAYLOAD_PATH, REDACTED_CONTACT_LINK_PATH); } +/** + * 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(redactContactLinkPayloads(value)); + } if (typeof value !== 'object' || value === null || seen.has(value)) { return value; } @@ -68,9 +131,47 @@ function dropSecretHeaders(event: Event): void { } } +/** + * 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 { dropSecretHeaders(event); - return scrubStrings(event) as T; + const scrubbed = scrubStrings(event) as T; + dropDatabaseErrorCauses(scrubbed); + return scrubbed; } function scrubBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb { 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/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), + } +} From 64801dcaa0e3d4bdfb3f7faa80460c63f0a1cb3b Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 01:56:38 -0400 Subject: [PATCH 15/25] fix(accounts): end the session when any older refresh token returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store kept only the current and immediately previous refresh token hashes and looked tokens up by hash, so after R0 → R1 → R2 presenting R0 was merely unknown (401) while R2 stayed live, though the contract says any older token ends the session. Refresh tokens are now self-identifying, `.` (b64u of 16 and 32 bytes, each in its canonical spelling; still opaque to the app). The session is read by id and the SHA-256 of the whole token is compared, in constant time, with the stored current and previous hashes. A token for a live session that is neither the current one nor the one just replaced within its minute ends the session: an older token, the replaced one after its minute, or one the session never issued (only someone who knows the session id can try, and its access token can end the session anyway). Malformed tokens are 401 before any database read; unknown, ended, or expired sessions are 401 without a write. Only hashes are stored, as before, with no new table or migration. Sessions opened before this change hold old-format tokens, which are now malformed: their next refresh is 401 and the app opens a new session. The app hasn't shipped sessions. --- AGENTS.md | 15 +++-- README.md | 3 +- src/accounts/contracts.ts | 28 +++++++-- src/accounts/openapi.ts | 21 +++++-- src/accounts/session.test.ts | 112 +++++++++++++++++++++++++++++++++-- src/accounts/session.ts | 5 +- src/accounts/sessionStore.ts | 100 ++++++++++++++++++------------- src/e2e/accounts.e2e.test.ts | 22 ++++++- 8 files changed, 242 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b7de677..e3f9c4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -596,12 +596,15 @@ Attest once, then bearer tokens (witness-work ADR 0027; "Sessions" in its 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 }`. Each refresh replaces the - token. The one just replaced still works for 60 seconds from its rotation - (a lost response; retries don't extend it). Presenting it later ends the - session. An unknown, ended, or expired token is 401 `unauthorized`. - Sessions last 90 days from opening and refreshing never extends them. - Read edge tier. +- **Refresh**: plain JSON `{ refreshToken }`. A refresh token is + `.` (b64u, 16 and 32 bytes; opaque to the app), so the + session is found by id, and a malformed one is 401 before any D1 read. + Each refresh replaces the token. The one just replaced still works for 60 + seconds from its rotation (a lost response; retries don't extend it). + Presenting it later, or any older token of a live session (or one it + never issued), ends the session. An unknown, ended, or expired session + is 401 `unauthorized`. 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 diff --git a/README.md b/README.md index fbba9d1..65db0df 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,8 @@ WitnessWork once per session and then calls with short-lived bearer tokens: /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). + response), and presenting an older one ends the session. Refresh tokens are opaque strings + (`.`). - **`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/*`) diff --git a/src/accounts/contracts.ts b/src/accounts/contracts.ts index 88a190c..972450b 100644 --- a/src/accounts/contracts.ts +++ b/src/accounts/contracts.ts @@ -329,11 +329,29 @@ export const sessionDeviceTooLarge = (payload: unknown): boolean => export const sessionAttestationHash = (accountPub: string): Promise => sha256Hex(`ww-account/v1/session\n${accountPub}`) -/** A refresh token: b64u of 32 random bytes, in its canonical spelling. */ -export const parseRefreshToken = (value: unknown): Uint8Array | null => { - if (typeof value !== 'string') return null +/** A refresh token as presented: the session it names, and the whole token. */ +export interface RefreshToken { + sessionId: string + token: string +} + +/** b64u of exactly `size` bytes, in its canonical spelling. */ +const isCanonicalB64u = (value: string, size: number): boolean => { const bytes = decodeB64u(value) - return bytes && bytes.length === 32 && bytesToBase64Url(bytes) === value - ? bytes + return bytes != null && bytes.length === size && bytesToBase64Url(bytes) === value +} + +/** + * A refresh token, opaque to the app: `.`, the session's + * id (b64u, 16 bytes) and b64u of 32 bytes, each in its canonical spelling. + * Null for anything else. + */ +export const parseRefreshToken = (value: unknown): RefreshToken | null => { + if (typeof value !== 'string') return null + const parts = value.split('.') + if (parts.length !== 2) return null + const [sessionId, secret] = parts + return isCanonicalB64u(sessionId, 16) && isCanonicalB64u(secret, 32) + ? { sessionId, token: value } : null } diff --git a/src/accounts/openapi.ts b/src/accounts/openapi.ts index f27cd6d..6744a6c 100644 --- a/src/accounts/openapi.ts +++ b/src/accounts/openapi.ts @@ -817,14 +817,23 @@ const sessionAttestationErrorSchema = apiErrorSchema 'The accounts error envelope (`error` repeats `code`) plus the attestation’s `reason` and `action`.', }) +/** Refresh tokens: `.`, b64u of 16 and 32 bytes. */ +const refreshTokenSchema = (description: string) => + z + .string() + .regex(/^[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}$/) + .meta({ + description: `${description}. Opaque to the app: the session’s id and a secret, \`.\` (base64url, 16 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: b64u( - 'The session’s refresh token, 32 bytes. Keep it in the platform keystore, not synced; each refresh replaces it' + 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)' @@ -911,13 +920,15 @@ ${fieldTable(playIntegritySessionAttestation)}` 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) gets a new pair; sending any older one ends the session. An ended or expired session is \`unauthorized\`: open a new one, attesting again, without asking the User. + 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) gets a new pair; 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: b64u('The session’s current refresh token') }) + z.object({ + refreshToken: refreshTokenSchema('The session’s current refresh token'), + }) ), responses: { 200: jsonResponse( @@ -926,7 +937,7 @@ ${readTier}`, ), 400: errorResponse('Malformed JSON, or no `refreshToken`.', ['bad_request']), 401: errorResponse( - 'Unknown, replaced, ended, or expired: open a new session.', + 'Malformed, unknown, replaced, ended, or expired: open a new session. A replaced token presented after its minute, or any older one, also ends the session.', ['unauthorized'] ), 429: errorResponse('Over the per-caller rate.', ['rate_limited']), diff --git a/src/accounts/session.test.ts b/src/accounts/session.test.ts index 12190a3..8fc9d6e 100644 --- a/src/accounts/session.test.ts +++ b/src/accounts/session.test.ts @@ -98,9 +98,10 @@ describe('POST /sessions with the dev bypass', () => { sessionId: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), accessToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/), accessExpiresAt: START + 15 * MINUTE, - refreshToken: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + refreshToken: `${session.sessionId}.${session.refreshToken.split('.')[1]}`, sessionExpiresAt: START + 90 * DAY, }) + expect(session.refreshToken).toMatch(/^[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}$/) // Only the account, platform, label, token hashes, and times. const [row] = sessionRows() @@ -275,7 +276,9 @@ describe('POST /sessions/refresh', () => { ok: true, accessToken: expect.any(String), accessExpiresAt: Date.now() + 15 * MINUTE, - refreshToken: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + refreshToken: expect.stringMatching( + new RegExp(`^${session.sessionId}\\.[A-Za-z0-9_-]{43}$`) + ), sessionExpiresAt: session.sessionExpiresAt, }) expect(first.body.refreshToken).not.toBe(session.refreshToken) @@ -294,8 +297,9 @@ describe('POST /sessions/refresh', () => { advance(59 * SECOND) const retried = await refresh(session.refreshToken) expect(retried.status).toBe(200) - // The pair issued in between is gone; the retried one works. - expect((await refresh(rotated.body.refreshToken)).status).toBe(401) + // The pair issued in between is gone (presenting it would end the + // session); the retried one works. + expect(retried.body.refreshToken).not.toBe(rotated.body.refreshToken) const next = await refresh(retried.body.refreshToken) expect(next.status).toBe(200) expect((await refresh(next.body.refreshToken)).status).toBe(200) @@ -323,7 +327,9 @@ describe('POST /sessions/refresh', () => { }) it('refuses unknown and malformed tokens', async () => { - const unknown = await refresh(b64u(randomBytes(32))) + const unknown = await refresh( + `${b64u(randomBytes(16))}.${b64u(randomBytes(32))}` + ) expect(unknown.status).toBe(401) expect(unknown.body).toEqual({ ok: false, @@ -363,6 +369,102 @@ describe('POST /sessions/refresh', () => { }) }) +/** 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 +} + +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('ends the session for a token it never issued', async () => { + const session = await open() + const forged = `${session.sessionId}.${b64u(randomBytes(32))}` + expect((await refresh(forged)).status).toBe(401) + expect(sessionRows()[0].revoked_at).toBe(Date.now()) + expect((await refresh(session.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]) + } + }) + + it('refuses malformed tokens without reading or writing the database', async () => { + const session = await open() + const [sessionId, secret] = session.refreshToken.split('.') + const queries = recordQueries() + for (const token of [ + secret, + b64u(randomBytes(32)), + `${sessionId}.${secret}.x`, + `${sessionId}${secret}`, + `${sessionId}.`, + `.${secret}`, + `${sessionId}.${secret}=`, + `${sessionId}.${secret.slice(0, -1)}`, + `${sessionId}.${b64u(randomBytes(33))}`, + `${b64u(randomBytes(15))}.${secret}`, + // Non-canonical: the same bytes, spelled with a padding bit set. + `${setPaddingBit(sessionId)}.${secret}`, + `${sessionId}.${setPaddingBit(secret)}`, + `${sessionId}.${secret.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() + expect( + (await refresh(`${b64u(randomBytes(16))}.${b64u(randomBytes(32))}`)) + .status + ).toBe(401) + expect((await refresh(session.refreshToken)).status).toBe(401) + expect(queries.every((query) => query.startsWith('select'))).toBe(true) + expect(sessionRows()[0]).toEqual(row) + }) + +}) + describe('DELETE /sessions/current', () => { it('ends the session; repeating it is ok', async () => { const session = await open() diff --git a/src/accounts/session.ts b/src/accounts/session.ts index cf62bb8..6d756a7 100644 --- a/src/accounts/session.ts +++ b/src/accounts/session.ts @@ -120,7 +120,10 @@ const handleOpen = guarded('session/open', async (c) => { ) }) -/** `POST /sessions/refresh`: plain JSON `{ refreshToken }`, replaced every time. */ +/** + * `POST /sessions/refresh`: plain JSON `{ refreshToken }`, replaced every + * time. A malformed token 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 diff --git a/src/accounts/sessionStore.ts b/src/accounts/sessionStore.ts index e083aba..08ddae1 100644 --- a/src/accounts/sessionStore.ts +++ b/src/accounts/sessionStore.ts @@ -1,16 +1,19 @@ import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' -import { bytesToBase64Url, sha256Bytes } from '../crypto' -import { ACCOUNT_LIMITS } from './contracts' +import { bytesToBase64Url, sha256Bytes, timingSafeEqual } from '../crypto' +import { ACCOUNT_LIMITS, type RefreshToken } from './contracts' import { accountSessions, type SessionPlatform } from './schema' import type { AccountsDb } from './store' /** * Session rows in the accounts database (`account_sessions`). A session is * live until it's revoked or reaches `expiresAt`, 90 days after it opened. - * Only the SHA-256 of a refresh 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 other - * replaced token, ends the session. + * + * 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. */ const { @@ -37,13 +40,20 @@ export interface IssuedSession { const randomBytes = (size: number): Uint8Array => crypto.getRandomValues(new Uint8Array(size)) -const refreshHash = async (token: Uint8Array): Promise => - bytesToBase64Url(await sha256Bytes(token)) +const encoder = new TextEncoder() -const newRefreshToken = async () => { - const bytes = randomBytes(32) - return { token: bytesToBase64Url(bytes), hash: await refreshHash(bytes) } -} +/** 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), +}) + +/** A refresh token for the session: a random secret. */ +const newRefreshToken = (sessionId: string) => + withHash(`${sessionId}.${bytesToBase64Url(randomBytes(32))}`) /** Rows a D1 write changed (Drizzle hands back D1's result for writes). */ const changes = (result: unknown): number => @@ -53,6 +63,8 @@ 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, @@ -62,6 +74,8 @@ type SessionRow = { id: string accountId: string platform: SessionPlatform + refreshHash: string + previousRefreshHash: string | null expiresAt: Date revokedAt: Date | null rotatedAt: Date | null @@ -91,7 +105,7 @@ export const createSession = async ( now: number = Date.now() ): Promise => { const id = bytesToBase64Url(randomBytes(16)) - const refresh = await newRefreshToken() + const refresh = await newRefreshToken(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. @@ -135,10 +149,21 @@ const revoke = (db: AccountsDb, sessionId: string, now: number) => 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 + /** * Replaces the session's refresh token. Null means 401 `unauthorized`: an - * unknown token, an ended or expired session, or a replaced token presented - * more than a minute after it was replaced, which also ends the session. + * 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 another new token, and the one * issued in between stops working; the minute still counts from the first @@ -146,19 +171,15 @@ const revoke = (db: AccountsDb, sessionId: string, now: number) => */ export const refreshSession = async ( db: AccountsDb, - token: Uint8Array, + presented: RefreshToken, now: number = Date.now() ): Promise => { - const hash = await refreshHash(token) + const hash = await refreshHash(presented.token) + let row = await findSession(db, presented.sessionId) + if (!row || !isLive(row, now)) return null - const current = (await db - .select(sessionColumns) - .from(accountSessions) - .where(eq(accountSessions.refreshHash, hash)) - .get()) as SessionRow | undefined - if (current) { - if (!isLive(current, now)) return null - const next = await newRefreshToken() + if (timingSafeEqual(hash, row.refreshHash)) { + const next = await newRefreshToken(row.id) const rotated = await db .update(accountSessions) .set({ @@ -168,43 +189,42 @@ export const refreshSession = async ( }) .where( and( - eq(accountSessions.id, current.id), + eq(accountSessions.id, row.id), eq(accountSessions.refreshHash, hash), isNull(accountSessions.revokedAt) ) ) if (changes(rotated) === 1) - return { session: record(current), refreshToken: next.token } + return { session: record(row), refreshToken: next.token } // A concurrent refresh with the same token won; this one is now the // replaced token, answered below. + row = await findSession(db, presented.sessionId) + if (!row || !isLive(row, now)) return null } - const replaced = (await db - .select(sessionColumns) - .from(accountSessions) - .where(eq(accountSessions.previousRefreshHash, hash)) - .get()) as SessionRow | undefined - if (!replaced) return null + const replaced = + row.previousRefreshHash != null && + timingSafeEqual(hash, row.previousRefreshHash) const inGrace = - replaced.rotatedAt != null && - now - replaced.rotatedAt.getTime() <= refreshGraceMs - if (!isLive(replaced, now) || !inGrace) { - await revoke(db, replaced.id, now) + row.rotatedAt != null && now - row.rotatedAt.getTime() <= refreshGraceMs + if (!replaced || !inGrace) { + // Replaced over a minute ago, or older still: someone kept a copy. + await revoke(db, row.id, now) return null } - const next = await newRefreshToken() + const next = await newRefreshToken(row.id) const reissued = await db .update(accountSessions) .set({ refreshHash: next.hash }) .where( and( - eq(accountSessions.id, replaced.id), + eq(accountSessions.id, row.id), eq(accountSessions.previousRefreshHash, hash), isNull(accountSessions.revokedAt) ) ) return changes(reissued) === 1 - ? { session: record(replaced), refreshToken: next.token } + ? { session: record(row), refreshToken: next.token } : null } diff --git a/src/e2e/accounts.e2e.test.ts b/src/e2e/accounts.e2e.test.ts index a3ab0b8..36e3208 100644 --- a/src/e2e/accounts.e2e.test.ts +++ b/src/e2e/accounts.e2e.test.ts @@ -269,7 +269,7 @@ describe.skipIf(!DEV_BYPASS_TOKEN)('/accounts/v1/sessions (dev bypass)', () => { 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_-]{43}$/), + refreshToken: expect.stringMatching(/^[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}$/), }) expect(session.sessionExpiresAt - session.accessExpiresAt).toBeGreaterThan(89 * 24 * 3_600_000) @@ -316,6 +316,26 @@ describe.skipIf(!DEV_BYPASS_TOKEN)('/accounts/v1/sessions (dev bypass)', () => { 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('refuses a bad bearer token without trying the envelope', async () => { const key = await registered() const res = await http('POST', '/accounts/v1/account/info', { From 39e2436465cb2fe9cda4f64485af54e49af445d1 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 01:56:59 -0400 Subject: [PATCH 16/25] fix(accounts): one rotation per refresh token, even for concurrent refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two refreshes presenting the replaced token within its minute could both update the row, each issuing a different token, and the reissue orphaned the token issued just before it. With older tokens now ending the session, a device that kept the orphaned one (a double refresh, or a retry racing a lost response) would end its own session on its next refresh. - The token that replaces another is derived from it: HMAC-SHA256 of the presented token under a rotation key derived from `ACCOUNT_TOKEN_SECRET` (`rotationKey`, kept apart from the JWT signing key). Every refresh presenting the same token gets the same next one, so no response carries a token the session doesn't know. - Rotating the current token is a compare-and-swap on the hash read (`UPDATE … WHERE id = ? AND refresh_hash = `): only one refresh rotates the row. The loser reads the row once more and finds its token just replaced, so it answers with the same new token. If the session moved on again in between, it's 401 without ending the session, since that token was current a moment ago. - The replaced token within its minute writes nothing: it gets the token it was replaced by (tried under the current, then the previous, token secret, so a retry spanning a secret rotation still works while `ACCOUNT_TOKEN_SECRET_PREVIOUS` is set; without it the retry is 401 and the session lives). Tests interleave refreshes at the compare-and-swap: both of a double refresh get the same token and the session lives; a lost race after the session moved on is 401 without revoking; two grace retries write nothing. --- AGENTS.md | 22 ++++-- README.md | 3 +- src/accounts/openapi.ts | 2 +- src/accounts/session.test.ts | 112 +++++++++++++++++++++++++++- src/accounts/session.ts | 13 +++- src/accounts/sessionStore.ts | 138 ++++++++++++++++++++++++----------- src/accounts/tokens.ts | 35 +++++++-- src/e2e/accounts.e2e.test.ts | 24 ++++++ 8 files changed, 286 insertions(+), 63 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e3f9c4f..1e247eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -599,12 +599,18 @@ Attest once, then bearer tokens (witness-work ADR 0027; "Sessions" in its - **Refresh**: plain JSON `{ refreshToken }`. A refresh token is `.` (b64u, 16 and 32 bytes; opaque to the app), so the session is found by id, and a malformed one is 401 before any D1 read. - Each refresh replaces the token. The one just replaced still works for 60 - seconds from its rotation (a lost response; retries don't extend it). - Presenting it later, or any older token of a live session (or one it - never issued), ends the session. An unknown, ended, or expired session - is 401 `unauthorized`. Sessions last 90 days from opening and refreshing - never extends them. Read edge tier. + Each refresh replaces the token with one derived from it (HMAC under a key + from `ACCOUNT_TOKEN_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 (or one it never issued), 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 @@ -638,7 +644,9 @@ openssl rand -base64 32 | wrangler secret put ACCOUNT_TOKEN_SECRET wrangler secret delete ACCOUNT_TOKEN_SECRET_PREVIOUS ``` -Skipping the previous slot only costs each device one extra refresh. Local +Skipping the previous slot only costs each device one extra refresh, and +a refresh retried within the minute after the rotation (a lost response) +gets 401, so that device opens a new session. Local `wrangler dev` reads `ACCOUNT_TOKEN_SECRET` from `.dev.vars` (`.dev.vars.example`); the verify launcher and the test harness set their own. diff --git a/README.md b/README.md index 65db0df..8c167a4 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,8 @@ WitnessWork once per session and then calls with short-lived bearer tokens: /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), and presenting an older one ends the session. Refresh tokens are opaque strings + response, which gets the same new refresh token again), and presenting an + older one ends the session. Refresh tokens are opaque strings (`.`). - **`DELETE /accounts/v1/sessions/current`** ends the token's session. - **`GET /accounts/v1/account`** reads the token's account. diff --git a/src/accounts/openapi.ts b/src/accounts/openapi.ts index 6744a6c..c84ca60 100644 --- a/src/accounts/openapi.ts +++ b/src/accounts/openapi.ts @@ -920,7 +920,7 @@ ${fieldTable(playIntegritySessionAttestation)}` 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) gets a new pair; 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. + 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. diff --git a/src/accounts/session.test.ts b/src/accounts/session.test.ts index 8fc9d6e..96fb13e 100644 --- a/src/accounts/session.test.ts +++ b/src/accounts/session.test.ts @@ -294,12 +294,15 @@ describe('POST /sessions/refresh', () => { 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 pair issued in between is gone (presenting it would end the - // session); the retried one works. - expect(retried.body.refreshToken).not.toBe(rotated.body.refreshToken) + // 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) @@ -381,6 +384,39 @@ const recordQueries = () => { 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. */ @@ -463,6 +499,76 @@ describe('refresh token reuse', () => { 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 the retry fails, but the session lives. + delete h.env.ACCOUNT_TOKEN_SECRET_PREVIOUS + expect((await refresh(session.refreshToken)).status).toBe(401) + expect(sessionRows()[0].revoked_at).toBeNull() + expect((await refresh(rotated.body.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', () => { diff --git a/src/accounts/session.ts b/src/accounts/session.ts index 6d756a7..92be584 100644 --- a/src/accounts/session.ts +++ b/src/accounts/session.ts @@ -13,7 +13,12 @@ import { } from './signed' import { accountsDb, newAccountId, registerAccount } from './store' import { closeSession, createSession, refreshSession } from './sessionStore' -import { accessTokenKeys, signAccessToken, type AccessTokenKeys } from './tokens' +import { + accessTokenKeys, + rotationKeys, + signAccessToken, + type AccessTokenKeys, +} from './tokens' import type { SessionPlatform } from './schema' /** @@ -135,7 +140,11 @@ const handleRefresh = guarded('session/refresh', async (c) => { return buddiesErrorResponse('bad_request') const token = parseRefreshToken(body.refreshToken) if (!token) return buddiesErrorResponse('unauthorized') - const issued = await refreshSession(accountsDb(setup.db), token) + const issued = await refreshSession( + accountsDb(setup.db), + rotationKeys(setup.keys), + token + ) if (!issued) return buddiesErrorResponse('unauthorized') return c.json({ ok: true, diff --git a/src/accounts/sessionStore.ts b/src/accounts/sessionStore.ts index 08ddae1..cae0ed0 100644 --- a/src/accounts/sessionStore.ts +++ b/src/accounts/sessionStore.ts @@ -14,6 +14,12 @@ import type { AccountsDb } from './store' * 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 token that replaces another is derived from it (HMAC under a key from + * `ACCOUNT_TOKEN_SECRET`, `rotationKey` in src/accounts/tokens.ts), 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 { @@ -51,10 +57,25 @@ const withHash = async (token: string) => ({ hash: await refreshHash(token), }) -/** A refresh token for the session: a random secret. */ -const newRefreshToken = (sessionId: string) => +/** A session's first refresh token: a random secret. */ +const firstRefreshToken = (sessionId: string) => withHash(`${sessionId}.${bytesToBase64Url(randomBytes(32))}`) +/** The token that replaces `presented`, under `rotationKey`. */ +const nextRefreshToken = async ( + rotationKey: CryptoKey, + presented: RefreshToken +) => { + const secret = await crypto.subtle.sign( + 'HMAC', + rotationKey, + encoder.encode(`ww-account/v1/refresh-next\n${presented.token}`) + ) + return withHash( + `${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 @@ -105,7 +126,7 @@ export const createSession = async ( now: number = Date.now() ): Promise => { const id = bytesToBase64Url(randomBytes(16)) - const refresh = await newRefreshToken(id) + const refresh = await firstRefreshToken(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. @@ -159,27 +180,32 @@ const findSession = async ( .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 } + /** - * Replaces the session's refresh token. Null means 401 `unauthorized`: 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 another new token, and the one - * issued in between stops working; the minute still counts from the first - * rotation, so retries can't stretch it. + * One refresh 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). */ -export const refreshSession = async ( +const refreshStep = async ( db: AccountsDb, + rotationKeys: readonly CryptoKey[], presented: RefreshToken, - now: number = Date.now() -): Promise => { - const hash = await refreshHash(presented.token) - let row = await findSession(db, presented.sessionId) - if (!row || !isLive(row, now)) return null + 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 newRefreshToken(row.id) + const next = await nextRefreshToken(rotationKeys[0], presented) const rotated = await db .update(accountSessions) .set({ @@ -194,12 +220,9 @@ export const refreshSession = async ( isNull(accountSessions.revokedAt) ) ) - if (changes(rotated) === 1) - return { session: record(row), refreshToken: next.token } - // A concurrent refresh with the same token won; this one is now the - // replaced token, answered below. - row = await findSession(db, presented.sessionId) - if (!row || !isLive(row, now)) return null + return changes(rotated) === 1 + ? { done: { session: record(row), refreshToken: next.token } } + : { changed: true } } const replaced = @@ -207,25 +230,58 @@ export const refreshSession = async ( timingSafeEqual(hash, row.previousRefreshHash) const inGrace = row.rotatedAt != null && now - row.rotatedAt.getTime() <= refreshGraceMs - if (!replaced || !inGrace) { - // Replaced over a minute ago, or older still: someone kept a copy. - await revoke(db, row.id, now) - return null + if (replaced && inGrace) { + // The current token is this one's replacement, derived under the key + // that rotated it: the current one, or the previous one just after the + // secret rotated. + for (const key of rotationKeys) { + const next = await nextRefreshToken(key, presented) + if (timingSafeEqual(next.hash, row.refreshHash)) + return { done: { session: record(row), refreshToken: next.token } } + } + return { done: null } } - const next = await newRefreshToken(row.id) - const reissued = await db - .update(accountSessions) - .set({ refreshHash: next.hash }) - .where( - and( - eq(accountSessions.id, row.id), - eq(accountSessions.previousRefreshHash, hash), - isNull(accountSessions.revokedAt) - ) + + // 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`: 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. + * `rotationKeys` are the current key first, then the previous one if set. + */ +export const refreshSession = async ( + db: AccountsDb, + rotationKeys: readonly CryptoKey[], + presented: RefreshToken, + now: number = Date.now() +): Promise => { + const hash = await refreshHash(presented.token) + for (const raced of [false, true]) { + const row = await findSession(db, presented.sessionId) + const step = await refreshStep( + db, + rotationKeys, + presented, + hash, + row, + now, + raced ) - return changes(reissued) === 1 - ? { session: record(row), refreshToken: next.token } - : null + if ('done' in step) return step.done + } + return null } /** Ends the account's session; ending an ended one is a no-op. */ diff --git a/src/accounts/tokens.ts b/src/accounts/tokens.ts index 10bfdcf..c6b9e60 100644 --- a/src/accounts/tokens.ts +++ b/src/accounts/tokens.ts @@ -18,6 +18,10 @@ import { SESSION_PLATFORMS, type SessionPlatform } from './schema' * 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. * + * Each secret also yields the key refresh tokens rotate under + * (`rotationKey`, src/accounts/sessionStore.ts), kept apart from the signing + * key 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. @@ -53,6 +57,8 @@ export interface AccessTokenClaims { interface TokenKey { kid: string key: CryptoKey + /** HMAC-SHA256 key for deriving refresh tokens: SHA-256 of a label and the secret. */ + rotationKey: CryptoKey } export interface AccessTokenKeys { @@ -80,14 +86,21 @@ const tokenKey = (secret: string): Promise => { const digest = await sha256Bytes( encoder.encode(`ww-account/v1/token-kid\n${secret}`) ) - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign', 'verify'] - ) - return { kid: bytesToBase64Url(digest).slice(0, 16), key } + const hmac = (bytes: Uint8Array) => + crypto.subtle.importKey( + 'raw', + bytes, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'] + ) + const [key, rotationKey] = await Promise.all([ + hmac(encoder.encode(secret)), + sha256Bytes( + encoder.encode(`ww-account/v1/refresh-rotation\n${secret}`) + ).then(hmac), + ]) + return { kid: bytesToBase64Url(digest).slice(0, 16), key, rotationKey } })() cached.catch(() => keyCache.delete(secret)) keyCache.set(secret, cached) @@ -131,6 +144,12 @@ export const accessTokenKeys = async ( return { issuer, current, previous } } +/** The keys refresh tokens rotate under: the current one first. */ +export const rotationKeys = (keys: AccessTokenKeys): CryptoKey[] => + keys.previous + ? [keys.current.rotationKey, keys.previous.rotationKey] + : [keys.current.rotationKey] + const encodeJson = (value: unknown): string => bytesToBase64Url(encoder.encode(JSON.stringify(value))) diff --git a/src/e2e/accounts.e2e.test.ts b/src/e2e/accounts.e2e.test.ts index 36e3208..0945f54 100644 --- a/src/e2e/accounts.e2e.test.ts +++ b/src/e2e/accounts.e2e.test.ts @@ -336,6 +336,30 @@ describe.skipIf(!DEV_BYPASS_TOKEN)('/accounts/v1/sessions (dev bypass)', () => { expect((await refresh(token)).status).toBe(401) }) + 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', { From d0447289bd9d3f601f933cc16aa4c6f59df18400 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 02:02:54 -0400 Subject: [PATCH 17/25] fix(notes-import): refuse empty or whitespace-only notesText The gate rejected only a missing `notesText`, so `""` or a run of whitespace passed the body checks and reached attestation, the credit pre-flight, and possibly the model. Kickoff and the synchronous `POST /notes-import` now answer 400 `bad_request` ("notesText is empty") for it, bearer and legacy alike, right after the missing-field checks and before any attestation, RevenueCat, allowance, or model work. Documented in the README and the routes' OpenAPI (the field and the 400). --- README.md | 3 +++ src/notesImport/openapi.ts | 9 ++++---- src/notesImport/route.bearer.test.ts | 33 ++++++++++++++++++++++++++++ src/notesImport/route.ts | 3 +++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8c167a4..c26bfd6 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,9 @@ same handlers for deployed apps. 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. diff --git a/src/notesImport/openapi.ts b/src/notesImport/openapi.ts index e6471e8..087c97b 100644 --- a/src/notesImport/openapi.ts +++ b/src/notesImport/openapi.ts @@ -255,7 +255,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, @@ -339,10 +340,10 @@ const successSchema = z const importGateErrors = (legacy: boolean) => ({ 400: niError( - `Bearer: invalid JSON, missing \`notesText\` or \`context\`, or an invalid \`revenueCatAppUserId\`. Legacy: ${ + `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`; 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`).' + ? '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'] ), diff --git a/src/notesImport/route.bearer.test.ts b/src/notesImport/route.bearer.test.ts index 3e7b6b5..067f82c 100644 --- a/src/notesImport/route.bearer.test.ts +++ b/src/notesImport/route.bearer.test.ts @@ -349,6 +349,39 @@ describe('bearer kickoff', () => { }) }) +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('Supporter status through revenueCatAppUserId', () => { it('checks the named RevenueCat id at kickoff and again at settlement', async () => { fetchMock.mockImplementation(async () => Response.json(LIFETIME)) diff --git a/src/notesImport/route.ts b/src/notesImport/route.ts index a01d26a..152a0cd 100644 --- a/src/notesImport/route.ts +++ b/src/notesImport/route.ts @@ -465,6 +465,9 @@ async function authenticateAndGate( } } + // 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, From c36c6e50872a12b74af138cf6370876b00650c4d Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 02:07:31 -0400 Subject: [PATCH 18/25] feat(buddies): keep the Notification Service Extension's sync after the legacy 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 (it would race the app and trip reuse revocation), so once KV `auth:legacy` is off its fetch got 426 and named alerts fell back to the template. While the switch is off, the Worker now admits one legacy request: an `inbox/sync` carrying `x-buddies-client: notification-service`, as `linkedOnly`. The inbox DO checks the owner signature, `ts`, nonce, and suspension exactly as before, then serves it only when its `owner_account` link is confirmed (the owner registered the inbox on a session, and D1 holds the link); otherwise it answers 426 `upgrade_required`, after the signature check. Every other legacy request stays 426, including other ops with the header and a wrong header value. While the switch is on, and on bearer requests, the header changes nothing. Nothing logs it. Tests cover each branch (confirmed, unlinked, unconfirmed, a pre-column row, other ops, wrong values, switch on, bearer), and the verify loop's e2e suite runs the confirmed and unlinked cases against a real worker. Documented in AGENTS.md (Buddies and the legacy-switch runbook: the floor must be a version whose extension sends the header), the protocol, and `inbox/sync`'s OpenAPI. --- AGENTS.md | 22 +++- docs/buddies-protocol.md | 5 +- src/buddies/contracts.ts | 22 +++- src/buddies/inboxDO.ts | 23 +++- src/buddies/openapi.ts | 20 ++- src/buddies/route.ts | 68 +++++++++-- src/buddies/sessions.test.ts | 181 ++++++++++++++++++++++++++++ src/e2e/buddiesSessions.e2e.test.ts | 49 +++++++- 8 files changed, 365 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1e247eb..bb43b35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -330,7 +330,17 @@ Links. Clients are iOS and Android 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. The live + 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 @@ -701,12 +711,20 @@ 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. +`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. diff --git a/docs/buddies-protocol.md b/docs/buddies-protocol.md index 1547799..bd512f9 100644 --- a/docs/buddies-protocol.md +++ b/docs/buddies-protocol.md @@ -61,6 +61,7 @@ Every op also takes `Authorization: Bearer ` from an attested acco - 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. @@ -80,7 +81,7 @@ 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, or a legacy request after the legacy switch was turned off; no `Retry-After` | +| 426 | `upgrade_required` | `inbox/live` requested without a WebSocket upgrade, or a legacy request after the legacy switch was turned off (except the Notification Service Extension's `inbox/sync` for an inbox with a confirmed link); no `Retry-After` | | 401 | `unauthorized` | A bearer request's token is missing, expired, or invalid; carries `WWW-Authenticate` | | 403 | `forbidden` | A bearer request for an inbox linked to another account, or an `account` proof for another account than the token's | | 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`) | @@ -285,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/src/buddies/contracts.ts b/src/buddies/contracts.ts index 3946d74..8ccdf2c 100644 --- a/src/buddies/contracts.ts +++ b/src/buddies/contracts.ts @@ -61,6 +61,17 @@ export const BUDDIES_LIVE_HEADERS = BUDDIES_ENVELOPE_HEADERS */ 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 * owner-signed like an owner op, but its body is the raw sealed bytes, so the @@ -141,8 +152,10 @@ export const BUDDIES_ERROR_STATUS = { /** * `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. Never carries `Retry-After`: - * updating the app is the fix. + * 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`. */ @@ -163,8 +176,9 @@ export const BUDDIES_ERROR_STATUS = { */ forbidden: 403, /** - * A bearer Buddies owner request for an inbox registered before accounts - * (linked to none). The app links it with `inbox/register` and retries. + * 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, /** diff --git a/src/buddies/inboxDO.ts b/src/buddies/inboxDO.ts index f48ca10..0898be8 100644 --- a/src/buddies/inboxDO.ts +++ b/src/buddies/inboxDO.ts @@ -449,10 +449,19 @@ export class BuddyInbox extends DurableObject { return result } + /** + * `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 + call: OwnerCall, + { linkedOnly = false }: { linkedOnly?: boolean } = {} ): Promise> { - const auth = await this.#authenticate('inbox/sync', call, 'owner') + 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)) @@ -1217,11 +1226,17 @@ export class BuddyInbox extends DurableObject { * 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: AuthCall, - ref: KeyRef + ref: KeyRef, + { linkedOnly = false }: { linkedOnly?: boolean } = {} ): Promise> { const key = this.#key(call, ref) if (!key.ok) return key @@ -1236,6 +1251,8 @@ export class BuddyInbox extends DurableObject { 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') diff --git a/src/buddies/openapi.ts b/src/buddies/openapi.ts index fc2c404..ab5bd97 100644 --- a/src/buddies/openapi.ts +++ b/src/buddies/openapi.ts @@ -15,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' @@ -428,9 +430,22 @@ export const buddiesOpDocs: Record = { 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 ), + 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({ @@ -440,7 +455,10 @@ export const buddiesOpDocs: Record = { }), 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.`, + }), }, }), diff --git a/src/buddies/route.ts b/src/buddies/route.ts index a9d0e1d..ae30dcc 100644 --- a/src/buddies/route.ts +++ b/src/buddies/route.ts @@ -6,10 +6,12 @@ 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, @@ -32,6 +34,7 @@ import { type OwnerCall, type PushJob, type Signed, + type SyncPayload, } from './contracts' import { isEnvelopeBody, @@ -60,7 +63,10 @@ import { parseJsonOrNull, type AccountAuth, } from '../accounts/auth' -import { refuseLegacyAuth } from '../accounts/legacySwitch' +import { + isLegacyAuthEnabled, + upgradeRequiredResponse, +} from '../accounts/legacySwitch' import { isBuddiesEnabled, isBuddiesPhotosEnabled } from './killSwitch' import { buddiesErrorResponse, buddiesInternalError } from './errorResponse' import { @@ -95,7 +101,8 @@ import { * 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. + * 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, token, or any @@ -148,6 +155,12 @@ const photosAvailable = async (env: Environment): Promise => 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 } @@ -160,6 +173,14 @@ type Admission = { ok: true; caller: Caller } | { ok: false; response: Response * `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, @@ -170,13 +191,15 @@ const admitCaller = async ( const auth = await authenticateAccount(c) if (!auth.ok) return auth const key = await buddiesAccountCallerKey(auth.account.accountId) - caller = { key, account: auth.account } + caller = { key, account: auth.account, linkedOnly: false } } else { - const refused = await refuseLegacyAuth(c.env) - if (refused) return { ok: false, response: refused } + 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)) @@ -184,6 +207,11 @@ const admitCaller = async ( : { 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, @@ -301,15 +329,25 @@ const registerInbox = async ( return ok({ body: accountId ? { accountId } : {} }) } +/** + * `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 = { - // `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': (env, call) => syncInbox(env, call), 'inbox/delete': async (env, call) => empty(await inbox(env, call.inboxId).deleteInbox(call)), 'device/register': async (env, call) => @@ -433,6 +471,12 @@ const runSigned = async ( 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 register = call.value as unknown as Signed< BuddiesPayloads['inbox/register'] diff --git a/src/buddies/sessions.test.ts b/src/buddies/sessions.test.ts index 0894936..3d1243a 100644 --- a/src/buddies/sessions.test.ts +++ b/src/buddies/sessions.test.ts @@ -25,6 +25,7 @@ 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, @@ -996,6 +997,186 @@ describe('the legacy switch', () => { }) }) +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 }) diff --git a/src/e2e/buddiesSessions.e2e.test.ts b/src/e2e/buddiesSessions.e2e.test.ts index 8fc117a..451a91b 100644 --- a/src/e2e/buddiesSessions.e2e.test.ts +++ b/src/e2e/buddiesSessions.e2e.test.ts @@ -1,12 +1,14 @@ import { afterAll, describe, expect, it } from 'vitest' import { blobOfSize, + envelope, photoBlob, randomBytes, randomId, relayError, unsignedEnvelope, b64u, + type SigningKey, } from '../test/buddiesClient' import { BearerRelayOwner, @@ -33,7 +35,8 @@ import { BUDDIES_ABUSE_LIMITS } from '../buddies/limits' * 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. + * legacy requests with 426 while bearer ones work, except the iOS + * Notification Service Extension's signed sync for a confirmed inbox. */ afterAll(() => { @@ -233,6 +236,50 @@ describe.skipIf(!LOCAL_LAUNCHER || !DEV_BYPASS_TOKEN)('the legacy switch on Budd } 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)', () => { From 381e48866391bd7eedbb635a3bbb4619bf0adb68 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 02:09:09 -0400 Subject: [PATCH 19/25] test(e2e): empty Notes Import text is a 400 through the verify loop Bearer kickoff, the bearer synchronous route, and a legacy kickoff with empty or whitespace-only notesText each answer 400 bad_request against the local worker. --- src/e2e/notesImportAccounts.e2e.test.ts | 27 ++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/e2e/notesImportAccounts.e2e.test.ts b/src/e2e/notesImportAccounts.e2e.test.ts index 038fe9e..da36bc1 100644 --- a/src/e2e/notesImportAccounts.e2e.test.ts +++ b/src/e2e/notesImportAccounts.e2e.test.ts @@ -13,7 +13,8 @@ import { /** * 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, and legacy requests get 426 once the legacy switch is off. + * 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 @@ -94,6 +95,30 @@ describe.skipIf(!DEV_BYPASS_TOKEN)('Notes Import with a bearer token', () => { 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) From fa4bccedf07048604ea7f80e1aaa7d9f58c3cfb1 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 02:24:50 -0400 Subject: [PATCH 20/25] fix(accounts): a forged refresh token can no longer end a session Refresh tokens were `.`, and a well-formed token naming a live session that matched neither the current nor the just-replaced hash ended the session as reuse. The session id is no secret (the access token's `sid` claim, the open response), so anyone who learned it could end someone's session with `.`. Refresh tokens are now `..`: the MAC is HMAC-SHA256 over `.` under its own key from ACCOUNT_TOKEN_SECRET (label `ww-account/v1/refresh-mac`, apart from the access-token key and the next-token rotation key), checked under the current and previous secret with `subtle.verify` (constant time). A token whose MAC doesn't verify is 401 before any D1 read and ends nothing; only a MAC-valid token that's neither current nor the replaced one within its minute counts as reuse and ends the session. The next token is still derived from the presented one, and signed under the same secret that derived it, so the same token always gets the same next one, including a lost-response retry across a secret rotation; the CAS rotation is unchanged. Two-part tokens from before the MAC (issued on the dev worker) are malformed now: 401 without a database read or revocation, and the app opens a new session. Tokens stay opaque, 110 characters. Behaviour change for secret rotation: refresh tokens issued under the old secret refresh only while it's in ACCOUNT_TOKEN_SECRET_PREVIOUS; AGENTS.md now says to keep it 90 days for a routine rotation (delete at once after a leak), since deleting it sends every device that hasn't refreshed since through a new session. Tests: made-up and moved MACs never read the database or end a session; MACs under the access-token key, the rotation key, another label, or another secret are refused, while the same token under the worker's own MAC key ends it (the MAC is the only difference); genuine older tokens still end the session, across a rotation too; a token signed under the previous secret refreshes and its replacement is signed under the new one; malformed and two-part inputs never touch the database. Removing the MAC check fails four of them. --- AGENTS.md | 40 ++++--- README.md | 3 +- src/accounts/contracts.ts | 34 ++++-- src/accounts/openapi.ts | 8 +- src/accounts/session.test.ts | 224 +++++++++++++++++++++++++++++------ src/accounts/session.ts | 17 +-- src/accounts/sessionStore.ts | 121 ++++++++++++------- src/accounts/tokens.test.ts | 29 +++++ src/accounts/tokens.ts | 51 +++++--- src/e2e/accounts.e2e.test.ts | 4 +- wrangler.toml | 3 +- 11 files changed, 402 insertions(+), 132 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bb43b35..9034a68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -607,16 +607,21 @@ Attest once, then bearer tokens (witness-work ADR 0027; "Sessions" in its key (`BUDDY_REGISTRATION_QUOTA` under a hashed `session-open` name), counted before the attestation. - **Refresh**: plain JSON `{ refreshToken }`. A refresh token is - `.` (b64u, 16 and 32 bytes; opaque to the app), so the - session is found by id, and a malformed one is 401 before any D1 read. - Each refresh replaces the token with one derived from it (HMAC under a key - from `ACCOUNT_TOKEN_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 (or one it never issued), ends the session. An unknown, + `..` (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 @@ -650,13 +655,20 @@ openssl rand -base64 32 | wrangler secret put ACCOUNT_TOKEN_SECRET # add --env # 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 -# After 15 minutes: +# Routine rotation: after 90 days (the longest a session lasts). After a +# leak: at once. wrangler secret delete ACCOUNT_TOKEN_SECRET_PREVIOUS ``` -Skipping the previous slot only costs each device one extra refresh, and -a refresh retried within the minute after the rotation (a lost response) -gets 401, so that device opens a new session. Local +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. diff --git a/README.md b/README.md index c26bfd6..c58dbb7 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,8 @@ WitnessWork once per session and then calls with short-lived bearer tokens: 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/*`) diff --git a/src/accounts/contracts.ts b/src/accounts/contracts.ts index 972450b..2ef44ae 100644 --- a/src/accounts/contracts.ts +++ b/src/accounts/contracts.ts @@ -329,29 +329,41 @@ export const sessionDeviceTooLarge = (payload: unknown): boolean => export const sessionAttestationHash = (accountPub: string): Promise => sha256Hex(`ww-account/v1/session\n${accountPub}`) -/** A refresh token as presented: the session it names, and the whole token. */ +/** 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 } -/** b64u of exactly `size` bytes, in its canonical spelling. */ -const isCanonicalB64u = (value: string, size: number): boolean => { +/** 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 + 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) and b64u of 32 bytes, each in its canonical spelling. - * Null for anything else. + * 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 !== 2) return null - const [sessionId, secret] = parts - return isCanonicalB64u(sessionId, 16) && isCanonicalB64u(secret, 32) - ? { sessionId, token: value } + 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/openapi.ts b/src/accounts/openapi.ts index c84ca60..cc0009c 100644 --- a/src/accounts/openapi.ts +++ b/src/accounts/openapi.ts @@ -817,13 +817,13 @@ const sessionAttestationErrorSchema = apiErrorSchema 'The accounts error envelope (`error` repeats `code`) plus the attestation’s `reason` and `action`.', }) -/** Refresh tokens: `.`, b64u of 16 and 32 bytes. */ +/** 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}$/) + .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 and a secret, \`.\` (base64url, 16 and 32 bytes); store and send it as is`, + 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 = { @@ -937,7 +937,7 @@ ${readTier}`, ), 400: errorResponse('Malformed JSON, or no `refreshToken`.', ['bad_request']), 401: errorResponse( - 'Malformed, unknown, replaced, ended, or expired: open a new session. A replaced token presented after its minute, or any older one, also ends the session.', + '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']), diff --git a/src/accounts/session.test.ts b/src/accounts/session.test.ts index 96fb13e..749d704 100644 --- a/src/accounts/session.test.ts +++ b/src/accounts/session.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SigningKey, TEST_DEV_BYPASS, + TEST_TOKEN_SECRET, createHarness, randomBytes, b64u, @@ -81,6 +82,14 @@ 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, @@ -98,10 +107,10 @@ describe('POST /sessions with the dev bypass', () => { sessionId: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), accessToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/), accessExpiresAt: START + 15 * MINUTE, - refreshToken: `${session.sessionId}.${session.refreshToken.split('.')[1]}`, + refreshToken: expect.stringMatching(REFRESH_TOKEN), sessionExpiresAt: START + 90 * DAY, }) - expect(session.refreshToken).toMatch(/^[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}$/) + expect(session.refreshToken.split('.')[0]).toBe(session.sessionId) // Only the account, platform, label, token hashes, and times. const [row] = sessionRows() @@ -276,11 +285,10 @@ describe('POST /sessions/refresh', () => { ok: true, accessToken: expect.any(String), accessExpiresAt: Date.now() + 15 * MINUTE, - refreshToken: expect.stringMatching( - new RegExp(`^${session.sessionId}\\.[A-Za-z0-9_-]{43}$`) - ), + 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) @@ -330,9 +338,7 @@ describe('POST /sessions/refresh', () => { }) it('refuses unknown and malformed tokens', async () => { - const unknown = await refresh( - `${b64u(randomBytes(16))}.${b64u(randomBytes(32))}` - ) + const unknown = await refresh(madeUp(b64u(randomBytes(16)))) expect(unknown.status).toBe(401) expect(unknown.body).toEqual({ ok: false, @@ -440,14 +446,6 @@ describe('refresh token reuse', () => { expect((await refresh(second.body.refreshToken)).status).toBe(401) }) - it('ends the session for a token it never issued', async () => { - const session = await open() - const forged = `${session.sessionId}.${b64u(randomBytes(32))}` - expect((await refresh(forged)).status).toBe(401) - expect(sessionRows()[0].revoked_at).toBe(Date.now()) - expect((await refresh(session.refreshToken)).status).toBe(401) - }) - it('stores only hashes of whole tokens', async () => { const session = await open() const next = await refresh(session.refreshToken) @@ -455,28 +453,39 @@ describe('refresh token reuse', () => { 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] = session.refreshToken.split('.') + const [sessionId, secret, mac] = session.refreshToken.split('.') const queries = recordQueries() for (const token of [ + '', secret, b64u(randomBytes(32)), - `${sessionId}.${secret}.x`, - `${sessionId}${secret}`, - `${sessionId}.`, - `.${secret}`, - `${sessionId}.${secret}=`, - `${sessionId}.${secret.slice(0, -1)}`, - `${sessionId}.${b64u(randomBytes(33))}`, - `${b64u(randomBytes(15))}.${secret}`, + // 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}`, - `${sessionId}.${setPaddingBit(secret)}`, - `${sessionId}.${secret.replace(/./, '+')}`, + `${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) } @@ -490,11 +499,15 @@ describe('refresh token reuse', () => { expect((await close(bearer(session.accessToken))).status).toBe(200) const row = sessionRows()[0] const queries = recordQueries() - expect( - (await refresh(`${b64u(randomBytes(16))}.${b64u(randomBytes(32))}`)) - .status - ).toBe(401) + // 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) }) @@ -510,12 +523,155 @@ describe('refresh token reuse', () => { expect(retried.status).toBe(200) expect(retried.body.refreshToken).toBe(rotated.body.refreshToken) - // Without the previous slot the retry fails, but the session lives. + // 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(sessionRows()[0].revoked_at).toBeNull() + 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', () => { diff --git a/src/accounts/session.ts b/src/accounts/session.ts index 92be584..115e0af 100644 --- a/src/accounts/session.ts +++ b/src/accounts/session.ts @@ -15,7 +15,7 @@ import { accountsDb, newAccountId, registerAccount } from './store' import { closeSession, createSession, refreshSession } from './sessionStore' import { accessTokenKeys, - rotationKeys, + refreshKeys, signAccessToken, type AccessTokenKeys, } from './tokens' @@ -107,11 +107,11 @@ const handleOpen = guarded('session/open', async (c) => { const db = accountsDb(setup.db) const account = await registerAccount(db, accountPub, newAccountId) - const { session, refreshToken } = await createSession(db, { - accountId: account.id, - platform, - device, - }) + const { session, refreshToken } = await createSession( + db, + setup.keys.current, + { accountId: account.id, platform, device } + ) return c.json( { ok: true, @@ -127,7 +127,8 @@ const handleOpen = guarded('session/open', async (c) => { /** * `POST /sessions/refresh`: plain JSON `{ refreshToken }`, replaced every - * time. A malformed token is 401 before any database read. + * 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) @@ -142,7 +143,7 @@ const handleRefresh = guarded('session/refresh', async (c) => { if (!token) return buddiesErrorResponse('unauthorized') const issued = await refreshSession( accountsDb(setup.db), - rotationKeys(setup.keys), + refreshKeys(setup.keys), token ) if (!issued) return buddiesErrorResponse('unauthorized') diff --git a/src/accounts/sessionStore.ts b/src/accounts/sessionStore.ts index cae0ed0..2da8b5a 100644 --- a/src/accounts/sessionStore.ts +++ b/src/accounts/sessionStore.ts @@ -3,23 +3,32 @@ 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. + * 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 token that replaces another is derived from it (HMAC under a key from - * `ACCOUNT_TOKEN_SECRET`, `rotationKey` in src/accounts/tokens.ts), 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. + * 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 { @@ -57,21 +66,45 @@ const withHash = async (token: string) => ({ hash: await refreshHash(token), }) -/** A session's first refresh token: a random secret. */ -const firstRefreshToken = (sessionId: string) => - withHash(`${sessionId}.${bytesToBase64Url(randomBytes(32))}`) +/** `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))}`) +} -/** The token that replaces `presented`, under `rotationKey`. */ -const nextRefreshToken = async ( - rotationKey: CryptoKey, +/** + * 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', - rotationKey, + keys.rotationKey, encoder.encode(`ww-account/v1/refresh-next\n${presented.token}`) ) - return withHash( + return issueRefreshToken( + keys, `${presented.sessionId}.${bytesToBase64Url(new Uint8Array(secret))}` ) } @@ -114,10 +147,11 @@ const record = (row: SessionRow): SessionRecord => ({ /** * Opens a session, then ends the account's oldest live sessions past - * `sessionsPerAccount`, in one batch. + * `sessionsPerAccount`, in one batch. `keys` are the current secret's. */ export const createSession = async ( db: AccountsDb, + keys: RefreshKeys, input: { accountId: string platform: SessionPlatform @@ -126,7 +160,7 @@ export const createSession = async ( now: number = Date.now() ): Promise => { const id = bytesToBase64Url(randomBytes(16)) - const refresh = await firstRefreshToken(id) + 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. @@ -187,15 +221,15 @@ type RefreshStep = | { changed: true } /** - * One refresh 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). + * 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, - rotationKeys: readonly CryptoKey[], + keys: readonly RefreshKeys[], presented: RefreshToken, hash: string, row: SessionRow | undefined, @@ -205,7 +239,7 @@ const refreshStep = async ( if (!row || !isLive(row, now)) return { done: null } if (timingSafeEqual(hash, row.refreshHash)) { - const next = await nextRefreshToken(rotationKeys[0], presented) + const next = await nextRefreshToken(keys[0], presented) const rotated = await db .update(accountSessions) .set({ @@ -231,27 +265,29 @@ const refreshStep = async ( const inGrace = row.rotatedAt != null && now - row.rotatedAt.getTime() <= refreshGraceMs if (replaced && inGrace) { - // The current token is this one's replacement, derived under the key - // that rotated it: the current one, or the previous one just after the - // secret rotated. - for (const key of rotationKeys) { - const next = await nextRefreshToken(key, presented) + // 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 } } - // Replaced over a minute ago, or older still: someone kept a copy. + // 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`: 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. + * 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 @@ -259,20 +295,21 @@ const refreshStep = async ( * 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. - * `rotationKeys` are the current key first, then the previous one if set. + * `keys` are the current secret's first, then the previous one's if set. */ export const refreshSession = async ( db: AccountsDb, - rotationKeys: readonly CryptoKey[], + 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, - rotationKeys, + keys, presented, hash, row, diff --git a/src/accounts/tokens.test.ts b/src/accounts/tokens.test.ts index 225bfdc..c695a1e 100644 --- a/src/accounts/tokens.test.ts +++ b/src/accounts/tokens.test.ts @@ -3,6 +3,7 @@ import { bytesToBase64Url } from '../crypto' import { ACCESS_TOKEN_AUDIENCE, accessTokenKeys, + refreshKeys, signAccessToken, verifyAccessToken, type TokenEnv, @@ -205,3 +206,31 @@ describe('access tokens', () => { 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 index c6b9e60..8fda45c 100644 --- a/src/accounts/tokens.ts +++ b/src/accounts/tokens.ts @@ -17,10 +17,14 @@ import { SESSION_PLATFORMS, type SessionPlatform } from './schema' * 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 key refresh tokens rotate under - * (`rotationKey`, src/accounts/sessionStore.ts), kept apart from the signing - * key by its own derivation. + * 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 @@ -54,11 +58,20 @@ export interface AccessTokenClaims { expiresAt: number } -interface TokenKey { +/** + * 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 - /** HMAC-SHA256 key for deriving refresh tokens: SHA-256 of a label and the secret. */ - rotationKey: CryptoKey } export interface AccessTokenKeys { @@ -94,13 +107,19 @@ const tokenKey = (secret: string): Promise => { false, ['sign', 'verify'] ) - const [key, rotationKey] = await Promise.all([ + const derived = (label: string) => + sha256Bytes(encoder.encode(`${label}\n${secret}`)).then(hmac) + const [key, macKey, rotationKey] = await Promise.all([ hmac(encoder.encode(secret)), - sha256Bytes( - encoder.encode(`ww-account/v1/refresh-rotation\n${secret}`) - ).then(hmac), + derived('ww-account/v1/refresh-mac'), + derived('ww-account/v1/refresh-rotation'), ]) - return { kid: bytesToBase64Url(digest).slice(0, 16), key, rotationKey } + return { + kid: bytesToBase64Url(digest).slice(0, 16), + key, + macKey, + rotationKey, + } })() cached.catch(() => keyCache.delete(secret)) keyCache.set(secret, cached) @@ -144,11 +163,11 @@ export const accessTokenKeys = async ( return { issuer, current, previous } } -/** The keys refresh tokens rotate under: the current one first. */ -export const rotationKeys = (keys: AccessTokenKeys): CryptoKey[] => - keys.previous - ? [keys.current.rotationKey, keys.previous.rotationKey] - : [keys.current.rotationKey] +/** 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))) diff --git a/src/e2e/accounts.e2e.test.ts b/src/e2e/accounts.e2e.test.ts index 0945f54..41fc955 100644 --- a/src/e2e/accounts.e2e.test.ts +++ b/src/e2e/accounts.e2e.test.ts @@ -269,7 +269,9 @@ describe.skipIf(!DEV_BYPASS_TOKEN)('/accounts/v1/sessions (dev bypass)', () => { 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}$/), + 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) diff --git a/wrangler.toml b/wrangler.toml index 2b8729f..25f4238 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -604,5 +604,6 @@ logs.persist = true # 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). +# 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] From e0d0d87760d0a2db204c562550dffe8d17c5ac83 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 02:32:27 -0400 Subject: [PATCH 21/25] fix(notes-import): every synchronous import holds its own concurrency slot The synchronous POST /notes-import keyed its slot on the content (`legacy:`), and the index DO admits every request for a key it already holds, so N identical requests at once each ran the model while holding one slot, and the first to finish released it for all. The index DO now hands each synchronous request a slot of its own (`acquireSync`, key `legacy:`), so a burst of identical requests is capped like any other. Charging is unchanged: recordUsage still charges a content hash once however many runs reach it, and a refinement still counts per run (now at most `cap` at once). The keys still never collide with a streaming run's importId. A per-request key can't be reclaimed by a retry the way the content key was, and the `finally` that releases it may never run (a client that disconnects mid-run, an evicted isolate), so `acquireSync` first drops synchronous slots older than 15 minutes (SYNC_SLOT_MAX_AGE_MS), far past any real run. That also clears content-keyed slots leaked before this change. Streaming runs' slots are never aged out. Tests (real index and run DOs): identical bearer, App Attest v1 legacy, and dev-bypass bursts run exactly `cap` models in distinct slots, refuse the rest with 429 active_cap while they run, free every slot, and charge the content once; identical refinements count per run; a synchronous request never shares a run's slot; abandoned slots age out at exactly 15 minutes, old content-keyed ones too, while a run's slot stays. All six route tests fail against the previous slot key. --- .../notes-import-streaming-durable-objects.md | 3 + src/notesImport/cap.ts | 14 + src/notesImport/indexDO.ts | 33 ++- src/notesImport/openapi.ts | 2 +- src/notesImport/route.bearer.test.ts | 271 ++++++++++++++++++ src/notesImport/route.ts | 23 +- 6 files changed, 331 insertions(+), 15 deletions(-) diff --git a/docs/notes-import-streaming-durable-objects.md b/docs/notes-import-streaming-durable-objects.md index e3fbe9d..0defab5 100644 --- a/docs/notes-import-streaming-durable-objects.md +++ b/docs/notes-import-streaming-durable-objects.md @@ -150,6 +150,9 @@ 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. - *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/src/notesImport/cap.ts b/src/notesImport/cap.ts index ce7aee9..325ad52 100644 --- a/src/notesImport/cap.ts +++ b/src/notesImport/cap.ts @@ -16,6 +16,20 @@ 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. Far beyond any real model run (about a minute); past it, a run still + * going at worst lets one more start. + */ +export const SYNC_SLOT_MAX_AGE_MS = 15 * 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/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 087c97b..e827966 100644 --- a/src/notesImport/openapi.ts +++ b/src/notesImport/openapi.ts @@ -421,7 +421,7 @@ export const legacyImportDocs = describeRoute({ 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. 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).`, deprecated: true, security: bearerOrLegacy, parameters: [devBypassHeader], diff --git a/src/notesImport/route.bearer.test.ts b/src/notesImport/route.bearer.test.ts index 067f82c..5596127 100644 --- a/src/notesImport/route.bearer.test.ts +++ b/src/notesImport/route.bearer.test.ts @@ -15,6 +15,8 @@ import { accountsDb, setAccountStatus } from '../accounts/store' import { accessTokenKeys, signAccessToken } from '../accounts/tokens' import { analyticsDistinctId } from '../analytics' import { app } from '../app' +import { SYNC_SLOT_MAX_AGE_MS } from './cap' +import { getNotesImportConfig } from './config' import { NotesImportIndex } from './indexDO' import { NotesImportRun } from './runDO' import { importOwnerKey } from './route' @@ -31,6 +33,8 @@ 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', () => ({ @@ -44,6 +48,14 @@ vi.mock('cloudflare:workers', () => ({ }, })) 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, @@ -116,6 +128,7 @@ beforeEach(async () => { }) mocks.transcribe.mockReset() mocks.capture.mockClear() + mocks.appAttest = null }) afterEach(() => { @@ -382,6 +395,264 @@ describe('empty notes', () => { }) }) +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 }, + ]) + }) +}) + +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)) diff --git a/src/notesImport/route.ts b/src/notesImport/route.ts index 152a0cd..e0701a3 100644 --- a/src/notesImport/route.ts +++ b/src/notesImport/route.ts @@ -1134,23 +1134,20 @@ 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). 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(gate)}` - 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( @@ -1250,7 +1247,7 @@ export async function handleNotesImportRequest(ctx: AppContext) { } satisfies NotesImportSuccess return ctx.json(response) } finally { - await idx.release(slotKey) + await idx.release(acquired.slot) } } From aa07a53684adfd470dcbed2699af504f461f7a5b Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 02:36:55 -0400 Subject: [PATCH 22/25] fix(sentry): cut credential query parameters from events and traces Legacy Notes Import run routes (events, result, cancel, destroy) take their subscribe token as `?token=`. The Cloudflare SDK records the request URL whole (event.request.url and query_string, the root span's `url.full`, the Referer header, spans and breadcrumbs), and Notes Import traces at 100%, so live subscribe tokens reached Sentry. `src/sentry.ts` now cuts the value of every `token` and `apiKey` query parameter (the HERE proxy drops a client-sent `apiKey`; no other route reads a credential from its query) from every string the existing scrubbers walk in errors, transactions with their spans, and breadcrumbs, leaving the rest of the URL (`token=[redacted]`). An object or pair-list `request.query_string`, which the event format allows, is redacted by key. Names match without regard to case. Tests: the scrubber on URLs, query strings, quoted and prose strings; hand-built errors, transactions, and breadcrumbs; and a real request through the SDK's own wrapRequestHandler with the worker's config and a recording transport, whose sent error and transaction carry no token anywhere. Removing either scrubber fails them. --- AGENTS.md | 8 +++ src/sentry.request.test.ts | 124 ++++++++++++++++++++++++++++++++++++ src/sentry.test.ts | 127 +++++++++++++++++++++++++++++++++++++ src/sentry.ts | 50 ++++++++++++++- 4 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 src/sentry.request.test.ts diff --git a/AGENTS.md b/AGENTS.md index 9034a68..3322326 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -294,6 +294,14 @@ 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`. 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 diff --git a/src/sentry.request.test.ts b/src/sentry.request.test.ts new file mode 100644 index 0000000..07d4dfe --- /dev/null +++ b/src/sentry.request.test.ts @@ -0,0 +1,124 @@ +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]' + +describe('Sentry on a request with a subscribe token in its URL', () => { + it('sends the error and the transaction without the token', async () => { + 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 {} + }), + } + // What the fetch integration records for an outbound call. + const app = new Hono().get('/notes-import/:importId/events', async (c) => { + 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_WITH_TOKEN, { + headers: { referer: URL_WITH_TOKEN, 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() + + const sent = envelopes.join('\n') + 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( + (transaction!.contexts as { trace: { data: Record } }) + .trace.data['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 }), + }), + ]) + }) +}) diff --git a/src/sentry.test.ts b/src/sentry.test.ts index 7b118ed..2da417e 100644 --- a/src/sentry.test.ts +++ b/src/sentry.test.ts @@ -13,6 +13,7 @@ import { isDatabaseError, redactContactLinkPayloads, redactDatabaseErrors, + redactSecretQueryParameters, reportableError, } from './sentry' import { recordSentryEvents, type SentryRecorder } from './test/sentry' @@ -59,6 +60,39 @@ 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]', + ], + ])('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', + ])('leaves %s alone', (input) => { + expect(redactSecretQueryParameters(input)).toBe(input) + }) +}) + describe('Sentry scrubbers', () => { it('beforeSend scrubs URLs, request data and messages', () => { const event: ErrorEvent = { @@ -221,6 +255,99 @@ describe('Sentry scrubbers', () => { ).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']], + ], + ]) { + 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', diff --git a/src/sentry.ts b/src/sentry.ts index 17c1920..48b81b1 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -20,6 +20,30 @@ 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. + */ +const SECRET_QUERY_PARAMETERS = ['token', 'apikey']; +const SECRET_QUERY_PARAMETER = new RegExp( + `((?:^|[?&])(?:${SECRET_QUERY_PARAMETERS.join('|')})=)[^&#\\s"'<>]*`, + 'gi' +); +const REDACTED_QUERY_VALUE = '[redacted]'; + +export function redactSecretQueryParameters(value: string): string { + return value.replace(SECRET_QUERY_PARAMETER, `$1${REDACTED_QUERY_VALUE}`); +} + +const isSecretQueryParameter = (name: string): boolean => + SECRET_QUERY_PARAMETERS.includes(name.toLowerCase()); + /** * Drizzle's query errors carry the SQL and its bound parameters (account ids, * session ids, refresh token hashes, inbox ids) in their message, as @@ -86,7 +110,9 @@ export function captureFailure(error: unknown, operation: string): void { */ function scrubStrings(value: unknown, seen = new WeakSet()): unknown { if (typeof value === 'string') { - return redactDatabaseErrors(redactContactLinkPayloads(value)); + return redactDatabaseErrors( + redactSecretQueryParameters(redactContactLinkPayloads(value)) + ); } if (typeof value !== 'object' || value === null || seen.has(value)) { return value; @@ -131,6 +157,27 @@ function dropSecretHeaders(event: Event): void { } } +/** + * `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 @@ -169,6 +216,7 @@ function dropDatabaseErrorCauses(event: Event): void { function scrubEvent(event: T): T { dropSecretHeaders(event); + redactQueryStringPairs(event); const scrubbed = scrubStrings(event) as T; dropDatabaseErrorCauses(scrubbed); return scrubbed; From aa1df382da8093d64a33f74e845d0d6d24a58934 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 02:38:44 -0400 Subject: [PATCH 23/25] test(e2e): a refresh token the worker didn't issue never ends a session A made-up token for a live session's id, a real MAC moved to another secret, and two-part tokens from before the MAC each get 401 unauthorized against the local worker, and the session's own token still refreshes after them. --- src/e2e/accounts.e2e.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/e2e/accounts.e2e.test.ts b/src/e2e/accounts.e2e.test.ts index 41fc955..44ed0da 100644 --- a/src/e2e/accounts.e2e.test.ts +++ b/src/e2e/accounts.e2e.test.ts @@ -338,6 +338,29 @@ describe.skipIf(!DEV_BYPASS_TOKEN)('/accounts/v1/sessions (dev bypass)', () => { 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 } }) From 40eaf21a73b5bb83a4f436dcee641221a633b80a Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 02:48:34 -0400 Subject: [PATCH 24/25] fix(sentry): cut credentials whose query parameter name is percent-encoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scrubber matched the literal names `token=` and `apiKey=`, but Hono decodes parameter names, so a legacy run route reads `?to%6Ben=` as `token` while the raw URL reached Sentry unchanged (request.url and query_string, `url.full`, the Referer header, spans, breadcrumbs). The HERE proxy's URLSearchParams likewise reads `api%4Bey` as `apiKey`. `src/sentry.ts` now finds every `name=` at a string's start or after `?` or `&`, decodes the name as URLSearchParams does (`+` as a space, percent-escapes decoded, a malformed one kept, never an error), and cuts the value when the decoded name is a credential name, without regard to case. The raw name stays (`to%6Ben=[redacted]`) and nothing else in the string is re-encoded. Names are matched only up to `=`, so a parameter inside another one's value (`?next=/x?token=…`) is still found, while a cut value runs to the next `&` whatever it holds. Object and pair-list `query_string` keys are decoded the same way. Tests: encoded, mixed-case, nested, and malformed names in strings and query_string objects and pairs; a cross-check that whenever Hono's `c.req.query('token')` or URLSearchParams' `get('apiKey')` reads the value for a spelling of the name, the scrubbed URL holds none of it (and that names decoding to anything else are left alone); and a real request through the SDK's wrapRequestHandler with `?to%6Ben=` and an encoded upstream URL, whose sent error and transaction carry no token. The new tests fail against the previous scrubber. --- AGENTS.md | 4 +- src/sentry.request.test.ts | 211 ++++++++++++++++++++++++------------- src/sentry.test.ts | 96 +++++++++++++++++ src/sentry.ts | 49 +++++++-- 4 files changed, 279 insertions(+), 81 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3322326..d4df6bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -298,7 +298,9 @@ 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`. A new route that takes a credential in its +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). diff --git a/src/sentry.request.test.ts b/src/sentry.request.test.ts index 07d4dfe..cc791b5 100644 --- a/src/sentry.request.test.ts +++ b/src/sentry.request.test.ts @@ -22,103 +22,168 @@ const URL_WITH_TOKEN = `https://ww-proxy.test/notes-import/${IMPORT_ID}/events?t const UPSTREAM = `https://upstream.test/echo?token=${SECRET}` const REDACTED_UPSTREAM = 'https://upstream.test/echo?token=[redacted]' -describe('Sentry on a request with a subscribe token in its URL', () => { - it('sends the error and the transaction without the token', async () => { - 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 {} - }), - } - // What the fetch integration records for an outbound call. - const app = new Hono().get('/notes-import/:importId/events', async (c) => { - 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_WITH_TOKEN, { - headers: { referer: URL_WITH_TOKEN, accept: 'text/event-stream' }, - }) - const flushes: Promise[] = [] +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 +} - const response = await wrapRequestHandler( +/** + * 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( { - options, - request: request as never, - context: { - waitUntil: (promise: Promise) => flushes.push(promise), - } as unknown as ExecutionContext, + name: `GET ${upstream}`, + op: 'http.client', + attributes: { 'url.full': upstream }, }, - () => app.fetch(request) + async () => undefined ) - expect(response.status).toBe(200) - await Promise.all(flushes) - await Sentry.flush(2_000) + 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[] = [] - // 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 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 ) - const transaction = items.find((item) => item.type === 'transaction') - expect(error).toBeDefined() - expect(transaction).toBeDefined() + expect(token).toBe(SECRET) - const sent = envelopes.join('\n') 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({ + expect(error.request).toMatchObject({ url: redacted, query_string: 'token=[redacted]&lastEventId=3', headers: { referer: redacted }, }) - expect(transaction!.transaction).toBe( + expect(transaction.transaction).toBe( `GET /notes-import/${IMPORT_ID}/events` ) - expect( - (transaction!.contexts as { trace: { data: Record } }) - .trace.data['url.full'] - ).toBe(redacted) - expect(error!.breadcrumbs).toEqual([ + 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(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 2da417e..007753e 100644 --- a/src/sentry.test.ts +++ b/src/sentry.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Hono } from 'hono' import type { Breadcrumb, CloudflareOptions, @@ -78,6 +79,34 @@ describe('redactSecretQueryParameters', () => { '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) }) @@ -88,9 +117,67 @@ describe('redactSecretQueryParameters', () => { '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', () => { @@ -303,6 +390,15 @@ describe('Sentry scrubbers', () => { [['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) }, diff --git a/src/sentry.ts b/src/sentry.ts index 48b81b1..baeac4f 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -29,20 +29,55 @@ export function redactContactLinkPayloads(value: string): string { * 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']; -const SECRET_QUERY_PARAMETER = new RegExp( - `((?:^|[?&])(?:${SECRET_QUERY_PARAMETERS.join('|')})=)[^&#\\s"'<>]*`, - 'gi' -); +/** + * 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]'; -export function redactSecretQueryParameters(value: string): string { - return value.replace(SECRET_QUERY_PARAMETER, `$1${REDACTED_QUERY_VALUE}`); +/** + * 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(name.toLowerCase()); + 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, From d9a25b0fdf8e536bdc671cc4a49e66593045879b Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 02:55:04 -0400 Subject: [PATCH 25/25] fix(notes-import): end a synchronous model call before its slot can age out The index DO ages out synchronous POST /notes-import slots after 15 minutes (SYNC_SLOT_MAX_AGE_MS), since a disconnected client's `finally` may never release one. The synchronous model call had no deadline, so a call still running past 15 minutes lost its slot to the age limit and let another import start past the per-user cap. The call now runs under SYNC_IMPORT_DEADLINE_MS (10 minutes), defined in `cap.ts` beside the slot lifetime, with a comment tying the two together. At the deadline it aborts the model request through its abort signal and stops waiting at once, even if the call ignores the signal, then takes the route's existing model-error path: 502 `model_error`, reported to Sentry (as a ModelDeadlineError) and analytics, nothing charged, and the slot released in the `finally` as before. The streaming run has no deadline of its own to reuse, and its slot never ages out. The OpenAPI description, whose minutes come from the constant, and the design doc say so. Tests: the deadline stays at least five minutes short of the slot lifetime; with a fake model that never resolves and ignores its signal in every slot of the cap, the requests run and hold the cap (a third gets 429) until 1 ms before the deadline, and at it each answers 502 model_error with its signal aborted, frees its slot itself (not by age), charges nothing, and reports outcome model_error; the next import runs. The route test fails against the previous route. --- .../notes-import-streaming-durable-objects.md | 4 +- src/notesImport/cap.test.ts | 19 ++++- src/notesImport/cap.ts | 18 ++++- src/notesImport/openapi.ts | 13 ++- src/notesImport/route.bearer.test.ts | 79 ++++++++++++++++++- src/notesImport/route.ts | 50 +++++++++++- 6 files changed, 170 insertions(+), 13 deletions(-) diff --git a/docs/notes-import-streaming-durable-objects.md b/docs/notes-import-streaming-durable-objects.md index 0defab5..9f1787a 100644 --- a/docs/notes-import-streaming-durable-objects.md +++ b/docs/notes-import-streaming-durable-objects.md @@ -152,7 +152,9 @@ heartbeat). 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. + 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/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 325ad52..3e189be 100644 --- a/src/notesImport/cap.ts +++ b/src/notesImport/cap.ts @@ -25,11 +25,25 @@ export const SYNC_SLOT_PREFIX = 'legacy:' * 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. Far beyond any real model run (about a minute); past it, a run still - * going at worst lets one more start. + * 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/openapi.ts b/src/notesImport/openapi.ts index e827966..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 @@ -421,7 +425,7 @@ export const legacyImportDocs = describeRoute({ 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. 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, and each request holds a slot of its own while it runs, identical ones included (they still charge once).`, +${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], @@ -437,9 +441,10 @@ ${GATE_DESCRIPTION} The same concurrency cap as kickoff applies, and each reques 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', - ]), + 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'] diff --git a/src/notesImport/route.bearer.test.ts b/src/notesImport/route.bearer.test.ts index 5596127..b548445 100644 --- a/src/notesImport/route.bearer.test.ts +++ b/src/notesImport/route.bearer.test.ts @@ -15,11 +15,11 @@ import { accountsDb, setAccountStatus } from '../accounts/store' import { accessTokenKeys, signAccessToken } from '../accounts/tokens' import { analyticsDistinctId } from '../analytics' import { app } from '../app' -import { SYNC_SLOT_MAX_AGE_MS } from './cap' +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 { importOwnerKey } from './route' +import { ModelDeadlineError, importOwnerKey } from './route' /** * Notes Import on account sessions (witness-work ADR 0027): bearer requests @@ -633,6 +633,81 @@ describe('synchronous imports and the concurrency cap', () => { { 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', () => { diff --git a/src/notesImport/route.ts b/src/notesImport/route.ts index e0701a3..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, @@ -1111,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 @@ -1141,7 +1181,9 @@ export async function handleNotesImportRequest(ctx: AppContext) { // 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). + // 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 @@ -1172,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, @@ -1180,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(