diff --git a/.agents/skills/verify-ww-api/SKILL.md b/.agents/skills/verify-ww-api/SKILL.md index 98685dd..607af5a 100644 --- a/.agents/skills/verify-ww-api/SKILL.md +++ b/.agents/skills/verify-ww-api/SKILL.md @@ -54,7 +54,7 @@ node scripts/verify/dev.mjs kv get app-store-ratings:summary node scripts/verify/dev.mjs kv delete buddies:enabled ``` -- For the isolated local worker, send a unique `cf-connecting-ip` per request (`198.18.x.y` or similar). Never send synthetic caller IPs to a deployed Worker: Cloudflare rejects them before the request reaches the API. Without one, every request shares the same per-IP key, and you hit the local rate limiters: 60/min per family (`/geocode` + `/autocomplete`, `/notes-import*`, `/route-planning/*`, and `/admin/*` each count separately), and the Buddies per-caller tiers (IPv6 grouped by /64): 120/min for `invite/fetch` + `invite/claim`, 60/min for `inbox/register`, 600/min for `inbox/sync` + `inbox/live`, 60/min for `blob/put`, 600/min for `blob/get`, 600/min for other signed ops. The e2e helpers and the fuzzer already do this. +- For the isolated local worker, send a unique `cf-connecting-ip` per request (`198.18.x.y` or similar). Never send synthetic caller IPs to a deployed Worker: Cloudflare rejects them before the request reaches the API. Without one, every request shares the same per-IP key, and you hit the local rate limiters: 60/min per family (`/geocode` + `/autocomplete`, `/notes-import*`, `/attestation/*`, `/route-planning/*`, and `/admin/*` each count separately), and the Buddies per-caller tiers (IPv6 grouped by /64): 120/min for `invite/fetch` + `invite/claim`, 60/min for `inbox/register`, 600/min for `inbox/sync` + `inbox/live`, 60/min for `blob/put`, 600/min for `blob/get`, 600/min for other signed ops. The e2e helpers and the fuzzer already do this. - Signed Buddies ops need Ed25519 envelopes. Use `RelayOwner` and `RelayWriter` in `src/test/e2e.ts` (built on `src/test/buddiesClient.ts`) rather than hand-rolling curl. Photo blobs: `photoBlob()` makes sealed-looking bytes with their id and token, `RelayOwner.putBlob` and `getBlob` send them (raw bytes, envelope in headers). - KV edits take effect on the next request (no 60 s edge cache locally). diff --git a/.agents/skills/verify-ww-api/features/README.md b/.agents/skills/verify-ww-api/features/README.md index 4d9e506..05d4671 100644 --- a/.agents/skills/verify-ww-api/features/README.md +++ b/.agents/skills/verify-ww-api/features/README.md @@ -40,9 +40,9 @@ Keep implementation details out of the map. Name only user paths, stable handles ## Features - [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 dev-bypass verify probe, the App Attest challenge, kickoff's auth gate, and the opt-in paid kickoff. +- [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. -- [Universal link pages](./universal-link-pages.md) covers the AASA, the `/c` and `/c/:payload` contact pages, and the `/b` invite page. +- [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. 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/buddies-relay.md b/.agents/skills/verify-ww-api/features/buddies-relay.md index 187b49f..c4bb227 100644 --- a/.agents/skills/verify-ww-api/features/buddies-relay.md +++ b/.agents/skills/verify-ww-api/features/buddies-relay.md @@ -21,7 +21,7 @@ Buddies lets two publishers share upcoming Plans through a blind relay. Each per ## How to get to it (user POV) -- In the app: Buddies → Invite a buddy creates an invite and shares `https://ww-proxy.leviwilkerson.com/b#1`. +- In the app: Buddies → Invite a buddy creates an invite and shares `https://ww-proxy.leviwilkerson.com/b#1` (`/buddies/invite#1` once the app generates the readable path). - The buddy taps the link. The app opens, fetches and claims the invite, and the inviter confirms. - Opening the Buddies tab or the calendar syncs the inbox, and editing Plans writes a card. Removing a buddy runs leave. Deleting Buddies data deletes the inbox. diff --git a/.agents/skills/verify-ww-api/features/notes-import.md b/.agents/skills/verify-ww-api/features/notes-import.md index be89390..f2d05af 100644 --- a/.agents/skills/verify-ww-api/features/notes-import.md +++ b/.agents/skills/verify-ww-api/features/notes-import.md @@ -4,18 +4,19 @@ Notes Import turns a publisher's free-form notes into contacts, visits, and time ## Sub-features -- `verify-v1` accepts `{}` with a valid `x-ww-dev-bypass` and returns `{"ok":true}`. +- `verify-v1` (`POST /attestation/v1/verify`) accepts `{}` with a valid `x-ww-dev-bypass` and returns `{"ok":true}`. - `verify-v2` echoes `operationId` for a v2 probe whose `requestHash` equals `contentHash`. - `verify-v2-mismatch` returns 400 when the two hashes differ. - `verify-wrong-bypass` treats a wrong token as unattested and returns 400 `Missing uuid, …`. -- `challenge` issues an App Attest challenge. +- `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. - `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) -- In the app: Tools → Notes Import, paste notes, and import. The app calls `challenge`, `attest`, `kickoff`, `events`, then `result`. -- The app's dev-tools diagnostics screen calls `POST /notes-import/verify` to prove the attestation path. +- In the app: Tools → Notes Import, paste notes, and import. The app calls the attestation `challenge` and `register` (`attest` on the old paths), then `kickoff`, `events`, and `result`. +- The app's dev-tools diagnostics screen calls `POST /attestation/v1/verify` (older builds: `/notes-import/verify`) to prove the attestation path. - Simulators and emulators can't attest, so dev builds on both platforms send the `x-ww-dev-bypass` header to the dev worker. ## Driving it with verify-ww-api @@ -25,10 +26,11 @@ Preconditions: - `doctor` passes, including `ok dev bypass configured (value hidden)`. - `URL` and `BYPASS` are exported (see README). -- **v1 probe.** Run `curl -s -X POST $URL/notes-import/verify -H "x-ww-dev-bypass: $BYPASS" -H "cf-connecting-ip: 198.18.2.1" -d '{}'`. The body is `{"ok":true}`. -- **v2 probe.** Run `H=$(printf verify | shasum -a 256 | cut -d' ' -f1); curl -s -X POST $URL/notes-import/verify -H "x-ww-dev-bypass: $BYPASS" -H "cf-connecting-ip: 198.18.2.2" -d "{\"protocolVersion\":2,\"operation\":\"assert\",\"operationId\":\"op_verify01\",\"purpose\":\"notes-import-verify\",\"contentHash\":\"$H\",\"requestHash\":\"$H\"}"`. The body is `{"ok":true,"protocolVersion":2,"operationId":"op_verify01"}`. +- **v1 probe.** Run `curl -s -X POST $URL/attestation/v1/verify -H "x-ww-dev-bypass: $BYPASS" -H "cf-connecting-ip: 198.18.2.1" -d '{}'`. The body is `{"ok":true}`. +- **v2 probe.** Run `H=$(printf verify | shasum -a 256 | cut -d' ' -f1); curl -s -X POST $URL/attestation/v1/verify -H "x-ww-dev-bypass: $BYPASS" -H "cf-connecting-ip: 198.18.2.2" -d "{\"protocolVersion\":2,\"operation\":\"assert\",\"operationId\":\"op_verify01\",\"purpose\":\"attestation-verify\",\"contentHash\":\"$H\",\"requestHash\":\"$H\"}"`. The body is `{"ok":true,"protocolVersion":2,"operationId":"op_verify01"}`. - **Wrong token.** Repeat the v1 probe with `-H "x-ww-dev-bypass: nope"`. You get 400 with `Missing uuid, keyId, challenge, assertion, or contentHash`. -- **Challenge.** Run `curl -s -X POST $URL/notes-import/challenge -H "cf-connecting-ip: 198.18.2.3" -d '{}'`. The body includes a `challenge` string. +- **Challenge.** Run `curl -s -X POST $URL/attestation/v1/challenge -H "cf-connecting-ip: 198.18.2.3" -d '{}'`. The body includes a `challenge` string. +- **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=""`. - **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`. @@ -36,7 +38,7 @@ Preconditions: ## Gotchas - The bypass works only with `APP_ATTEST_ENVIRONMENT=development` (`--env dev`). Plain `pnpm dev` uses prod vars, and Notes Import routes then return 500 when a bypass token is present. -- `/notes-import*` is rate limited at 60/min per `cf-connecting-ip` (its own bucket, apart from place search). Reusing one IP across a long run produces 429s (`rate_limited`, `Retry-After: 60`) that look like bugs. +- `/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. - 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/.agents/skills/verify-ww-api/features/universal-link-pages.md b/.agents/skills/verify-ww-api/features/universal-link-pages.md index 4410c2a..732d7bc 100644 --- a/.agents/skills/verify-ww-api/features/universal-link-pages.md +++ b/.agents/skills/verify-ww-api/features/universal-link-pages.md @@ -1,19 +1,19 @@ # Universal link pages -Shared contacts (`/c#`) and buddy invites (`/b#1`) are universal links. On iOS, the AASA file routes them into the app. Without the app, the worker serves localized fallback pages that build the "Open app" link client-side, so the payload never reaches the server or link previews. +Shared contacts (`/contacts#`), buddy invites (`/buddies/invite#1`), and Set Up Another Device codes (`/accounts/handoff#1…`) are universal links. Older app versions shared the short `/c`, `/b`, and `/h`, which serve the same pages for good. On iOS, the AASA file routes them into the app. Without the app, the worker serves localized fallback pages that build the "Open app" link client-side, so the payload never reaches the server or link previews. ## Sub-features -- `aasa` returns JSON with three app ids (`.com.leviwilkerson.jwtime`, `…jwtimebeta`, `…jwtimedev`) and the components `/c#?*`, `/c/*`, and `/b#1?*`. -- `contact-page` serves the `/c` HTML with a hidden `#open-app` link and the `witnesswork://import-contact/` script. -- `contact-locale` localizes `/c?lang=es` and `Accept-Language` (`Content-Language` header, ``). +- `aasa` returns JSON with three app ids (`.com.leviwilkerson.jwtime`, `…jwtimebeta`, `…jwtimedev`) and seven components: `/contacts#?*`, `/buddies/invite#1?*`, `/accounts/handoff#1?*`, then the short `/c#?*`, `/c/*`, `/b#1?*`, `/h#1?*`. +- `contact-page` serves the `/contacts` (and `/c`) HTML with a hidden `#open-app` link, the `witnesswork://import-contact/` script, and `og:url` set to the path it was served on. +- `contact-locale` localizes `/contacts?lang=es` and `Accept-Language` (`Content-Language` header, ``). - `contact-legacy` makes `/c/` render an `href="witnesswork://import-contact/"` that never appears in a meta tag, and escapes non-base64url payloads. -- `invite-page` serves `/b` with a strict CSP (`default-src 'none'`), `X-Robots-Tag: noindex, nofollow`, and `Referrer-Policy: no-referrer`. +- `invite-page` serves `/buddies/invite` (and `/b`) with a strict CSP (`default-src 'none'`), `X-Robots-Tag: noindex, nofollow`, and `Referrer-Policy: no-referrer`. ## How to get to it (user POV) -- Someone taps a shared contact link in Messages. With the app installed, iOS opens it. Without the app, the browser shows the `/c` page. -- A buddy invite link opens the app, or the `/b` page in a browser. +- Someone taps a shared contact link in Messages. With the app installed, iOS opens it. Without the app, the browser shows the `/contacts` page (`/c` for older links). +- A buddy invite link opens the app, or the `/buddies/invite` page (`/b` for older links) in a browser. - iOS downloads `/.well-known/apple-app-site-association` when the app is installed or updated. ## Driving it with verify-ww-api @@ -22,13 +22,13 @@ Preconditions: - `doctor` passes and `URL` is exported. -- **AASA.** Run `curl -si $URL/.well-known/apple-app-site-association`. You get 200 with `content-type: application/json`, the app ids prefixed `VERIFY0000.` (or the real team id when passed), and the three components. -- **Contact page.** Run `curl -s $URL/c | grep -c 'id="open-app"'`. It prints `1`. -- **Locale.** Run `curl -si "$URL/c?lang=es" | grep -i content-language`. The header starts with `es`. +- **AASA.** Run `curl -si $URL/.well-known/apple-app-site-association`. You get 200 with `content-type: application/json`, the app ids prefixed `VERIFY0000.` (or the real team id when passed), and the seven components. +- **Contact page.** Run `curl -s $URL/contacts | grep -c 'id="open-app"'`, then the same for `$URL/c`. Each prints `1`. +- **Locale.** Run `curl -si "$URL/contacts?lang=es" | grep -i content-language`. The header starts with `es`. - **Legacy link.** Run `curl -s $URL/c/abc_DEF-123 | grep -o 'witnesswork://import-contact/abc_DEF-123'`. The deep link is printed. - **Hostile payload.** Run `curl -s "$URL/c/%22%3E%3Cscript%3Ex%3C%2Fscript%3E" | grep -c ''`. It prints `0`. -- **Invite page.** Run `curl -si $URL/b`. You get 200 with a `content-security-policy` that starts `default-src 'none'` and the title `A WitnessWork buddy invite`. -- **Proof.** Run `pnpm test:e2e src/e2e/public.e2e.test.ts -t "universal links"`. All four tests pass, with the transcript in `.verify/artifacts/e2e-public-*.json`. +- **Invite page.** Run `curl -si $URL/buddies/invite` (and `$URL/b`). You get 200 with a `content-security-policy` that starts `default-src 'none'` and the title `A WitnessWork buddy invite`. +- **Proof.** Run `pnpm test:e2e src/e2e/public.e2e.test.ts -t "universal links"`. All tests pass, with the transcript in `.verify/artifacts/e2e-public-*.json`. ## Gotchas diff --git a/AGENTS.md b/AGENTS.md index cabcae7..22ce714 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,9 +67,10 @@ them in sync with the top-level prod config when adding new bindings. Android authenticates Notes Import with Google Play Integrity standard requests instead of App Attest (witness-work ADR 0017; code in `src/playIntegrity/`). A -request opts in with `attestationProvider: "play-integrity"` on the existing -`/notes-import/challenge`, `/kickoff`, and `/verify` routes; bodies without it -take the unchanged App Attest paths. Challenges live in the +request opts in with `attestationProvider: "play-integrity"` on +`/attestation/v1/challenge`, `/attestation/v1/verify`, and +`/notes-import/kickoff` (and `/transcribe`); bodies without it take the +unchanged App Attest paths. Challenges live in the `PLAY_INTEGRITY_CHALLENGES` Durable Object (migration `v4`), never in `AppAttestIdentity`. The worker decodes each token through Google's `decodeIntegrityToken` with a service account, then requires the request @@ -103,6 +104,54 @@ stable `reason`: `device_integrity_failed`, `app_not_recognized`, quota; reported to Sentry), or a challenge reason. The default quota is 10,000 decodes per day; request more through Google's quota form before it matters. +## Attestation + +Device attestation is API-wide, under `/attestation/v1/` (code in +`src/attestation/`): App Attest on iOS, Play Integrity on Android. + +| Route | What | +| --- | --- | +| `POST /attestation/v1/challenge` | One-time challenge (App Attest v1/v2, Play Integrity) | +| `POST /attestation/v1/register` | Bind an App Attest key, or enroll a v2 recovery token | +| `POST /attestation/v1/verify` | Attested no-op for the app's developer tools | +| `GET /attestation/v1/status` | Accepted providers (Play Integrity once configured) | + +- **Purposes**: every App Attest v2 / Play Integrity assertion signs a + `purpose`, and each protected route accepts only its own. The registry is + `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. +- **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. +- **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 + `Link: ; rel="successor-version"`, are documented as + deprecated, still count in the `notes-import` rate-limit bucket, and log + `deprecated path: -> `. Remove them only after the minimum app + version uses the new paths and those Workers Logs lines stop. +- New paths count in their own `attestation` per-IP bucket and keep 100% + Sentry trace sampling (status excepted), as they had under Notes Import. + +## Link pages + +Universal links (and their no-app fallback pages) use readable paths under +their feature: `/contacts#`, `/buddies/invite#1…`, +`/accounts/handoff#1…`. Older app versions shared the short `/c#…` (and +`/c/`), `/b#1…`, and `/h#1…`. Those links live on in people's +messages and screenshots, so **the short paths stay routed and in the AASA for +good**; a redirect wouldn't do, because iOS decides from the AASA whether a +tapped URL opens the app. Each page's `og:url` is the path it was served on +(never a payload). + +The app must parse the readable paths (and Android's intent filters must list +them) for at least one release before it starts generating them, or people on +older builds get the web page instead of the app. + ## Deploying to dev ```bash @@ -204,10 +253,12 @@ its existing sampling configuration. `POST /buddies/v1/{op}` is the Buddies relay, built to the wire contract in [`docs/buddies-protocol.md`](docs/buddies-protocol.md) (the witness-work repo -holds the canonical copy). Code lives in `src/buddies/`. `GET /b` is the -no-app fallback page for invite links (App Store and Google Play buttons), the -AASA matches `/b#1…`, and `public/.well-known/assetlinks.json` verifies the -whole host, `/b` included, for Android App Links. Clients are iOS and Android +holds the canonical copy). Code lives in `src/buddies/`. +`GET /buddies/invite` is the no-app fallback page for invite links (App Store +and Google Play buttons), and the short `/b` serves it for invites already +sent. The AASA matches `/buddies/invite#1…` and `/b#1…`, and +`public/.well-known/assetlinks.json` verifies the whole host for Android App +Links. Clients are iOS and Android (witness-work ADR 0020). - **Bindings** (repeated under `[env.dev]`): `BUDDY_INBOX` (`BuddyInbox`, one @@ -333,8 +384,8 @@ whole host, `/b` included, for Android App Links. Clients are iOS and Android - **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 Notes Import purpose and have - `blob/put` require a recent one, rather than attesting each upload. The protocol's durable push outbox (retries for + 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 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. @@ -455,11 +506,12 @@ Shared helpers for the self-signed ops are in `src/accounts/signed.ts`. counts against the Buddies photo-upload edge tier, the rest the read tier. `delete` without `slotId` deletes every slot. Suspended accounts can still back up. Without the bucket or `ACCOUNTS_DB`, every op is 503 `disabled`. -- **`GET /h`** is the page a camera opens for a handoff QR code - (`/h#1<…>`): install or open WitnessWork on the new phone and scan from - inside the app. Localized like `/c` (`?lang=`, then `Accept-Language`; - strings in `src/accounts/handoffPageLocales.ts`), no script. The AASA - matches `/h#1…`; `assetlinks.json` already covers the whole host. +- **`GET /accounts/handoff`** (and the short `/h`) is the page a camera + opens for a handoff QR code (`/accounts/handoff#1<…>`): install or open + WitnessWork on the new phone and scan from inside the app. Localized like + `/contacts` (`?lang=`, then `Accept-Language`; strings in + `src/accounts/handoffPageLocales.ts`), no script. The AASA matches both + paths with `#1…`; `assetlinks.json` already covers the whole host. - **Privacy**: handoff and backup log only the op name. Never log or report bodies, headers, keys, tokens, account ids, channel ids, or slot ids. @@ -574,8 +626,8 @@ need the token). - `hono-openapi` builds the document from the routes themselves: each route carries a `describeRoute(...)` middleware, kept in an `openapi.ts` beside its - handlers (`src/notesImport/`, `src/buddies/`, `src/accounts/`, - `src/routePlanning/`; routes registered in `src/app.ts` use + handlers (`src/attestation/`, `src/notesImport/`, `src/buddies/`, + `src/accounts/`, `src/routePlanning/`; routes registered in `src/app.ts` use `src/openapi/coreDocs.ts`). Shared helpers and the error envelope are in `src/openapi/schemas.ts`; tags, servers, and the Scalar config in `src/openapi/route.ts`. diff --git a/README.md b/README.md index b8d968b..f2d3406 100644 --- a/README.md +++ b/README.md @@ -201,24 +201,26 @@ curl "https://your-worker.workers.dev/geocode?q=1600+Amphitheatre+Parkway" ### `/.well-known/apple-app-site-association` -Serves the AASA JSON used by iOS to validate universal links for contact -sharing. Lists the prod (`com.leviwilkerson.jwtime`), beta -(`com.leviwilkerson.jwtimebeta`), and dev (`com.leviwilkerson.jwtimedev`) bundle -IDs, prefixed with `APPLE_TEAM_ID`. +Serves the AASA JSON used by iOS to validate universal links. Lists the prod +(`com.leviwilkerson.jwtime`), beta (`com.leviwilkerson.jwtimebeta`), and dev +(`com.leviwilkerson.jwtimedev`) bundle IDs, prefixed with `APPLE_TEAM_ID`. -Matches `/c` with a non-empty fragment (`/c#`) and, for links shared -by older app versions, any URL under `/c/*`. +Matches `/contacts#`, `/buddies/invite#1…`, and +`/accounts/handoff#1…`, plus the short paths older app versions put in links +(`/c#`, any URL under `/c/*`, `/b#1…`, `/h#1…`). Those links are +already in people's messages, so the short paths stay listed and served for +good; dropping one would open its links in Safari instead of the app. -### `/c` +### `/contacts` (and the short `/c`) Universal-link landing page for shared WitnessWork contacts -(`/c#`). iOS hands the link off to the WitnessWork app when +(`/contacts#`). iOS hands the link off to the WitnessWork app when installed; otherwise this endpoint serves a fallback HTML page with an App -Store CTA. +Store CTA. `/c` serves the same page for links older app versions shared. The page, accessibility labels, and Open Graph/Twitter preview strings support the same 18 locales as `witness-work/src/lib/locales.ts`. Pass `?lang=pt-pt` -before the fragment (`/c?lang=pt-pt#`) to choose a language explicitly; +before the fragment (`/contacts?lang=pt-pt#`) to choose a language explicitly; otherwise the worker uses `Accept-Language` preferences, including quality weights, and defaults to English (`en-us`). Unsupported or malformed `lang` values fall back to browser preferences. Regional variants fall back to the @@ -226,7 +228,8 @@ same language; Portuguese defaults to `pt-br`, and Chinese uses Simplified unless Traditional script or a Taiwan/Hong Kong/Macau region is requested. Responses include `Content-Language` and `Vary: Accept-Language`. Preview crawlers without a language preference receive English unless `lang` is set. -The canonical `og:url` remains the bare `/c`, without language or contact data. +The `og:url` is the bare page path (`/contacts` or `/c`), without language or +contact data. The same localization rules apply to legacy `/c/:payload` links. The payload is an opaque gzip + base64url–encoded contact export produced by @@ -241,6 +244,36 @@ same page with a server-rendered "Open app" link; the payload stays out of `og:url` and the other meta tags, and Sentry events redact it to `/c/[redacted]`. The worker never decodes it. +### Attestation (`/attestation/v1/*`) + +Device attestation for the whole API: App Attest on iOS, Play Integrity on +Android. Code in `src/attestation/`. + +- **`POST /attestation/v1/challenge`** issues a one-time challenge: App Attest + v1 (empty body), App Attest v2 (`bind`, `enroll`, or `assert`), or Play + Integrity. v2 and Play Integrity challenges are bound to one protected + request: its `purpose`, ids, and hashes. +- **`POST /attestation/v1/register`** binds an App Attest key to the install + id, or enrolls a v2 recovery token. Play Integrity has no registration step. +- **`POST /attestation/v1/verify`** is an attested no-op for the app's + developer tools (purpose `attestation-verify`; the older + `notes-import-verify` is accepted too). +- **`GET /attestation/v1/status`** lists the accepted providers (App Attest + always; Play Integrity once configured). + +Each protected route verifies an assertion signed for its own `purpose` +(registry: `src/attestation/purposes.ts`). New routes call +`requireAttestation` (`src/attestation/http.ts`), which takes App Attest v2 or +Play Integrity. Purposes are signed by installed apps, so a shipped one is +never renamed. + +The old paths `POST /notes-import/challenge`, `/notes-import/attest`, and +`/notes-import/verify` run the same handlers for deployed apps, adding +`Deprecation: @1792022400` (2026-10-15) and +`Link: ; rel="successor-version"`. Each hit logs +`deprecated path: -> ` (route names only). Remove them once the +minimum app version uses the new paths and those logs go quiet. + ### Notes Import (`/notes-import*`) LLM-backed parsing of free-form ministry notes into structured WitnessWork @@ -269,10 +302,11 @@ prevent fuzzy-name merges, and unnamed people identified by address retain their visits. Model logs include only routing, finish reason, and token/cache counts. -Core routes (all rate-limited): +Core routes (all rate-limited). Attestation (challenge, App Attest key +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/challenge`** → `{ challenge }`. One-time App Attest nonce. -- **`POST /notes-import/attest`** performs the App Attest handshake. - **`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. @@ -406,8 +440,8 @@ Every JSON route answers errors with one envelope (`src/errors.ts`): ``` - `code` is always the stable, machine-readable code; read it first. -- `error` is the same code everywhere except Notes Import and route planning, - where it stays a human-readable message for shipped builds. +- `error` is the same code everywhere except attestation, Notes Import, and + route planning, where it stays a human-readable message for shipped builds. - Every 429 and 503 sends `Retry-After` (seconds) and repeats it as `retryAfter`, except quotas no wait can free (Notes Import's `limit_reached` and `refinement_limit`, Buddies' `limit`). @@ -417,14 +451,16 @@ Every JSON route answers errors with one envelope (`src/errors.ts`): keeps its fields and gains the envelope (`bad_request`, `not_found`, `rate_limited`, `upstream_error`). HERE unreachable or answering non-JSON is 502 `upstream_error`. -- The HTML pages (`/c`, `/c/:payload`, `/b`) are for browsers and always +- The HTML pages (`/contacts`, `/buddies/invite`, `/accounts/handoff`, and + the short `/c`, `/c/:payload`, `/b`, `/h`) are for browsers and always answer 200. - `POST /buddies/v1/blob/get` answers a Buddies photo blob's raw bytes (`application/octet-stream`) on success; its errors use the envelope. Per-IP limits (`RATE_LIMITER`, 60/min) count each route family in its own bucket: `places` (`/geocode`, `/autocomplete`), `notes-import`, -`route-planning`, `admin` (`src/rateLimit.ts`). Refusals are 429 +`attestation`, `route-planning`, `admin` (`src/rateLimit.ts`). The old +`/notes-import/*` attestation paths still count in `notes-import`. Refusals are 429 `rate_limited` with `Retry-After: 60`. Unexpected errors are 500 `server_error` and are reported to Sentry. diff --git a/docs/buddies-protocol.md b/docs/buddies-protocol.md index 61bf6f0..1fb4907 100644 --- a/docs/buddies-protocol.md +++ b/docs/buddies-protocol.md @@ -333,8 +333,8 @@ The root seed is 32 random bytes. Everything below derives from it, so it is the ### Invite link and secrets -- Secret `s`: 16 random bytes. Link: `https://ww-proxy.leviwilkerson.com/b#1{b64u(s)}` — the leading `1` is the link version. Nothing identifying is in the path. -- The link opens the app through iOS universal links (the AASA's `/b` component) and Android App Links (an auto-verified `/b` intent filter; the host's `assetlinks.json` covers every path). Both hand the app the whole URL, fragment included. Without the app, `GET /b` is a static page with App Store and Google Play buttons and a Copy button; the User installs, then taps the link again or pastes it in Buddies. +- Secret `s`: 16 random bytes. Link: `https://ww-proxy.leviwilkerson.com/buddies/invite#1{b64u(s)}` — the leading `1` is the link version. Nothing identifying is in the path. The short `https://ww-proxy.leviwilkerson.com/b#1{b64u(s)}` is the same link: clients accept both forever (older apps shared it), and keep generating `/b` until every app version they support parses `/buddies/invite`. +- The link opens the app through iOS universal links (the AASA's `/buddies/invite` and `/b` components) and Android App Links (auto-verified intent filters for both paths; the host's `assetlinks.json` covers every path). Both hand the app the whole URL, fragment included. Without the app, `GET /buddies/invite` (or `/b`) is a static page with App Store and Google Play buttons and a Copy button; the User installs, then taps the link again or pastes it in Buddies. - `inviteId = b64u(HKDF(s, "", "ww-buddies/v1/invite/id", 16))` - `inviteKey = HKDF(s, "", "ww-buddies/v1/invite/key", 32)` - `claimSecret = HKDF(s, "", "ww-buddies/v1/invite/claim", 32)`; `claimVerifier = b64u(SHA-256(claimSecret))` diff --git a/scripts/verify/dev.mjs b/scripts/verify/dev.mjs index 4d4d0e5..4d4b2d8 100755 --- a/scripts/verify/dev.mjs +++ b/scripts/verify/dev.mjs @@ -470,7 +470,7 @@ async function doctor() { } try { - const verify = await fetchJson(`${state.url}/notes-import/verify`, { + const verify = await fetchJson(`${state.url}/attestation/v1/verify`, { method: 'POST', headers: { 'content-type': 'application/json', diff --git a/src/accounts/handoffPage.test.ts b/src/accounts/handoffPage.test.ts index d8a0285..e56fa93 100644 --- a/src/accounts/handoffPage.test.ts +++ b/src/accounts/handoffPage.test.ts @@ -7,12 +7,27 @@ import { handoffPageTranslations } from './handoffPageLocales' const app = new Hono<{ Bindings: Environment }>() app.get('/h', handleHandoffPage) +app.get('/accounts/handoff', handleHandoffPage) const env = { APPLE_TEAM_ID: 'TEAMID1234' } as Environment const page = (path = '/h', headers: Record = {}) => app.request(path, { headers }, env) +describe('GET /accounts/handoff', () => { + it('serves the /h page under its own og:url', async () => { + const short = await (await page('/h')).text() + const readable = await (await page('/accounts/handoff')).text() + expect(readable).toContain( + 'property="og:url" content="https://ww-proxy.leviwilkerson.com/accounts/handoff"' + ) + expect(short).toContain( + 'property="og:url" content="https://ww-proxy.leviwilkerson.com/h"' + ) + expect(readable.replace('/accounts/handoff"', '/h"')).toBe(short) + }) +}) + describe('GET /h', () => { it('tells someone to scan the code from inside the app, with store links', async () => { const response = await page() diff --git a/src/accounts/handoffPage.ts b/src/accounts/handoffPage.ts index 39d9533..65f682e 100644 --- a/src/accounts/handoffPage.ts +++ b/src/accounts/handoffPage.ts @@ -7,14 +7,15 @@ import { import { handoffPageTranslations } from './handoffPageLocales' /** - * `GET /h`: the page a phone's camera opens for a Set Up Another Device QR - * code (`https://ww-proxy.leviwilkerson.com/h#1`) + * `GET /accounts/handoff` (and the short `/h`): the page a phone's camera + * opens for a Set Up Another Device QR code + * (`https://ww-proxy.leviwilkerson.com/accounts/handoff#1`) * when WitnessWork doesn't catch the link. It says to install or open the app * on the new phone and scan the code from inside it. * * The handoff secret lives in the fragment, which browsers never send. The * page reads nothing from the request but the language (`?lang=` or - * `Accept-Language`, as on `/c`), runs no script, and its CSP lets it load + * `Accept-Language`, as on `/contacts`), runs no script, and its CSP lets it load * nothing but its own icon, so it can't send the fragment anywhere. */ @@ -42,7 +43,13 @@ const escapeHtml = (value: string): string => .replace(/"/g, '"') .replace(/'/g, ''') -const renderPage = (locale: ContactLinkLocale): string => { +const HANDOFF_PATH = '/accounts/handoff' +const SHORT_HANDOFF_PATH = '/h' + +const renderPage = ( + locale: ContactLinkLocale, + path: typeof HANDOFF_PATH | typeof SHORT_HANDOFF_PATH +): string => { const messages = handoffPageTranslations[locale] const title = escapeHtml(messages.title) const description = escapeHtml(messages.description) @@ -63,7 +70,7 @@ const renderPage = (locale: ContactLinkLocale): string => { - + @@ -130,7 +137,9 @@ export const handleHandoffPage = (context: AppContext): Response => { context.req.query('lang'), context.req.header('Accept-Language') ) - return context.html(renderPage(locale), 200, { + const path = + context.req.path === SHORT_HANDOFF_PATH ? SHORT_HANDOFF_PATH : HANDOFF_PATH + return context.html(renderPage(locale, path), 200, { 'Cache-Control': 'public, max-age=3600', 'Content-Language': locale, Vary: 'Accept-Language', diff --git a/src/app.ts b/src/app.ts index 73d8c3f..80e304b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,9 +14,6 @@ import { handleLegacyContactLinkRequest, } from "./contactLink"; import { - handleChallengeRequest, - handleAttestRequest, - handleNotesImportVerifyRequest, handleNotesImportRequest, handleNotesImportStatusRequest, handleNotesImportKickoffRequest, @@ -28,9 +25,7 @@ import { handleNotesImportAdminResetRequest, } from "./notesImport/route"; import { - attestDocs as notesImportAttestDocs, cancelDocs as notesImportCancelDocs, - challengeDocs as notesImportChallengeDocs, destroyDocs as notesImportDestroyDocs, eventsDocs as notesImportEventsDocs, kickoffDocs as notesImportKickoffDocs, @@ -38,8 +33,24 @@ import { resultDocs as notesImportResultDocs, statusDocs as notesImportStatusDocs, transcribeDocs as notesImportTranscribeDocs, - verifyDocs as notesImportVerifyDocs, } from "./notesImport/openapi"; +import { + ATTESTATION_BASE_PATH, + deprecatedPath, + handleAttestationChallengeRequest, + handleAttestationRegisterRequest, + handleAttestationStatusRequest, + handleAttestationVerifyRequest, +} from "./attestation/route"; +import { + challengeDocs as attestationChallengeDocs, + legacyAttestDocs, + legacyChallengeDocs, + legacyVerifyDocs, + registerDocs as attestationRegisterDocs, + statusDocs as attestationStatusDocs, + verifyDocs as attestationVerifyDocs, +} from "./attestation/openapi"; import { handleAppStoreRatingsRequest } from "./appStoreRatings/route"; import { Sentry } from "./sentry"; import { createBuddiesRoutes } from "./buddies/route"; @@ -59,6 +70,9 @@ import { geocodeDocs, healthDocs, legacyContactLinkDocs, + shortBuddyInvitePageDocs, + shortContactLinkDocs, + shortHandoffPageDocs, notesImportAdminResetDocs, } from "./openapi/coreDocs"; @@ -109,6 +123,7 @@ app.use("/geocode", rateLimit("places")); app.use("/autocomplete", rateLimit("places")); app.use("/notes-import", rateLimit("notes-import")); app.use("/notes-import/*", rateLimit("notes-import")); +app.use("/attestation/*", rateLimit("attestation")); app.use("/admin/*", rateLimit("admin")); app.use("/route-planning/*", rateLimit("route-planning")); @@ -121,23 +136,55 @@ app.get( appStoreRatingsDocs, handleAppStoreRatingsRequest ); -// Notes Import: availability probe, App Attest handshake + the metered call. +// Device attestation for the whole API (App Attest on iOS, Play Integrity on +// Android): challenges, App Attest key registration, and a diagnostic verify. +// Each protected route checks its own assertion (src/attestation/http.ts). +const attestationPath = (op: string) => `${ATTESTATION_BASE_PATH}/${op}`; +app.get( + attestationPath("status"), + attestationStatusDocs, + handleAttestationStatusRequest +); +app.post( + attestationPath("challenge"), + attestationChallengeDocs, + handleAttestationChallengeRequest +); +app.post( + attestationPath("register"), + attestationRegisterDocs, + handleAttestationRegisterRequest +); +app.post( + attestationPath("verify"), + attestationVerifyDocs, + handleAttestationVerifyRequest +); +// Notes Import: availability probe, the metered calls, and its run routes. app.get( "/notes-import/status", notesImportStatusDocs, handleNotesImportStatusRequest ); +// The attestation routes' old paths, for deployed apps: same handlers, plus a +// `Deprecation` header. Remove once the minimum app version uses the new ones. app.post( "/notes-import/challenge", - notesImportChallengeDocs, - handleChallengeRequest + legacyChallengeDocs, + deprecatedPath("/notes-import/challenge", attestationPath("challenge")), + handleAttestationChallengeRequest +); +app.post( + "/notes-import/attest", + legacyAttestDocs, + deprecatedPath("/notes-import/attest", attestationPath("register")), + handleAttestationRegisterRequest ); -app.post("/notes-import/attest", notesImportAttestDocs, handleAttestRequest); -// Attested no-op: dev-tools diagnostics prove the assertion path server-side. app.post( "/notes-import/verify", - notesImportVerifyDocs, - handleNotesImportVerifyRequest + legacyVerifyDocs, + deprecatedPath("/notes-import/verify", attestationPath("verify")), + handleAttestationVerifyRequest ); // Streaming import: attested kickoff → SSE progress stream → result snapshot. app.post( @@ -183,9 +230,11 @@ app.route("/admin/accounts", createAccountsAdminRoutes()); // Universal-link support for WitnessWork contact sharing. AASA must be served // at this exact path with Content-Type application/json and no redirects. app.get("/.well-known/apple-app-site-association", aasaDocs, handleAasaRequest); -// Shared contacts ride in the fragment (`/c#`), which never reaches -// the worker. `/c/:payload` keeps links from older app versions working. -app.get("/c", contactLinkDocs, handleContactLinkRequest); +// Shared contacts ride in the fragment (`/contacts#`), which never +// reaches the worker. The short `/c` (and `/c/:payload`, from the oldest +// builds) are in links people already sent, so they stay for good. +app.get("/contacts", contactLinkDocs, handleContactLinkRequest); +app.get("/c", shortContactLinkDocs, handleContactLinkRequest); 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). @@ -195,10 +244,13 @@ app.route("/buddies/v1", createBuddiesRoutes()); // instead) and 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. -app.get("/b", buddyInvitePageDocs, handleBuddyInvitePage); +// `/b` is the short path in invites already sent. +app.get("/buddies/invite", buddyInvitePageDocs, handleBuddyInvitePage); +app.get("/b", shortBuddyInvitePageDocs, handleBuddyInvitePage); // Set Up Another Device QR codes opened by a camera instead of the app; the -// handoff secret stays in the URL fragment. -app.get("/h", handoffPageDocs, handleHandoffPage); +// handoff secret stays in the URL fragment. `/h` is the short path. +app.get("/accounts/handoff", handoffPageDocs, handleHandoffPage); +app.get("/h", shortHandoffPageDocs, handleHandoffPage); // Supporter-only route ordering (HERE Waypoints Sequence); coordinates only, // with per-account limits in the RoutePlanningQuota DO. app.route("/route-planning", createRoutePlanningRoutes()); diff --git a/src/appAttest/protocol.ts b/src/appAttest/protocol.ts index 975e5d0..e210120 100644 --- a/src/appAttest/protocol.ts +++ b/src/appAttest/protocol.ts @@ -4,20 +4,24 @@ import { sha256Bytes, sha256Hex, } from '../crypto' +import { + isAttestationPurpose, + type AttestationPurpose, +} from '../attestation/purposes' export const APP_ATTEST_PROTOCOL_VERSION = 2 as const export const APP_ATTEST_V2_DOMAIN = 'witnesswork.app-attest' export const APP_ATTEST_CHALLENGE_TTL_SECONDS = 300 export type AppAttestOperation = 'bind' | 'enroll' | 'assert' -export type AppAttestAssertionPurpose = - | 'notes-import-kickoff' - | 'notes-import-verify' - | 'notes-import-transcribe' +/** The registry in src/attestation/purposes.ts, shared with Play Integrity. */ +export type AppAttestAssertionPurpose = AttestationPurpose -export const NOTES_IMPORT_KICKOFF_PURPOSE = 'notes-import-kickoff' -export const NOTES_IMPORT_VERIFY_PURPOSE = 'notes-import-verify' -export const NOTES_IMPORT_TRANSCRIBE_PURPOSE = 'notes-import-transcribe' +export { + NOTES_IMPORT_KICKOFF_PURPOSE, + NOTES_IMPORT_VERIFY_PURPOSE, + NOTES_IMPORT_TRANSCRIBE_PURPOSE, +} from '../attestation/purposes' export interface V2ChallengeRequest { protocolVersion: 2 @@ -339,9 +343,4 @@ export const isAppAttestKeyId = (value: string): boolean => { export const isAppAttestChallenge = (value: string): boolean => /^[A-Za-z0-9_-]{43}$/.test(value) -export const isAppAttestAssertionPurpose = ( - value: string -): value is AppAttestAssertionPurpose => - value === NOTES_IMPORT_KICKOFF_PURPOSE || - value === NOTES_IMPORT_VERIFY_PURPOSE || - value === NOTES_IMPORT_TRANSCRIBE_PURPOSE +export const isAppAttestAssertionPurpose = isAttestationPurpose diff --git a/src/attestation/http.ts b/src/attestation/http.ts new file mode 100644 index 0000000..b9eee8f --- /dev/null +++ b/src/attestation/http.ts @@ -0,0 +1,354 @@ +import type { AppContext, ErrorResponse } from '../types' +import { HTTP_STATUS } from '../config' +import { Sentry } from '../sentry' +import { retryAfterHeaders } from '../errors' +import { PLAY_INTEGRITY_CHALLENGE_TTL_SECONDS } from '../playIntegrity/protocol' +import { + appAttestProtocolVersion, + AppAttestError, + type AppAttestAction, + type AppAttestReason, +} from '../appAttest' +import { appAttestHttpCode } from '../appAttest/errors' +import { + isAppAttestChallenge, + isAppAttestKeyId, + isAppAttestOperationId, + isAppAttestUuid, + type V2AssertionChallengeRequest, + type V2AssertionFinalRequest, +} from '../appAttest/protocol' +import { + PlayIntegrityError, + isPlayIntegrityRequest, + parsePlayIntegrityAssertionRequest, + verifyPlayIntegrityAssertion, + type PlayIntegrityReason, +} from '../playIntegrity' +import type { PlayIntegrityAction } from '../playIntegrity/errors' +import { isAttestationPurpose, type AttestationPurpose } from './purposes' + +/** + * The HTTP side of device attestation, shared by `/attestation/v1/*` and + * every route that requires an attested request: App Attest on iOS, Play + * Integrity on Android. A new feature calls `requireAttestation`; Notes + * Import's kickoff keeps its own protocol gate (it still accepts App Attest + * v1) but verifies through the same helpers. + */ + +/** A 503 that didn't say otherwise: worth one more try after a short wait. */ +export const UNAVAILABLE_RETRY_AFTER_SECONDS = 30 + +/** + * The attestation error response: the shared envelope (src/errors.ts) with a + * human message in `error`, as shipped builds expect, plus the stable `code` + * and, on attestation failures, `reason` and `action`. Every 503 carries + * `Retry-After`. + */ +export const attestationError = ( + ctx: AppContext, + status: number, + error: string, + code: string, + reason?: AppAttestReason | PlayIntegrityReason, + action?: AppAttestAction | PlayIntegrityAction, + retryAfter: number | undefined = status === 503 + ? UNAVAILABLE_RETRY_AFTER_SECONDS + : undefined +): Response => { + const body: ErrorResponse = { ok: false, error, code } + if (retryAfter != null) body.retryAfter = retryAfter + if (reason) body.reason = reason + if (action) body.action = action + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ctx.json(body, status as any, retryAfterHeaders(retryAfter)) +} + +export const asString = (v: unknown): string | null => + typeof v === 'string' && v.length > 0 ? v : null + +export const asRecord = (value: unknown): Record | null => + value != null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null + +export const isLowercaseSha256 = (value: string): boolean => + /^[a-f0-9]{64}$/.test(value) + +/** + * Shape gate for the client-supplied shared account id (witness-work ADR + * 0011). It ends up in a `|`-delimited signed clientData field, a Durable + * Object name, and a RevenueCat path segment, so keep it to a bounded + * uuid-ish alphabet. + */ +export const isValidAccountId = (value: string): boolean => + /^[A-Za-z0-9_-]{8,64}$/.test(value) + +export const attestationBadRequest = ( + ctx: AppContext, + message: string +): Response => + attestationError( + ctx, + HTTP_STATUS.BAD_REQUEST, + message, + 'bad_request', + 'invalid_request', + 'none' + ) + +export const unsupportedProtocol = ( + ctx: AppContext, + message: string +): Response => + attestationError( + ctx, + HTTP_STATUS.BAD_REQUEST, + message, + 'bad_request', + 'unsupported_protocol', + 'none' + ) + +export const appAttestFailureResponse = ( + ctx: AppContext, + error: AppAttestError, + input: unknown +): Response => { + // Stable reason only: request bodies may contain a recovery token. + console.warn('attestation: App Attest rejected:', error.reason) + if (error.reason === 'storage_unavailable') Sentry.captureException(error) + + const version = appAttestProtocolVersion(input) + const status = version === 1 && error.status < 500 ? 401 : error.status + const code = appAttestHttpCode(version, error.reason) + return attestationError( + ctx, + status, + error.message, + code, + error.reason, + error.action + ) +} + +export const playIntegrityFailureResponse = ( + ctx: AppContext, + error: PlayIntegrityError +): Response => { + // Stable reason and policy detail only; never the token or verdict payload. + console.warn( + 'attestation: Play Integrity rejected:', + error.reason, + error.message + ) + if (error.reason === 'storage_unavailable') Sentry.captureException(error) + return attestationError( + ctx, + error.status, + error.message, + error.status >= 500 ? 'server_error' : 'attestation_failed', + error.reason, + error.action, + error.status === 429 + ? PLAY_INTEGRITY_CHALLENGE_TTL_SECONDS + : error.status === 503 + ? UNAVAILABLE_RETRY_AFTER_SECONDS + : undefined + ) +} + +export const parseV2AssertionChallengeRequest = ( + record: Record +): V2AssertionChallengeRequest | null => { + const operationId = asString(record.operationId) + const uuid = asString(record.uuid) + const keyId = asString(record.keyId) + const purpose = asString(record.purpose) + const contentHash = asString(record.contentHash) + const requestHash = asString(record.requestHash) + const accountId = + record.accountId == null ? undefined : asString(record.accountId) + if ( + record.protocolVersion !== 2 || + record.operation !== 'assert' || + !operationId || + !isAppAttestOperationId(operationId) || + !uuid || + !isAppAttestUuid(uuid) || + !keyId || + !isAppAttestKeyId(keyId) || + !purpose || + !isAttestationPurpose(purpose) || + (record.accountId != null && + (!accountId || !isValidAccountId(accountId))) || + !contentHash || + !isLowercaseSha256(contentHash) || + !requestHash || + !isLowercaseSha256(requestHash) + ) { + return null + } + return { + protocolVersion: 2, + operation: 'assert', + operationId, + uuid, + keyId, + purpose, + ...(accountId ? { accountId } : {}), + contentHash, + requestHash, + } +} + +export const parseV2AssertionFinalRequest = ( + record: Record, + expectedPurpose: AttestationPurpose +): V2AssertionFinalRequest | null => { + const challengeRequest = parseV2AssertionChallengeRequest(record) + const challenge = asString(record.challenge) + const assertion = asString(record.assertion) + if ( + !challengeRequest || + challengeRequest.purpose !== expectedPurpose || + !challenge || + !isAppAttestChallenge(challenge) || + !assertion || + assertion.length > 65_536 + ) { + return null + } + return { ...challengeRequest, challenge, assertion } +} + +const storageUnavailable = (cause: unknown): AppAttestError => + new AppAttestError('App Attest storage is temporarily unavailable', { + reason: 'storage_unavailable', + action: 'retry', + status: 503, + cause, + }) + +export const issueV2AssertionChallenge = async ( + ctx: AppContext, + request: V2AssertionChallengeRequest +) => { + try { + const id = ctx.env.APP_ATTEST_IDENTITY.idFromName(request.uuid) + const result = await ctx.env.APP_ATTEST_IDENTITY.get(id).issueChallenge( + request + ) + if (!result.ok) throw AppAttestError.fromFailure(result.error) + return result.value + } catch (error) { + if (error instanceof AppAttestError) throw error + throw storageUnavailable(error) + } +} + +export const verifyV2Assertion = async ( + ctx: AppContext, + request: V2AssertionFinalRequest +) => { + try { + const id = ctx.env.APP_ATTEST_IDENTITY.idFromName(request.uuid) + const result = await ctx.env.APP_ATTEST_IDENTITY.get(id).verifyAssertion( + request + ) + if (!result.ok) throw AppAttestError.fromFailure(result.error) + return result.value + } catch (error) { + if (error instanceof AppAttestError) throw error + throw storageUnavailable(error) + } +} + +const playIntegrityDependencies = { + fetch: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init), + now: () => Date.now(), + report: (error: Error) => { + Sentry.captureException(error) + }, +} + +/** Android's protected-request check (ADR 0017); null when it passed. */ +export const verifyPlayIntegrityRecord = async ( + ctx: AppContext, + record: Record, + purpose: AttestationPurpose +): Promise => { + const request = parsePlayIntegrityAssertionRequest(record, purpose) + if (!request) { + return attestationBadRequest(ctx, 'Invalid Play Integrity request') + } + try { + await verifyPlayIntegrityAssertion( + ctx.env, + request, + playIntegrityDependencies + ) + return null + } catch (e) { + if (e instanceof PlayIntegrityError) { + return playIntegrityFailureResponse(ctx, e) + } + Sentry.captureException(e) + return attestationError( + ctx, + HTTP_STATUS.INTERNAL_SERVER_ERROR, + 'Auth error', + 'server_error' + ) + } +} + +/** iOS's protected-request check (App Attest v2); null when it passed. */ +export const verifyAppAttestV2Record = async ( + ctx: AppContext, + record: Record, + request: V2AssertionFinalRequest +): Promise => { + try { + await verifyV2Assertion(ctx, request) + return null + } catch (e) { + if (e instanceof AppAttestError) { + return appAttestFailureResponse(ctx, e, record) + } + Sentry.captureException(e) + return attestationError( + ctx, + HTTP_STATUS.INTERNAL_SERVER_ERROR, + 'Auth error', + 'server_error' + ) + } +} + +/** + * The attestation check for a protected request: App Attest v2 (iOS) or Play + * Integrity (Android), signed for `purpose`; App Attest v1 is refused. Null + * when the request passed, else the response to send. + * + * The route recomputes its own `contentHash` and `requestHash` from the + * payload and rejects a mismatch before calling this, so the assertion + * provably covers what the route is about to act on. Challenges come from + * `POST /attestation/v1/challenge` with the same purpose and hashes. + */ +export const requireAttestation = async ( + ctx: AppContext, + record: Record, + purpose: AttestationPurpose, + unsupportedMessage = 'This request requires App Attest v2 or Play Integrity' +): Promise => { + if (isPlayIntegrityRequest(record)) { + return verifyPlayIntegrityRecord(ctx, record, purpose) + } + const request = + appAttestProtocolVersion(record) === 2 + ? parseV2AssertionFinalRequest(record, purpose) + : null + if (!request) return unsupportedProtocol(ctx, unsupportedMessage) + return verifyAppAttestV2Record(ctx, record, request) +} diff --git a/src/attestation/openapi.ts b/src/attestation/openapi.ts new file mode 100644 index 0000000..e1929f4 --- /dev/null +++ b/src/attestation/openapi.ts @@ -0,0 +1,592 @@ +import { z } from 'zod' +import { describeRoute } from 'hono-openapi' +import { + apiErrorSchema, + errorResponse, + json, + jsonBody, + jsonResponse, + rateLimitedResponse, +} from '../openapi/schemas' +import { ATTESTATION_PURPOSES } from './purposes' + +/** + * OpenAPI descriptions for `/attestation/v1/*` (src/attestation/route.ts) and + * the attestation fields every protected route documents. Doc-only: the + * handlers keep their own parsing, so update this file with any change to the + * wire contract. + */ + +const TAG = 'Attestation' + +/** Where the old `/notes-import/*` paths are listed. */ +const LEGACY_TAG = 'Notes Import' + +// --- Shared fields ------------------------------------------------------- + +export const sha256Hex = (description: string) => + z + .string() + .regex(/^[a-f0-9]{64}$/) + .meta({ description: `${description} (lowercase hex SHA-256)` }) + +export const installId = z + .string() + .regex(/^[A-Za-z0-9_-]{8,64}$/) + .meta({ + description: + 'The install id (`uuid`). App Attest keys are pinned to it; usage is metered on it when no `accountId` is sent.', + }) + +export const accountId = z + .string() + .regex(/^[A-Za-z0-9_-]{8,64}$/) + .optional() + .meta({ + description: + 'Optional shared account id. When present, Supporter status, allowances, the concurrency cap, and replay all key on it instead of `uuid`. A malformed value is a 400, never ignored.', + }) + +export const operationId = z + .string() + .regex(/^[A-Za-z0-9_-]{8,128}$/) + .meta({ + description: + 'Client-chosen id for one challenge → request round trip. Reusing it with different data is `operation_conflict`.', + }) + +export const keyId = z + .string() + .regex(/^[A-Za-z0-9+/]{43}=$/) + .meta({ + description: 'The App Attest key id (standard base64 of 32 bytes).', + }) + +export const challenge = z + .string() + .regex(/^[A-Za-z0-9_-]{43}$/) + .meta({ + description: + 'The one-time server challenge from `POST /attestation/v1/challenge` (43-character base64url). Consumed on use.', + }) + +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`.', + }) + +export const expiresAt = z.number().int().meta({ + description: 'When the challenge expires (epoch ms; 5 minutes after issue).', +}) + +export const playIntegrityProvider = z.literal('play-integrity').meta({ + description: + 'Opts the request into the Android Play Integrity path. Bodies without it take the App Attest path.', +}) + +export const devBypassHeader = { + 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. A match skips attestation (and, on metered routes, the RevenueCat check, using the Supporter allowances). Production never sets the token, so the header is ignored there.', +} + +// --- Errors ------------------------------------------------------------- + +export const attestationErrorSchema = apiErrorSchema + .extend({ + reason: z.string().optional().meta({ + description: + 'Stable App Attest or Play Integrity reason, on attestation failures: `invalid_request`, `unsupported_protocol`, `operation_conflict`, `too_many_challenges`, `challenge_not_found`, `challenge_expired`, `identity_not_bound`, `key_not_active`, `recovery_not_enrolled`, `recovery_token_mismatch`, `attestation_invalid`, `assertion_invalid`, `counter_not_increasing`, `storage_unavailable`; Android adds `integrity_unavailable`, `integrity_token_invalid`, `app_not_recognized`, `device_integrity_failed`.', + }), + action: z + .enum([ + 'none', + 'retry', + 'request_challenge', + 'start_new_operation', + 'bind', + 'enroll_recovery', + 'use_active_key', + ]) + .optional() + .meta({ + description: + 'What the client should do next, beside `reason`. Transient failures never ask for key rotation.', + }), + }) + .meta({ + id: 'AttestationError', + description: + 'The shared error envelope; `error` is a human-readable message. Attestation failures add `reason` and `action`.', + }) + +const attError = (description: string, codes: readonly string[]) => + errorResponse(description, codes, attestationErrorSchema) + +export const V1_CODES = [ + 'device_verification_failed', + 'assertion_failed', + 'challenge_expired', + 'counter_conflict', +] as const + +export const ATTESTATION_NOTE = + 'App Attest v1 (legacy) failures are always 401 with `device_verification_failed`, `assertion_failed`, `challenge_expired`, or `counter_conflict`, which never ask the client to rotate its key. App Attest v2 and Play Integrity failures are `attestation_failed` at the reason’s own status; branch on `reason`/`action`.' + +export const storageUnavailable = + '`server_error`: attestation storage or Google’s decode service is unavailable (`Retry-After: 30`).' + +export const attestationResponses = { + 401: attError( + `Attestation failed: an unknown, consumed, or expired challenge, an invalid assertion or token, or an identity whose key isn’t bound or active. ${ATTESTATION_NOTE}`, + ['attestation_failed', ...V1_CODES] + ), + 403: attError( + '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`).', + ['attestation_failed'] + ), + 409: attError( + 'App Attest v2 / Play Integrity: `operationId` was used with different data (`operation_conflict`), or the assertion’s sign count didn’t increase (`counter_not_increasing`). Start a new operation.', + ['attestation_failed'] + ), +} + +// --- Attested request fields -------------------------------------------- + +/** One route's purpose, or a schema when it accepts more than one. */ +type PurposeField = string | z.ZodType + +const purposeSchema = (forPurpose: PurposeField) => + typeof forPurpose === 'string' ? z.literal(forPurpose) : forPurpose + +/** App Attest v2 assertion fields shared by every attested request. */ +export const appAttestV2AssertionFields = (forPurpose: PurposeField) => ({ + protocolVersion: z.literal(2), + operation: z.literal('assert'), + operationId, + keyId, + purpose: purposeSchema(forPurpose), + challenge, + assertion: z.string().max(65_536).meta({ + description: + 'App Attest assertion (base64) over SHA-256 of the client data `witnesswork.app-attest|2|assert|||||||`.', + }), +}) + +/** Play Integrity fields shared by every attested request. */ +export const playIntegrityFields = (forPurpose: PurposeField) => ({ + attestationProvider: playIntegrityProvider, + protocolVersion: z.literal(1), + operation: z.literal('assert'), + operationId, + purpose: purposeSchema(forPurpose), + challenge, + integrityToken: z.string().max(32_768).meta({ + description: + 'A Play Integrity standard-request token whose `requestHash` is the lowercase hex SHA-256 of `witnesswork.play-integrity|1|assert|||||||`. Never send `keyId` or `assertion` with it.', + }), +}) + +// --- POST /attestation/v1/challenge --------------------------------------- + +const challengeRequestSchema = z.union([ + z + .object({ + protocolVersion: z.literal(1).optional(), + }) + .meta({ + title: 'App Attest v1 (legacy)', + description: + 'An empty body, `{}`, or `{ "protocolVersion": 1 }`. Returns a bare challenge for a v1 attestation or assertion.', + }), + z + .object({ + protocolVersion: z.literal(2), + operation: z.enum(['bind', 'enroll']), + operationId, + uuid: installId, + keyId, + }) + .meta({ + title: 'App Attest v2 bind / enroll', + description: + 'A challenge for `POST /attestation/v1/register`: `bind` attests a new key, `enroll` adds a recovery token with the active key. No assertion fields allowed.', + }), + z + .object({ + protocolVersion: z.literal(2), + operation: z.literal('assert'), + operationId, + uuid: installId, + keyId, + purpose, + accountId, + contentHash: sha256Hex( + 'The content hash the protected request will carry' + ), + requestHash: sha256Hex( + 'The request hash the protected request will carry' + ), + }) + .meta({ + title: 'App Attest v2 assert', + description: + 'A challenge bound to one protected request (verify, kickoff, or transcribe). The later request must repeat these values exactly.', + }), + z + .object({ + attestationProvider: playIntegrityProvider, + protocolVersion: z.literal(1), + operation: z.literal('assert'), + operationId, + uuid: installId, + accountId, + purpose, + contentHash: sha256Hex( + 'The content hash the protected request will carry' + ), + requestHash: sha256Hex( + 'The request hash the protected request will carry' + ), + }) + .meta({ + title: 'Play Integrity', + description: + 'Android. Same binding as the App Attest v2 assert challenge, without `keyId`.', + }), +]) + +const challengeResponseSchema = z.union([ + z.object({ challenge: z.string() }).meta({ title: 'App Attest v1 (legacy)' }), + z + .object({ + protocolVersion: z.literal(2), + operation: z.enum(['bind', 'enroll', 'assert']), + operationId, + challenge, + expiresAt, + }) + .meta({ title: 'App Attest v2' }), + z + .object({ + attestationProvider: z.literal('play-integrity'), + protocolVersion: z.literal(1), + operation: z.literal('assert'), + operationId, + challenge, + expiresAt, + }) + .meta({ title: 'Play Integrity' }), +]) + +const challengeRoute: RouteDocs = { + summary: 'Issue an attestation challenge', + description: `Step one of every attested call, on any route. Returns a one-time challenge (5-minute lifetime) for App Attest v1, App Attest v2 (\`bind\`, \`enroll\`, or \`assert\`), or Play Integrity. + +v2 and Play Integrity challenges are bound to their request: the descriptor (operation, \`operationId\`, \`uuid\`, \`keyId\`, purpose, \`accountId\`, and both hashes) is stored, and the consuming request must match it. Re-requesting the same \`operationId\` with the same data returns the outstanding challenge. At most 32 unexpired challenges per install. + +**Hashes.** Each protected route defines its own. For Notes Import, \`contentHash\` is SHA-256 of \`notesText\` (kickoff), of the base64 \`image\` string as sent (transcribe), or any value the client picks (verify). \`requestHash\` is SHA-256 of the sorted-key canonical JSON \`{"context","notesText","refinement"}\` (kickoff; \`refinement\` is \`null\` when absent), of \`{"imageHash","mimeType"}\` (transcribe), or equal to \`contentHash\` (verify).`, + requestBody: { + required: false, + description: 'Optional; an empty body asks for a v1 challenge.', + content: json(challengeRequestSchema), + }, + responses: { + 200: jsonResponse('A challenge.', challengeResponseSchema), + 400: attError( + 'Invalid JSON, an invalid challenge request (`reason: invalid_request`), or an unsupported `protocolVersion` (`reason: unsupported_protocol`). v2 requests the lifecycle rejects answer `attestation_failed`.', + ['bad_request', 'attestation_failed'] + ), + 409: attError( + '`operationId` was already used with different data (`operation_conflict`).', + ['attestation_failed'] + ), + 429: attError( + 'Too many requests from this IP (`rate_limited`, `Retry-After: 60`), or 32 unexpired challenges for this install (`attestation_failed`, `reason: too_many_challenges`; Play Integrity sends `Retry-After: 300`).', + ['rate_limited', 'attestation_failed'] + ), + 500: attError('Unexpected failure.', ['server_error']), + 503: attError( + 'Challenge storage is unavailable, or Play Integrity isn’t configured on this worker (`reason: integrity_unavailable`). `Retry-After: 30`.', + ['server_error'] + ), + }, +} + +// --- POST /attestation/v1/register ---------------------------------------- + +const recoveryToken = z + .string() + .regex(/^[A-Za-z0-9_-]{43}$/) + .meta({ + description: + 'A 256-bit secret the app keeps (43-character base64url). Only a verifier is stored; it lets a reinstall or a new key take over the install id later.', + }) + +const attestRequestSchema = z.union([ + z + .object({ + protocolVersion: z.literal(1).optional(), + keyId: z.string(), + attestation: z.string().meta({ + description: 'App Attest attestation object (base64).', + }), + challenge: z.string(), + uuid: z.string(), + }) + .meta({ + title: 'App Attest v1 (legacy)', + description: 'Binds a key to the install id.', + }), + z + .object({ + protocolVersion: z.literal(2), + operation: z.literal('bind'), + operationId, + uuid: installId, + keyId, + challenge, + attestation: z.string().meta({ + description: + 'App Attest attestation object (base64) whose client data hash is SHA-256 of `witnesswork.app-attest|2|bind||||`, where `tokenHash` is base64url SHA-256 of the decoded recovery token.', + }), + recoveryToken, + }) + .meta({ + title: 'App Attest v2 bind', + description: + 'Attests a key for the install id with a recovery token. Rebinding the same install to a new key requires the recovery token enrolled earlier.', + }), + z + .object({ + protocolVersion: z.literal(2), + operation: z.literal('enroll'), + operationId, + uuid: installId, + keyId, + challenge, + assertion: z.string().max(65_536).meta({ + description: + 'Assertion by the active key over SHA-256 of `witnesswork.app-attest|2|enroll|||||`.', + }), + recoveryToken, + }) + .meta({ + title: 'App Attest v2 enroll', + description: + 'Adds a recovery token to an install already bound by a legacy key.', + }), +]) + +const attestResponseSchema = z.union([ + z.object({ ok: z.literal(true) }).meta({ title: 'App Attest v1 (legacy)' }), + z + .object({ + ok: z.literal(true), + protocolVersion: z.literal(2), + operationId, + status: z + .enum(['bound', 'already_bound', 'rotated', 'recovery_enrolled']) + .meta({ + description: + '`bound`: first key; `already_bound`: this key was already active; `rotated`: a new key replaced the old one with the recovery token; `recovery_enrolled`: enroll succeeded.', + }), + recoveryEnrolled: z.boolean(), + }) + .meta({ title: 'App Attest v2' }), +]) + +const registerRoute: RouteDocs = { + summary: 'Register an App Attest key', + description: `iOS only. Binds a Secure Enclave key to the install id (v1 or v2 \`bind\`), or enrolls a v2 recovery token with the active key (\`enroll\`), using a challenge from \`POST /attestation/v1/challenge\`. + +The attested App ID must be \`.\` for an accepted bundle id (the App Store app, plus the TestFlight Beta app on production); the key stays bound to the one it matched. Play Integrity has no registration step: a body with \`attestationProvider: "play-integrity"\` is a 400 with \`reason: unsupported_protocol\`.`, + requestBody: jsonBody(attestRequestSchema), + responses: { + 200: jsonResponse('Registered.', attestResponseSchema), + 400: attError( + 'Invalid JSON, missing fields, a Play Integrity body, or (v2) an invalid field or recovery token. Route-level checks answer `bad_request`; v2 checks in the lifecycle answer `attestation_failed` with `reason: invalid_request` or `unsupported_protocol`.', + ['bad_request', 'attestation_failed'] + ), + 401: attError( + `The challenge, attestation, or recovery token didn’t verify: \`challenge_not_found\`, \`challenge_expired\`, \`attestation_invalid\`, \`recovery_not_enrolled\` (\`action: enroll_recovery\`), \`recovery_token_mismatch\`, \`identity_not_bound\`. ${ATTESTATION_NOTE}`, + ['attestation_failed', ...V1_CODES] + ), + 409: attError( + '`operationId` was already used with different data (`operation_conflict`).', + ['attestation_failed'] + ), + ...rateLimitedResponse, + 500: attError('Unexpected failure.', ['server_error']), + 503: attError('App Attest storage is unavailable. `Retry-After: 30`.', [ + 'server_error', + ]), + }, +} + +// --- POST /attestation/v1/verify ------------------------------------------ + +const verifyPurpose = z.enum(['attestation-verify', 'notes-import-verify']).meta({ + description: + '`attestation-verify`; `notes-import-verify`, which older apps sign, is accepted too.', +}) + +const verifyRequestSchema = z.union([ + z + .object({ + protocolVersion: z.literal(1).optional(), + uuid: z.string(), + accountId, + keyId: z.string(), + challenge: z.string(), + assertion: z.string().meta({ + description: + 'Assertion over SHA-256 of `||`, or `|||` when `accountId` is sent.', + }), + contentHash: sha256Hex('Any value the client picks'), + }) + .meta({ title: 'App Attest v1 (legacy)' }), + z + .object({ + uuid: installId, + accountId, + ...appAttestV2AssertionFields(verifyPurpose), + contentHash: sha256Hex('Any value the client picks'), + requestHash: sha256Hex('Must equal `contentHash`'), + }) + .meta({ title: 'App Attest v2' }), + z + .object({ + uuid: installId, + accountId, + ...playIntegrityFields(verifyPurpose), + contentHash: sha256Hex('Any value the client picks'), + requestHash: sha256Hex('Must equal `contentHash`'), + }) + .meta({ title: 'Play Integrity' }), +]) + +const verifyResponseSchema = z.union([ + z + .object({ ok: z.literal(true) }) + .meta({ title: 'App Attest v1 (or dev bypass)' }), + z + .object({ + ok: z.literal(true), + protocolVersion: z.literal(2), + operationId, + }) + .meta({ title: 'App Attest v2' }), + z + .object({ + ok: z.literal(true), + attestationProvider: z.literal('play-integrity'), + protocolVersion: z.literal(1), + operationId, + }) + .meta({ title: 'Play Integrity' }), +]) + +const verifyRoute: RouteDocs = { + summary: 'Verify attestation (diagnostic)', + description: `An attested no-op for the app’s developer tools. Runs the same verification as the protected routes, consuming the challenge and advancing the sign count, but touches no allowance and runs no model. + +There is no payload, so \`requestHash\` must equal \`contentHash\` (v2 and Play Integrity). The purpose is \`attestation-verify\`; \`notes-import-verify\`, which older apps sign, is accepted too. Not gated by any feature's kill switch.`, + parameters: [devBypassHeader], + requestBody: jsonBody(verifyRequestSchema), + responses: { + 200: jsonResponse('The attestation verified.', verifyResponseSchema), + 400: attError( + 'Invalid JSON, missing or malformed fields, `requestHash` ≠ `contentHash`, or an invalid v2 / Play Integrity request (`reason: invalid_request`).', + ['bad_request', 'attestation_failed'] + ), + ...attestationResponses, + ...rateLimitedResponse, + 500: attError('Unexpected failure.', ['server_error']), + 503: attError(storageUnavailable, ['server_error']), + }, +} + +// --- GET /attestation/v1/status ------------------------------------------- + +/** The provider fields, shared with Notes Import's `capabilities`. */ +export const attestationProvidersShape = { + appAttest: z.object({ + protocolVersions: z.array(z.number().int()).meta({ + description: 'App Attest protocol versions accepted: `[1, 2]`.', + }), + }), + playIntegrity: z + .object({ + protocolVersions: z.array(z.number().int()).meta({ + description: 'Play Integrity protocol versions accepted: `[1]`.', + }), + cloudProjectNumber: z.string().meta({ + description: + 'The Google Cloud project number the app passes to the Play Integrity standard-request provider.', + }), + }) + .optional() + .meta({ + description: + 'Present only when this worker can verify Android requests. Android keeps attested features closed while it is absent.', + }), +} + +const statusRoute: RouteDocs = { + summary: 'Accepted attestation providers', + description: + 'Which attestation providers and protocol versions this worker accepts, from its configuration alone (no I/O, `Cache-Control: no-store`). `GET /notes-import/status` repeats them in `capabilities` for older apps.', + responses: { + 200: jsonResponse( + 'The accepted providers.', + z.object({ providers: z.object(attestationProvidersShape) }) + ), + ...rateLimitedResponse, + }, +} + +// --- Routes --------------------------------------------------------------- + +type RouteDocs = Omit[0], 'tags'> & { + summary: string + description: string +} + +const docs = (route: RouteDocs) => describeRoute({ ...route, tags: [TAG] }) + +/** + * The same docs on an old `/notes-import/*` path, which serves the same + * handler for deployed apps. + */ +const movedDocs = (successor: string, route: RouteDocs) => + describeRoute({ + ...route, + tags: [LEGACY_TAG], + summary: `${route.summary} (moved)`, + deprecated: true, + description: `Moved to \`${successor}\`, which runs the same handler; this path stays for deployed apps. Responses add \`Deprecation\` and \`Link: <${successor.split(' ')[1]}>; rel="successor-version"\`.\n\n${route.description}`, + }) + +export const challengeDocs = docs(challengeRoute) +export const registerDocs = docs(registerRoute) +export const verifyDocs = docs(verifyRoute) +export const statusDocs = docs(statusRoute) + +export const legacyChallengeDocs = movedDocs( + 'POST /attestation/v1/challenge', + challengeRoute +) +export const legacyAttestDocs = movedDocs( + 'POST /attestation/v1/register', + registerRoute +) +export const legacyVerifyDocs = movedDocs( + 'POST /attestation/v1/verify', + verifyRoute +) diff --git a/src/attestation/providers.ts b/src/attestation/providers.ts new file mode 100644 index 0000000..6f122a0 --- /dev/null +++ b/src/attestation/providers.ts @@ -0,0 +1,36 @@ +import type { Environment } from '../types' +import { APP_ATTEST_PROTOCOL_VERSION } from '../appAttest/protocol' +import { playIntegrityConfig } from '../playIntegrity/config' +import { PLAY_INTEGRITY_PROTOCOL_VERSION } from '../playIntegrity/protocol' + +export interface AttestationProviders { + appAttest: { + protocolVersions: readonly [1, typeof APP_ATTEST_PROTOCOL_VERSION] + } + /** + * Present only when this worker can verify Android requests (ADR 0017). + * Android keeps attested features closed while it is absent. + */ + playIntegrity?: { + protocolVersions: readonly [typeof PLAY_INTEGRITY_PROTOCOL_VERSION] + cloudProjectNumber: string + } +} + +/** Which attestation providers this worker accepts, from config alone. */ +export const attestationProviders = ( + env: Environment +): AttestationProviders => { + const playIntegrity = playIntegrityConfig(env) + return { + appAttest: { protocolVersions: [1, APP_ATTEST_PROTOCOL_VERSION] }, + ...(playIntegrity + ? { + playIntegrity: { + protocolVersions: [PLAY_INTEGRITY_PROTOCOL_VERSION], + cloudProjectNumber: playIntegrity.cloudProjectNumber, + }, + } + : {}), + } +} diff --git a/src/attestation/purposes.ts b/src/attestation/purposes.ts new file mode 100644 index 0000000..ebdb1d1 --- /dev/null +++ b/src/attestation/purposes.ts @@ -0,0 +1,42 @@ +/** + * Every `purpose` an App Attest v2 or Play Integrity assertion may name. The + * purpose is signed into the assertion's client data (and the Play Integrity + * request hash), so an assertion made for one protected request can't be + * spent on another. Each consuming route checks that the purpose is its own. + * + * A feature that wants attested requests adds its purposes here and passes + * one to `requireAttestation` (src/attestation/http.ts). Shipped values are + * signed by installed apps: never rename one. Retire a purpose by removing + * the route that consumes it. + */ +export const ATTESTATION_PURPOSES = [ + // POST /attestation/v1/verify: the no-payload probe. + 'attestation-verify', + // Notes Import (src/notesImport/route.ts). + 'notes-import-kickoff', + 'notes-import-transcribe', + // 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', +] as const + +export type AttestationPurpose = (typeof ATTESTATION_PURPOSES)[number] + +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' + +const PURPOSES: ReadonlySet = new Set(ATTESTATION_PURPOSES) + +export const isAttestationPurpose = ( + value: string +): value is AttestationPurpose => PURPOSES.has(value) + +/** The purposes `POST /attestation/v1/verify` (and its old path) accepts. */ +export const isVerifyPurpose = ( + value: unknown +): value is + | typeof ATTESTATION_VERIFY_PURPOSE + | typeof NOTES_IMPORT_VERIFY_PURPOSE => + value === ATTESTATION_VERIFY_PURPOSE || value === NOTES_IMPORT_VERIFY_PURPOSE diff --git a/src/attestation/route.test.ts b/src/attestation/route.test.ts new file mode 100644 index 0000000..5c32f5c --- /dev/null +++ b/src/attestation/route.test.ts @@ -0,0 +1,264 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('cloudflare:workers', () => ({ DurableObject: class {} })) + +import { app } from '../app' +import { makeMemoryKv } from '../test/memoryKv' +import type { Environment } from '../types' +import { ATTESTATION_PURPOSES, isAttestationPurpose } from './purposes' +import { requireAttestation } from './http' +import type { AppContext } from '../types' + +const ORIGIN = 'https://ww-proxy.leviwilkerson.com' +const UUID = '65A8B00C-DA7A-4E0A-BA5C-8E3D1B8C5F1C' +const HASH = 'a'.repeat(64) + +const v2VerifyBody = (purpose: string) => ({ + protocolVersion: 2, + operation: 'assert', + operationId: 'verify-operation-1', + purpose, + uuid: UUID, + keyId: `${'A'.repeat(43)}=`, + challenge: 'C'.repeat(43), + assertion: 'assertion', + contentHash: HASH, + requestHash: HASH, +}) + +const makeEnv = (overrides: Partial> = {}) => { + const verifyAssertion = vi.fn(async () => ({ + ok: true as const, + value: { + ok: true as const, + protocolVersion: 2 as const, + operationId: 'verify-operation-1', + }, + })) + const limit = vi.fn(async (_: { key: string }) => ({ success: true })) + const env = { + APP_ATTEST_ENVIRONMENT: 'production', + NOTES_KV: makeMemoryKv(), + RATE_LIMITER: { limit }, + APP_ATTEST_IDENTITY: { + idFromName: () => ({ toString: () => 'identity-id' }), + get: () => ({ verifyAssertion }), + }, + ...overrides, + } as unknown as Environment + return { env, verifyAssertion, limit } +} + +const call = ( + env: Environment, + method: string, + path: string, + body?: unknown, + headers: Record = {} +) => + app.fetch( + new Request(`${ORIGIN}${path}`, { + method, + headers: { 'content-type': 'application/json', ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + env + ) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('/attestation/v1', () => { + it('issues a challenge with no deprecation headers', async () => { + const { env } = makeEnv() + const response = await call(env, 'POST', '/attestation/v1/challenge') + expect(response.status).toBe(200) + const body = (await response.json()) as { challenge: string } + expect(body.challenge).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(response.headers.get('Deprecation')).toBeNull() + }) + + it('counts in its own rate-limit family', async () => { + const { env, limit } = makeEnv() + await call(env, 'POST', '/attestation/v1/challenge', undefined, { + 'cf-connecting-ip': '198.18.0.1', + }) + expect(limit).toHaveBeenCalledWith({ key: 'attestation:198.18.0.1' }) + }) + + it('refuses a Play Integrity body on register', async () => { + const { env } = makeEnv() + const response = await call(env, 'POST', '/attestation/v1/register', { + attestationProvider: 'play-integrity', + }) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + code: 'bad_request', + reason: 'unsupported_protocol', + }) + }) + + it.each(['attestation-verify', 'notes-import-verify'])( + 'verifies a v2 assertion signed for %s', + async (purpose) => { + const { env, verifyAssertion } = makeEnv() + const body = v2VerifyBody(purpose) + const response = await call(env, 'POST', '/attestation/v1/verify', body) + expect(response.status).toBe(200) + expect(verifyAssertion).toHaveBeenCalledWith(body) + } + ) + + it('refuses an assertion signed for another route', async () => { + const { env, verifyAssertion } = makeEnv() + const response = await call( + env, + 'POST', + '/attestation/v1/verify', + v2VerifyBody('notes-import-kickoff') + ) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + code: 'bad_request', + reason: 'invalid_request', + }) + expect(verifyAssertion).not.toHaveBeenCalled() + }) + + it('accepts the dev bypass with the new purpose', async () => { + const { env } = makeEnv({ + APP_ATTEST_ENVIRONMENT: 'development', + NOTES_IMPORT_DEV_BYPASS_TOKEN: 'bypass-token', + }) + const { keyId: _k, challenge: _c, assertion: _a, ...body } = + v2VerifyBody('attestation-verify') + const response = await call(env, 'POST', '/attestation/v1/verify', body, { + 'x-ww-dev-bypass': 'bypass-token', + }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + ok: true, + protocolVersion: 2, + operationId: body.operationId, + }) + }) + + it('reports the accepted providers', async () => { + const { env } = makeEnv() + const response = await call(env, 'GET', '/attestation/v1/status') + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') + await expect(response.json()).resolves.toEqual({ + providers: { appAttest: { protocolVersions: [1, 2] } }, + }) + }) +}) + +describe('the old /notes-import attestation paths', () => { + it.each([ + ['/notes-import/challenge', '/attestation/v1/challenge', undefined], + ['/notes-import/attest', '/attestation/v1/register', {}], + ['/notes-import/verify', '/attestation/v1/verify', {}], + ])( + '%s answers like %s, with Deprecation and a successor link', + async (path, successor, body) => { + const { env } = makeEnv() + vi.spyOn(console, 'log').mockImplementation(() => {}) + const [old, current] = await Promise.all([ + call(env, 'POST', path, body), + call(env, 'POST', successor, body), + ]) + expect(old.status).toBe(current.status) + if (old.status !== 200) { + expect(await old.json()).toEqual(await current.json()) + } + expect(old.headers.get('Deprecation')).toBe('@1792022400') + expect(old.headers.get('Link')).toBe( + `<${successor}>; rel="successor-version"` + ) + expect(console.log).toHaveBeenCalledWith( + `deprecated path: ${path} -> ${successor}` + ) + } + ) + + it('still counts in the Notes Import rate-limit family', async () => { + const { env, limit } = makeEnv() + vi.spyOn(console, 'log').mockImplementation(() => {}) + await call(env, 'POST', '/notes-import/challenge', undefined, { + 'cf-connecting-ip': '198.18.0.1', + }) + expect(limit).toHaveBeenCalledWith({ key: 'notes-import:198.18.0.1' }) + }) + + it('keeps accepting the old verify purpose', async () => { + const { env, verifyAssertion } = makeEnv() + vi.spyOn(console, 'log').mockImplementation(() => {}) + const response = await call( + env, + 'POST', + '/notes-import/verify', + v2VerifyBody('notes-import-verify') + ) + expect(response.status).toBe(200) + expect(verifyAssertion).toHaveBeenCalledOnce() + }) +}) + +describe('attestation purposes', () => { + it('keeps every shipped purpose', () => { + // Installed apps sign these; renaming one breaks them. + for (const purpose of [ + 'notes-import-kickoff', + 'notes-import-verify', + 'notes-import-transcribe', + 'attestation-verify', + ]) { + expect(isAttestationPurpose(purpose)).toBe(true) + } + expect(isAttestationPurpose('notes-import')).toBe(false) + }) + + it('fit the signed-field alphabet', () => { + for (const purpose of ATTESTATION_PURPOSES) { + expect(purpose).toMatch(/^[a-z0-9-]{1,64}$/) + } + }) +}) + +describe('requireAttestation', () => { + const context = (env: Environment) => + ({ + env, + json: (body: unknown, status = 200) => Response.json(body, { status }), + }) as unknown as AppContext + + it('passes an App Attest v2 assertion signed for the purpose', async () => { + const { env, verifyAssertion } = makeEnv() + const record = v2VerifyBody('attestation-verify') + await expect( + requireAttestation(context(env), record, 'attestation-verify') + ).resolves.toBeNull() + expect(verifyAssertion).toHaveBeenCalledWith(record) + }) + + it('refuses App Attest v1 and other purposes as unsupported', async () => { + const { env, verifyAssertion } = makeEnv() + for (const record of [ + { keyId: 'k', challenge: 'c', assertion: 'a' }, + v2VerifyBody('notes-import-kickoff'), + ]) { + const response = await requireAttestation( + context(env), + record, + 'attestation-verify' + ) + expect(response?.status).toBe(400) + await expect(response?.json()).resolves.toMatchObject({ + reason: 'unsupported_protocol', + }) + } + expect(verifyAssertion).not.toHaveBeenCalled() + }) +}) diff --git a/src/attestation/route.ts b/src/attestation/route.ts new file mode 100644 index 0000000..e0375ee --- /dev/null +++ b/src/attestation/route.ts @@ -0,0 +1,336 @@ +import type { MiddlewareHandler } from 'hono' +import type { AppContext, Environment } from '../types' +import { HTTP_STATUS } from '../config' +import { Sentry } from '../sentry' +import { matchesDevBypass, resolveDevBypassToken } from '../devBypass' +import { + appAttestLifecycle, + appAttestProtocolVersion, + AppAttestError, +} from '../appAttest' +import { + isAppAttestOperationId, + type V2AssertionFinalRequest, +} from '../appAttest/protocol' +import { + PlayIntegrityError, + isPlayIntegrityRequest, + issuePlayIntegrityChallenge, + parsePlayIntegrityChallengeRequest, +} from '../playIntegrity' +import { attestationProviders } from './providers' +import { + ATTESTATION_VERIFY_PURPOSE, + NOTES_IMPORT_VERIFY_PURPOSE, + isVerifyPurpose, +} from './purposes' +import { + appAttestFailureResponse, + asRecord, + asString, + attestationBadRequest, + attestationError, + isLowercaseSha256, + isValidAccountId, + issueV2AssertionChallenge, + parseV2AssertionChallengeRequest, + parseV2AssertionFinalRequest, + playIntegrityFailureResponse, + unsupportedProtocol, + verifyPlayIntegrityRecord, + verifyV2Assertion, +} from './http' + +/** + * `/attestation/v1/*`: device attestation for the whole API, App Attest on + * iOS and Play Integrity on Android. A device gets a challenge here, registers + * its App Attest key here, and can prove the whole path with `verify`; each + * protected route then checks its own assertion (src/attestation/http.ts). + * + * These lived under `/notes-import/` until 2026-10. The old paths still serve + * the same handlers for deployed apps, marked with `deprecatedPath`. + */ + +export const ATTESTATION_BASE_PATH = '/attestation/v1' + +const readOptionalJson = async ( + ctx: AppContext +): Promise<{ ok: true; value: unknown } | { ok: false }> => { + const text = await ctx.req.text() + if (!text.trim()) return { ok: true, value: undefined } + try { + return { ok: true, value: JSON.parse(text) as unknown } + } catch { + return { ok: false } + } +} + +/** POST /attestation/v1/challenge: an App Attest (v1 or v2) or Play Integrity challenge. */ +export async function handleAttestationChallengeRequest(ctx: AppContext) { + const parsed = await readOptionalJson(ctx) + if (!parsed.ok) return attestationBadRequest(ctx, 'Invalid JSON') + if (parsed.value !== undefined && !asRecord(parsed.value)) { + return attestationBadRequest(ctx, 'Invalid JSON body') + } + const record = asRecord(parsed.value) + if (record && isPlayIntegrityRequest(record)) { + const request = parsePlayIntegrityChallengeRequest(record) + if (!request) { + return attestationBadRequest(ctx, 'Invalid Play Integrity challenge') + } + try { + return ctx.json(await issuePlayIntegrityChallenge(ctx.env, request)) + } catch (e) { + if (e instanceof PlayIntegrityError) { + return playIntegrityFailureResponse(ctx, e) + } + Sentry.captureException(e) + return attestationError( + ctx, + HTTP_STATUS.INTERNAL_SERVER_ERROR, + 'Challenge error', + 'server_error' + ) + } + } + const isV2Assertion = + record?.protocolVersion === 2 && record.operation === 'assert' + const v2AssertionRequest = isV2Assertion + ? parseV2AssertionChallengeRequest(record) + : null + if (isV2Assertion && !v2AssertionRequest) { + return attestationBadRequest( + ctx, + 'Invalid App Attest v2 assertion challenge' + ) + } + + try { + const response = v2AssertionRequest + ? await issueV2AssertionChallenge(ctx, v2AssertionRequest) + : await appAttestLifecycle(ctx.env).issueChallenge(parsed.value) + return ctx.json(response) + } catch (e) { + if (e instanceof AppAttestError) { + return appAttestFailureResponse(ctx, e, parsed.value) + } + Sentry.captureException(e) + return attestationError( + ctx, + HTTP_STATUS.INTERNAL_SERVER_ERROR, + 'Challenge error', + 'server_error' + ) + } +} + +/** POST /attestation/v1/register: bind an App Attest key or enroll v2 recovery. */ +export async function handleAttestationRegisterRequest(ctx: AppContext) { + let parsed: unknown + try { + parsed = await ctx.req.json() + } catch { + return attestationBadRequest(ctx, 'Invalid JSON') + } + const body = asRecord(parsed) + if (!body) return attestationBadRequest(ctx, 'Invalid JSON body') + // Play Integrity has no key to register; every request carries a token. + if (isPlayIntegrityRequest(body)) { + return unsupportedProtocol(ctx, 'Play Integrity has no registration step') + } + + // Keep the deployed v1 request gate and top-level error text unchanged. + if (appAttestProtocolVersion(body) === 1) { + const keyId = asString(body.keyId) + const attestation = asString(body.attestation) + const challenge = asString(body.challenge) + const uuid = asString(body.uuid) + if (!keyId || !attestation || !challenge || !uuid) { + return attestationBadRequest( + ctx, + 'Missing keyId, attestation, challenge, or uuid' + ) + } + } + + try { + const response = await appAttestLifecycle(ctx.env).register(body) + return ctx.json(response) + } catch (e) { + if (e instanceof AppAttestError) { + return appAttestFailureResponse(ctx, e, body) + } + Sentry.captureException(e) + return attestationError( + ctx, + HTTP_STATUS.INTERNAL_SERVER_ERROR, + 'Attestation error', + 'server_error' + ) + } +} + +/** + * POST /attestation/v1/verify: an attested no-op for the app's developer + * tools. Runs the same verification the protected routes use (challenge + * consumption and sign-count advance included) but spends nothing, so the + * Tools screen can prove the whole attestation path server-side. There is no + * payload, so `requestHash` must equal the client-chosen `contentHash`. + * + * The purpose is `attestation-verify`; `notes-import-verify`, which deployed + * apps sign, is accepted too, on both paths. + */ +export async function handleAttestationVerifyRequest(ctx: AppContext) { + let parsed: unknown + try { + parsed = await ctx.req.json() + } catch { + return attestationBadRequest(ctx, 'Invalid JSON') + } + const body = asRecord(parsed) + if (!body) return attestationBadRequest(ctx, 'Invalid JSON body') + + const devBypass = matchesDevBypass( + resolveDevBypassToken(ctx.env), + ctx.req.header('x-ww-dev-bypass') + ) + if (devBypass) { + const version = appAttestProtocolVersion(body) + if (version === 2) { + const operationId = asString(body.operationId) + const contentHash = asString(body.contentHash) + const requestHash = asString(body.requestHash) + if ( + !operationId || + !isAppAttestOperationId(operationId) || + body.operation !== 'assert' || + !isVerifyPurpose(body.purpose) || + !contentHash || + !isLowercaseSha256(contentHash) || + requestHash !== contentHash + ) { + return attestationBadRequest(ctx, 'Invalid App Attest v2 bypass probe') + } + return ctx.json({ ok: true, protocolVersion: 2, operationId }) + } + return ctx.json({ ok: true }) + } + + // An unknown purpose falls through to a purpose mismatch below. + const purpose = isVerifyPurpose(body.purpose) + ? body.purpose + : ATTESTATION_VERIFY_PURPOSE + + if (isPlayIntegrityRequest(body)) { + // Same no-payload probe contract as App Attest v2 verify. + if (body.requestHash !== body.contentHash) { + return attestationBadRequest( + ctx, + 'requestHash must equal contentHash for Play Integrity verify' + ) + } + const failure = await verifyPlayIntegrityRecord(ctx, body, purpose) + if (failure) return failure + return ctx.json({ + ok: true, + attestationProvider: 'play-integrity', + protocolVersion: 1, + operationId: body.operationId, + }) + } + + const uuid = asString(body.uuid) + const keyId = asString(body.keyId) + const challenge = asString(body.challenge) + const assertion = asString(body.assertion) + const contentHash = asString(body.contentHash) + if (!uuid || !keyId || !challenge || !assertion || !contentHash) { + return attestationBadRequest( + ctx, + 'Missing uuid, keyId, challenge, assertion, or contentHash' + ) + } + // Same shape gates as the protected routes: both values end up in the + // `|`-delimited signed clientData, so keep them to their bounded alphabets. + const accountId = body.accountId != null ? asString(body.accountId) : null + if (body.accountId != null && (!accountId || !isValidAccountId(accountId))) { + return attestationBadRequest(ctx, 'Invalid accountId') + } + if (!isLowercaseSha256(contentHash)) { + return attestationBadRequest(ctx, 'Invalid contentHash') + } + + const version = appAttestProtocolVersion(body) + let v2Request: V2AssertionFinalRequest | null = null + if (version === 2) { + const requestHash = asString(body.requestHash) + if ( + !requestHash || + !isLowercaseSha256(requestHash) || + requestHash !== contentHash + ) { + return attestationBadRequest( + ctx, + 'requestHash must equal contentHash for App Attest verify' + ) + } + v2Request = parseV2AssertionFinalRequest(body, purpose) + if (!v2Request) { + return attestationBadRequest( + ctx, + 'Invalid App Attest v2 verify assertion' + ) + } + } + + try { + const response = v2Request + ? await verifyV2Assertion(ctx, v2Request) + : // v1 doesn't sign a purpose; the binding still names one. + await appAttestLifecycle(ctx.env).verifyAssertion(body, { + uuid, + accountId: accountId ?? undefined, + contentHash, + purpose: NOTES_IMPORT_VERIFY_PURPOSE, + }) + return ctx.json(response) + } catch (e) { + if (e instanceof AppAttestError) { + return appAttestFailureResponse(ctx, e, body) + } + Sentry.captureException(e) + return attestationError( + ctx, + HTTP_STATUS.INTERNAL_SERVER_ERROR, + 'Verify error', + 'server_error' + ) + } +} + +/** GET /attestation/v1/status: the providers this worker accepts. No I/O. */ +export function handleAttestationStatusRequest(ctx: AppContext) { + return ctx.json({ providers: attestationProviders(ctx.env) }, 200, { + 'Cache-Control': 'no-store', + }) +} + +/** When the `/notes-import/*` attestation paths were deprecated (RFC 9745). */ +const DEPRECATED_AT = `@${Date.UTC(2026, 9, 15) / 1000}` + +/** + * Marks an old path that serves the same handler as its successor: adds + * `Deprecation` (RFC 9745) and a `successor-version` link, and logs the hit + * (route names only) so Workers Logs shows when old clients stop calling it. + */ +export const deprecatedPath = + ( + path: string, + successor: string + ): MiddlewareHandler<{ Bindings: Environment }> => + async (ctx, next) => { + console.log(`deprecated path: ${path} -> ${successor}`) + await next() + ctx.res.headers.set('Deprecation', DEPRECATED_AT) + ctx.res.headers.append('Link', `<${successor}>; rel="successor-version"`) + } diff --git a/src/buddies/invitePage.test.ts b/src/buddies/invitePage.test.ts index a988f9f..36c1b7a 100644 --- a/src/buddies/invitePage.test.ts +++ b/src/buddies/invitePage.test.ts @@ -7,9 +7,27 @@ import { handleBuddyInvitePage } from './invitePage' const app = new Hono<{ Bindings: Environment }>() app.get('/.well-known/apple-app-site-association', handleAasaRequest) app.get('/b', handleBuddyInvitePage) +app.get('/buddies/invite', handleBuddyInvitePage) const env = { APPLE_TEAM_ID: 'TEAMID1234' } as Environment +describe('GET /buddies/invite', () => { + it('serves the /b page under its own og:url', async () => { + const [short, readable] = await Promise.all( + ['/b', '/buddies/invite'].map(async (path) => + (await app.request(path, {}, env)).text() + ) + ) + expect(readable).toContain( + 'property="og:url" content="https://ww-proxy.leviwilkerson.com/buddies/invite"' + ) + expect(short).toContain( + 'property="og:url" content="https://ww-proxy.leviwilkerson.com/b"' + ) + expect(readable.replace('/buddies/invite"', '/b"')).toBe(short) + }) +}) + describe('GET /b', () => { it('serves a static, generic, noindex page with the install steps and a copy button', async () => { const response = await app.request('/b', {}, env) @@ -84,6 +102,9 @@ describe('apple-app-site-association', () => { 'TEAMID1234.com.leviwilkerson.jwtimedev', ]) expect(details.components).toEqual([ + { '/': '/contacts', '#': '?*' }, + { '/': '/buddies/invite', '#': '1?*' }, + { '/': '/accounts/handoff', '#': '1?*' }, { '/': '/c', '#': '?*' }, { '/': '/c/*' }, { '/': '/b', '#': '1?*' }, diff --git a/src/buddies/invitePage.ts b/src/buddies/invitePage.ts index 53a587e..2cc9b42 100644 --- a/src/buddies/invitePage.ts +++ b/src/buddies/invitePage.ts @@ -1,8 +1,9 @@ import type { AppContext } from '../types' /** - * `GET /b` — no-app fallback for Buddy invite links - * (`https://ww-proxy.leviwilkerson.com/b#1`). + * `GET /buddies/invite` — no-app fallback for Buddy invite links + * (`https://ww-proxy.leviwilkerson.com/buddies/invite#1`). The short + * `/b`, in invites already sent, serves the same page. * * The secret lives in the fragment, which browsers never send to a server. * This handler reads nothing from the request and the page is fully static and @@ -52,7 +53,12 @@ const COPY_SCRIPT = ` })(); ` -const PAGE_HTML = ` +const INVITE_PATH = '/buddies/invite' +const SHORT_INVITE_PATH = '/b' + +const renderPage = ( + path: typeof INVITE_PATH | typeof SHORT_INVITE_PATH +): string => ` @@ -67,7 +73,7 @@ const PAGE_HTML = ` - + @@ -164,7 +170,9 @@ export const handleBuddyInvitePage = async ( "form-action 'none'", "frame-ancestors 'none'", ].join('; ') - return context.html(PAGE_HTML, 200, { + const path = + context.req.path === SHORT_INVITE_PATH ? SHORT_INVITE_PATH : INVITE_PATH + return context.html(renderPage(path), 200, { 'Cache-Control': 'public, max-age=3600', 'Content-Security-Policy': policy, 'Referrer-Policy': 'no-referrer', diff --git a/src/contactLink.test.ts b/src/contactLink.test.ts index 04446ef..56fd5d2 100644 --- a/src/contactLink.test.ts +++ b/src/contactLink.test.ts @@ -7,6 +7,7 @@ import worker from './index' const ORIGIN = 'https://ww-proxy.leviwilkerson.com' const CANONICAL_URL = `${ORIGIN}/c` +const CONTACTS_URL = `${ORIGIN}/contacts` // gzip + base64url contact JSON, exactly as the app encodes it. const PAYLOAD = 'H4sIAAAAAAAAE42MMQvCMBQG_0r4VpPyUohgNsHJ2ckttA8MYl5IglVK_7tEcBduvLsVTy41SoKHHQga7Z0ZHktsiWtdpNzNJKmFqUGDX1lK4_nY4DHSuDd0MGQvRP7LQERXaPwKvyLO8JgsNFJ49PM5JFYn4a4VDn_c8k1SL3dWOecUWSJs2wdjV8GKuwAAAA' @@ -67,6 +68,9 @@ describe('apple-app-site-association', () => { 'TEAM123456.com.leviwilkerson.jwtimedev', ], components: [ + { '/': '/contacts', '#': '?*' }, + { '/': '/buddies/invite', '#': '1?*' }, + { '/': '/accounts/handoff', '#': '1?*' }, { '/': '/c', '#': '?*' }, { '/': '/c/*' }, { '/': '/b', '#': '1?*' }, @@ -112,6 +116,25 @@ describe('GET /c', () => { }) }) +describe('GET /contacts', () => { + it('serves the /c page under its own og:url and never reflects a payload', async () => { + for (const path of ['/contacts', `/contacts?${PAYLOAD}`]) { + const page = await html(path) + expect(page).not.toContain(PAYLOAD) + expect(ogUrl(page)).toBe(CONTACTS_URL) + expect(page).toContain(' + renderFallbackPage(locale, path, ` `) ) } @@ -148,7 +162,10 @@ export function handleLegacyContactLinkRequest(context: AppContext) { const openAppLink = BASE64URL.test(payload) ? `${escapeHtml(contactLinkTranslations[locale].openApp)}` : '' - return context.html(renderFallbackPage(locale, openAppLink)) + // Never the request path: it holds the payload. + return context.html( + renderFallbackPage(locale, SHORT_CONTACT_LINK_PATH, openAppLink) + ) } function contactLinkLocale(context: AppContext): ContactLinkLocale { @@ -174,10 +191,14 @@ function escapeHtml(value: string): string { * Fallback HTML for taps that reach the worker (app not installed, non-iOS, * or Safari address-bar hit). Kept self-contained — no external fonts — so * iMessage's rich-link sniffer can render a fast preview. `og:url` is always - * the bare canonical URL, and `no-referrer` keeps legacy page URLs out of the - * Referer header of follow-up requests. + * a bare page URL (`/contacts` or `/c`, never a payload), and `no-referrer` + * keeps legacy page URLs out of the Referer header of follow-up requests. */ -function renderFallbackPage(locale: ContactLinkLocale, openAppLink: string): string { +function renderFallbackPage( + locale: ContactLinkLocale, + path: typeof CONTACT_LINK_PATH | typeof SHORT_CONTACT_LINK_PATH, + openAppLink: string +): string { const messages = contactLinkTranslations[locale] const title = escapeHtml(messages.title) const description = escapeHtml(messages.description) @@ -203,7 +224,7 @@ function renderFallbackPage(locale: ContactLinkLocale, openAppLink: string): str - + diff --git a/src/devBypass.ts b/src/devBypass.ts new file mode 100644 index 0000000..a1d989c --- /dev/null +++ b/src/devBypass.ts @@ -0,0 +1,30 @@ +import type { Environment } from './types' +import { timingSafeEqual } from './crypto' + +/** + * The development worker's shared `x-ww-dev-bypass` token + * (`NOTES_IMPORT_DEV_BYPASS_TOKEN`), or null on production. Throws when + * production has it set, or when `APP_ATTEST_ENVIRONMENT` isn't configured, so + * a misconfigured deploy fails loudly instead of skipping attestation. + */ +export const resolveDevBypassToken = ( + env: Pick +): string | null => { + const token = env.NOTES_IMPORT_DEV_BYPASS_TOKEN?.trim() || null + if (env.APP_ATTEST_ENVIRONMENT === 'production') { + if (token) { + throw new Error( + 'NOTES_IMPORT_DEV_BYPASS_TOKEN cannot be set in production' + ) + } + return null + } + if (env.APP_ATTEST_ENVIRONMENT === 'development') return token + throw new Error('APP_ATTEST_ENVIRONMENT must be explicitly configured') +} + +/** Whether `header` carries the dev bypass token (constant-time compare). */ +export const matchesDevBypass = ( + token: string | null, + header: string | undefined +): boolean => token != null && timingSafeEqual(header ?? '', token) diff --git a/src/e2e/accounts.e2e.test.ts b/src/e2e/accounts.e2e.test.ts index e7fba9c..4eec027 100644 --- a/src/e2e/accounts.e2e.test.ts +++ b/src/e2e/accounts.e2e.test.ts @@ -226,9 +226,9 @@ describe('POST /accounts/v1/backup/*', () => { }) }) -describe('GET /h', () => { - it('serves the scan-from-the-app page', async () => { - const res = await http('GET', '/h') +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) expect(res.status).toBe(200) expect(res.text).toContain('Scan this code in WitnessWork') }) diff --git a/src/e2e/notesImport.e2e.test.ts b/src/e2e/notesImport.e2e.test.ts index 8fa0941..8cf58ae 100644 --- a/src/e2e/notesImport.e2e.test.ts +++ b/src/e2e/notesImport.e2e.test.ts @@ -3,8 +3,9 @@ import { createHash, randomUUID } from 'node:crypto' import { DEV_BYPASS_TOKEN, configured, http, writeTranscript } from '../test/e2e' /** - * Notes Import without inference: the dev-bypass verify probe, the App Attest - * challenge, and kickoff's auth gate. A real kickoff calls OpenRouter (paid), + * Notes Import without inference: the attestation verify probe (dev bypass), + * the App Attest challenge, their old /notes-import paths, and kickoff's auth + * gate. A real kickoff calls OpenRouter (paid), * so it only runs with WW_API_E2E_ALLOW_PAID=1 plus a dev bypass. Local * workers also need OPENROUTER_API_KEY; deployed workers use their own secret. */ @@ -21,9 +22,9 @@ const bypass = (token = DEV_BYPASS_TOKEN ?? '') => ({ }) const sha256 = (text: string) => createHash('sha256').update(text).digest('hex') -describe.skipIf(!DEV_BYPASS_TOKEN)('POST /notes-import/verify with the dev bypass', () => { +describe.skipIf(!DEV_BYPASS_TOKEN)('POST /attestation/v1/verify with the dev bypass', () => { it('accepts a v1 probe', async () => { - const res = await http('POST', '/notes-import/verify', { + const res = await http('POST', '/attestation/v1/verify', { body: {}, headers: bypass(), }) @@ -34,13 +35,13 @@ describe.skipIf(!DEV_BYPASS_TOKEN)('POST /notes-import/verify with the dev bypas it('echoes a valid v2 probe operationId', async () => { const hash = sha256('verify-probe') const operationId = `op_${randomUUID().replaceAll('-', '')}` - const res = await http('POST', '/notes-import/verify', { + const res = await http('POST', '/attestation/v1/verify', { headers: bypass(), body: { protocolVersion: 2, operation: 'assert', operationId, - purpose: 'notes-import-verify', + purpose: 'attestation-verify', contentHash: hash, requestHash: hash, }, @@ -50,13 +51,13 @@ describe.skipIf(!DEV_BYPASS_TOKEN)('POST /notes-import/verify with the dev bypas }) it('rejects a v2 probe whose requestHash differs', async () => { - const res = await http('POST', '/notes-import/verify', { + const res = await http('POST', '/attestation/v1/verify', { headers: bypass(), body: { protocolVersion: 2, operation: 'assert', operationId: 'op_mismatch01', - purpose: 'notes-import-verify', + purpose: 'attestation-verify', contentHash: sha256('a'), requestHash: sha256('b'), }, @@ -65,7 +66,7 @@ describe.skipIf(!DEV_BYPASS_TOKEN)('POST /notes-import/verify with the dev bypas }) it('treats a wrong bypass token as an unattested request', async () => { - const res = await http('POST', '/notes-import/verify', { + const res = await http('POST', '/attestation/v1/verify', { body: {}, headers: bypass('not-the-token'), }) @@ -73,8 +74,28 @@ describe.skipIf(!DEV_BYPASS_TOKEN)('POST /notes-import/verify with the dev bypas expect(res.text).toContain('Missing uuid') }) - it('rejects malformed JSON', async () => { + it('keeps the old path and purpose working, marked deprecated', async () => { + const hash = sha256('verify-probe') + const operationId = `op_${randomUUID().replaceAll('-', '')}` const res = await http('POST', '/notes-import/verify', { + headers: bypass(), + body: { + protocolVersion: 2, + operation: 'assert', + operationId, + purpose: 'notes-import-verify', + contentHash: hash, + requestHash: hash, + }, + }) + expect(res.status).toBe(200) + expect(res.body).toEqual({ ok: true, protocolVersion: 2, operationId }) + expect(res.headers.get('deprecation')).toMatch(/^@\d+$/) + expect(res.headers.get('link')).toBe('; rel="successor-version"') + }) + + it('rejects malformed JSON', async () => { + const res = await http('POST', '/attestation/v1/verify', { raw: '{not json', headers: bypass(), }) @@ -83,11 +104,26 @@ describe.skipIf(!DEV_BYPASS_TOKEN)('POST /notes-import/verify with the dev bypas }) describe('App Attest handshake (no inference)', () => { - it('POST /notes-import/challenge issues a challenge', async () => { - const res = await http('POST', '/notes-import/challenge', { body: {} }) + it('POST /attestation/v1/challenge issues a challenge', async () => { + const res = await http('POST', '/attestation/v1/challenge', { body: {} }) expect(res.status).toBe(200) expect(typeof res.body.challenge).toBe('string') expect(res.body.challenge.length).toBeGreaterThan(16) + expect(res.headers.get('deprecation')).toBeNull() + }) + + it('POST /notes-import/challenge still issues one, marked deprecated', async () => { + const res = await http('POST', '/notes-import/challenge', { body: {} }) + expect(res.status).toBe(200) + expect(typeof res.body.challenge).toBe('string') + expect(res.headers.get('deprecation')).toMatch(/^@\d+$/) + expect(res.headers.get('link')).toBe('; rel="successor-version"') + }) + + it('GET /attestation/v1/status lists App Attest', async () => { + const res = await http('GET', '/attestation/v1/status') + expect(res.status).toBe(200) + expect(res.body.providers.appAttest.protocolVersions).toEqual([1, 2]) }) it('kickoff without credentials is refused before any model call', async () => { diff --git a/src/e2e/public.e2e.test.ts b/src/e2e/public.e2e.test.ts index 4d5dc7e..f16d3d3 100644 --- a/src/e2e/public.e2e.test.ts +++ b/src/e2e/public.e2e.test.ts @@ -120,7 +120,7 @@ describe('app store ratings', () => { }) describe('universal links', () => { - it('serves the AASA as JSON with /c, /c/*, and /b#1 components', async () => { + it('serves the AASA as JSON with the readable and short link paths', async () => { const res = await http('GET', '/.well-known/apple-app-site-association') expect(res.status).toBe(200) expect(res.headers.get('content-type')).toContain('application/json') @@ -130,6 +130,9 @@ describe('universal links', () => { expect(appId).toMatch(/^[A-Z0-9]{10}\.com\.leviwilkerson\.jwtime(beta|dev)?$/) } expect(detail.components).toEqual([ + { '/': '/contacts', '#': '?*' }, + { '/': '/buddies/invite', '#': '1?*' }, + { '/': '/accounts/handoff', '#': '1?*' }, { '/': '/c', '#': '?*' }, { '/': '/c/*' }, { '/': '/b', '#': '1?*' }, @@ -137,14 +140,15 @@ describe('universal links', () => { ]) }) - it('GET /c renders the localized contact fallback page', async () => { - const english = await http('GET', '/c') + it.each(['/contacts', '/c'])('GET %s renders the localized contact fallback page', async (path) => { + const english = await http('GET', path) expect(english.status).toBe(200) expect(english.headers.get('content-type')).toContain('text/html') expect(english.text).toContain('id="open-app"') expect(english.text).toContain('witnesswork://import-contact/') + expect(english.text).toContain(`property="og:url" content="https://ww-proxy.leviwilkerson.com${path}"`) - const spanish = await http('GET', '/c?lang=es') + const spanish = await http('GET', `${path}?lang=es`) expect(spanish.status).toBe(200) expect(spanish.headers.get('content-language')).toMatch(/^es/) expect(spanish.text).toContain(' { const appAttestConfig = ( env: Environment -): Pick => { - const token = env.NOTES_IMPORT_DEV_BYPASS_TOKEN?.trim() || null - if (env.APP_ATTEST_ENVIRONMENT === 'production') { - if (token) { - throw new Error( - 'NOTES_IMPORT_DEV_BYPASS_TOKEN cannot be set in production' - ) - } - return { devBypassToken: null, requireProduction: true } - } - if (env.APP_ATTEST_ENVIRONMENT === 'development') { - return { devBypassToken: token, requireProduction: false } - } - throw new Error('APP_ATTEST_ENVIRONMENT must be explicitly configured') -} +): Pick => ({ + devBypassToken: resolveDevBypassToken(env), + requireProduction: env.APP_ATTEST_ENVIRONMENT === 'production', +}) /** Read alone by the public status probe, which has no App Attest config. */ export const resolveTranscribeMaxBytes = (env: Environment): number => diff --git a/src/notesImport/openapi.ts b/src/notesImport/openapi.ts index bc2c227..b588ab2 100644 --- a/src/notesImport/openapi.ts +++ b/src/notesImport/openapi.ts @@ -1,13 +1,25 @@ import { z } from 'zod' import { describeRoute } from 'hono-openapi' import { - apiErrorSchema, errorResponse, - json, jsonBody, jsonResponse, rateLimitedResponse, } from '../openapi/schemas' +import { + accountId, + appAttestV2AssertionFields, + ATTESTATION_NOTE, + attestationErrorSchema, + attestationProvidersShape, + attestationResponses, + devBypassHeader, + installId, + playIntegrityFields, + sha256Hex, + storageUnavailable, + V1_CODES, +} from '../attestation/openapi' import { notesImportResultValidator } from './schema' /** @@ -18,83 +30,6 @@ import { notesImportResultValidator } from './schema' const TAG = 'Notes Import' -// --- Shared fields ------------------------------------------------------- - -const sha256Hex = (description: string) => - z - .string() - .regex(/^[a-f0-9]{64}$/) - .meta({ description: `${description} (lowercase hex SHA-256)` }) - -const installId = z - .string() - .regex(/^[A-Za-z0-9_-]{8,64}$/) - .meta({ - description: - 'The install id (`uuid`). App Attest keys are pinned to it; usage is metered on it when no `accountId` is sent.', - }) - -const accountId = z - .string() - .regex(/^[A-Za-z0-9_-]{8,64}$/) - .optional() - .meta({ - description: - 'Optional shared account id. When present, Supporter status, allowances, the concurrency cap, and replay all key on it instead of `uuid`. A malformed value is a 400, never ignored.', - }) - -const operationId = z - .string() - .regex(/^[A-Za-z0-9_-]{8,128}$/) - .meta({ - description: - 'Client-chosen id for one challenge → request round trip. Reusing it with different data is `operation_conflict`.', - }) - -const keyId = z - .string() - .regex(/^[A-Za-z0-9+/]{43}=$/) - .meta({ - description: 'The App Attest key id (standard base64 of 32 bytes).', - }) - -const challenge = z - .string() - .regex(/^[A-Za-z0-9_-]{43}$/) - .meta({ - description: - 'The one-time server challenge from `POST /notes-import/challenge` (43-character base64url). Consumed on use.', - }) - -const purpose = z - .enum([ - 'notes-import-kickoff', - 'notes-import-verify', - 'notes-import-transcribe', - ]) - .meta({ - description: - 'Which protected request the challenge is for. It must match the route that consumes it.', - }) - -const expiresAt = z.number().int().meta({ - description: 'When the challenge expires (epoch ms; 5 minutes after issue).', -}) - -const playIntegrityProvider = z.literal('play-integrity').meta({ - description: - 'Opts the request into the Android Play Integrity path. Bodies without it take the App Attest path.', -}) - -const devBypassHeader = { - 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. A match skips attestation and the RevenueCat check and uses the Supporter allowances. Production never sets the token, so the header is ignored there.', -} - // --- Shared bodies and responses ---------------------------------------- const creditsSchema = z @@ -130,31 +65,12 @@ const creditsSchema = z 'Usage snapshot, shared by kickoff, `done`, the legacy response, and allowance denials.', }) -const notesImportErrorSchema = apiErrorSchema +const notesImportErrorSchema = attestationErrorSchema .extend({ detail: z.string().optional().meta({ description: 'On `unavailable`, why (`disabled`, `no_provider`, or an operator message). On the development worker, a debug detail for `server_error` and `model_error`.', }), - reason: z.string().optional().meta({ - description: - 'Stable App Attest or Play Integrity reason, on attestation failures: `invalid_request`, `unsupported_protocol`, `operation_conflict`, `too_many_challenges`, `challenge_not_found`, `challenge_expired`, `identity_not_bound`, `key_not_active`, `recovery_not_enrolled`, `recovery_token_mismatch`, `attestation_invalid`, `assertion_invalid`, `counter_not_increasing`, `storage_unavailable`; Android adds `integrity_unavailable`, `integrity_token_invalid`, `app_not_recognized`, `device_integrity_failed`.', - }), - action: z - .enum([ - 'none', - 'retry', - 'request_challenge', - 'start_new_operation', - 'bind', - 'enroll_recovery', - 'use_active_key', - ]) - .optional() - .meta({ - description: - 'What the client should do next, beside `reason`. Transient failures never ask for key rotation.', - }), credits: creditsSchema.optional().meta({ description: 'On `limit_reached` and `refinement_limit`.', }), @@ -168,67 +84,9 @@ const notesImportErrorSchema = apiErrorSchema const niError = (description: string, codes: readonly string[]) => errorResponse(description, codes, notesImportErrorSchema) -const V1_CODES = [ - 'device_verification_failed', - 'assertion_failed', - 'challenge_expired', - 'counter_conflict', -] as const - -const ATTESTATION_NOTE = - 'App Attest v1 (legacy) failures are always 401 with `device_verification_failed`, `assertion_failed`, `challenge_expired`, or `counter_conflict`, which never ask the client to rotate its key. App Attest v2 and Play Integrity failures are `attestation_failed` at the reason’s own status; branch on `reason`/`action`.' - const killSwitchUnavailable = '`unavailable`: the kill switch is off or no allowlisted model host is healthy (`detail` says which; `Retry-After: 60`). ' -const storageUnavailable = - '`server_error`: attestation storage or Google’s decode service is unavailable (`Retry-After: 30`).' - -const attestationResponses = { - 401: niError( - `Attestation failed: an unknown, consumed, or expired challenge, an invalid assertion or token, or an identity whose key isn’t bound or active. ${ATTESTATION_NOTE}`, - ['attestation_failed', ...V1_CODES] - ), - 403: niError( - '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`).', - ['attestation_failed'] - ), - 409: niError( - 'App Attest v2 / Play Integrity: `operationId` was used with different data (`operation_conflict`), or the assertion’s sign count didn’t increase (`counter_not_increasing`). Start a new operation.', - ['attestation_failed'] - ), -} - -// --- Attested request fields -------------------------------------------- - -/** App Attest v2 assertion fields shared by verify, kickoff, and transcribe. */ -const appAttestV2AssertionFields = (forPurpose: string) => ({ - protocolVersion: z.literal(2), - operation: z.literal('assert'), - operationId, - keyId, - purpose: z.literal(forPurpose), - challenge, - assertion: z.string().max(65_536).meta({ - description: - 'App Attest assertion (base64) over SHA-256 of the client data `witnesswork.app-attest|2|assert|||||||`.', - }), -}) - -/** Play Integrity fields shared by verify, kickoff, and transcribe. */ -const playIntegrityFields = (forPurpose: string) => ({ - attestationProvider: playIntegrityProvider, - protocolVersion: z.literal(1), - operation: z.literal('assert'), - operationId, - purpose: z.literal(forPurpose), - challenge, - integrityToken: z.string().max(32_768).meta({ - description: - 'A Play Integrity standard-request token whose `requestHash` is the lowercase hex SHA-256 of `witnesswork.play-integrity|1|assert|||||||`. Never send `keyId` or `assertion` with it.', - }), -}) - // --- GET /notes-import/status -------------------------------------------- const allowancePair = z.object({ @@ -237,26 +95,8 @@ const allowancePair = z.object({ }) const capabilitiesSchema = z.object({ - appAttest: z.object({ - protocolVersions: z.array(z.number().int()).meta({ - description: 'App Attest protocol versions accepted: `[1, 2]`.', - }), - }), - playIntegrity: z - .object({ - protocolVersions: z.array(z.number().int()).meta({ - description: 'Play Integrity protocol versions accepted: `[1]`.', - }), - cloudProjectNumber: z.string().meta({ - description: - 'The Google Cloud project number the app passes to the Play Integrity standard-request provider.', - }), - }) - .optional() - .meta({ - description: - 'Present only when this worker can verify Android requests. Android keeps Notes Import closed while it is absent.', - }), + // The same as GET /attestation/v1/status `providers`. + ...attestationProvidersShape, transcribe: z .object({ maxPages: z @@ -322,324 +162,6 @@ Completed responses are reused in each Worker isolate for up to 30 seconds (KV r }, }) -// --- POST /notes-import/challenge ------------------------------------------ - -const challengeRequestSchema = z.union([ - z - .object({ - protocolVersion: z.literal(1).optional(), - }) - .meta({ - title: 'App Attest v1 (legacy)', - description: - 'An empty body, `{}`, or `{ "protocolVersion": 1 }`. Returns a bare challenge for a v1 attestation or assertion.', - }), - z - .object({ - protocolVersion: z.literal(2), - operation: z.enum(['bind', 'enroll']), - operationId, - uuid: installId, - keyId, - }) - .meta({ - title: 'App Attest v2 bind / enroll', - description: - 'A challenge for `POST /notes-import/attest`: `bind` attests a new key, `enroll` adds a recovery token with the active key. No assertion fields allowed.', - }), - z - .object({ - protocolVersion: z.literal(2), - operation: z.literal('assert'), - operationId, - uuid: installId, - keyId, - purpose, - accountId, - contentHash: sha256Hex( - 'The content hash the protected request will carry' - ), - requestHash: sha256Hex( - 'The request hash the protected request will carry' - ), - }) - .meta({ - title: 'App Attest v2 assert', - description: - 'A challenge bound to one protected request (verify, kickoff, or transcribe). The later request must repeat these values exactly.', - }), - z - .object({ - attestationProvider: playIntegrityProvider, - protocolVersion: z.literal(1), - operation: z.literal('assert'), - operationId, - uuid: installId, - accountId, - purpose, - contentHash: sha256Hex( - 'The content hash the protected request will carry' - ), - requestHash: sha256Hex( - 'The request hash the protected request will carry' - ), - }) - .meta({ - title: 'Play Integrity', - description: - 'Android. Same binding as the App Attest v2 assert challenge, without `keyId`.', - }), -]) - -const challengeResponseSchema = z.union([ - z.object({ challenge: z.string() }).meta({ title: 'App Attest v1 (legacy)' }), - z - .object({ - protocolVersion: z.literal(2), - operation: z.enum(['bind', 'enroll', 'assert']), - operationId, - challenge, - expiresAt, - }) - .meta({ title: 'App Attest v2' }), - z - .object({ - attestationProvider: z.literal('play-integrity'), - protocolVersion: z.literal(1), - operation: z.literal('assert'), - operationId, - challenge, - expiresAt, - }) - .meta({ title: 'Play Integrity' }), -]) - -export const challengeDocs = describeRoute({ - tags: [TAG], - summary: 'Issue an attestation challenge', - description: `Step one of every attested call. Returns a one-time challenge (5-minute lifetime) for App Attest v1, App Attest v2 (\`bind\`, \`enroll\`, or \`assert\`), or Play Integrity. - -v2 and Play Integrity challenges are bound to their request: the descriptor (operation, \`operationId\`, \`uuid\`, \`keyId\`, purpose, \`accountId\`, and both hashes) is stored, and the consuming request must match it. Re-requesting the same \`operationId\` with the same data returns the outstanding challenge. At most 32 unexpired challenges per install. - -**Hashes.** \`contentHash\` is SHA-256 of \`notesText\` (kickoff), of the base64 \`image\` string as sent (transcribe), or any value the client picks (verify). \`requestHash\` is SHA-256 of the sorted-key canonical JSON \`{"context","notesText","refinement"}\` (kickoff; \`refinement\` is \`null\` when absent), of \`{"imageHash","mimeType"}\` (transcribe), or equal to \`contentHash\` (verify).`, - requestBody: { - required: false, - description: 'Optional; an empty body asks for a v1 challenge.', - content: json(challengeRequestSchema), - }, - responses: { - 200: jsonResponse('A challenge.', challengeResponseSchema), - 400: niError( - 'Invalid JSON, an invalid challenge request (`reason: invalid_request`), or an unsupported `protocolVersion` (`reason: unsupported_protocol`). v2 requests the lifecycle rejects answer `attestation_failed`.', - ['bad_request', 'attestation_failed'] - ), - 409: niError( - '`operationId` was already used with different data (`operation_conflict`).', - ['attestation_failed'] - ), - 429: niError( - 'Too many requests from this IP (`rate_limited`, `Retry-After: 60`), or 32 unexpired challenges for this install (`attestation_failed`, `reason: too_many_challenges`; Play Integrity sends `Retry-After: 300`).', - ['rate_limited', 'attestation_failed'] - ), - 500: niError('Unexpected failure.', ['server_error']), - 503: niError( - 'Challenge storage is unavailable, or Play Integrity isn’t configured on this worker (`reason: integrity_unavailable`). `Retry-After: 30`.', - ['server_error'] - ), - }, -}) - -// --- POST /notes-import/attest --------------------------------------------- - -const recoveryToken = z - .string() - .regex(/^[A-Za-z0-9_-]{43}$/) - .meta({ - description: - 'A 256-bit secret the app keeps (43-character base64url). Only a verifier is stored; it lets a reinstall or a new key take over the install id later.', - }) - -const attestRequestSchema = z.union([ - z - .object({ - protocolVersion: z.literal(1).optional(), - keyId: z.string(), - attestation: z.string().meta({ - description: 'App Attest attestation object (base64).', - }), - challenge: z.string(), - uuid: z.string(), - }) - .meta({ - title: 'App Attest v1 (legacy)', - description: 'Binds a key to the install id.', - }), - z - .object({ - protocolVersion: z.literal(2), - operation: z.literal('bind'), - operationId, - uuid: installId, - keyId, - challenge, - attestation: z.string().meta({ - description: - 'App Attest attestation object (base64) whose client data hash is SHA-256 of `witnesswork.app-attest|2|bind||||`, where `tokenHash` is base64url SHA-256 of the decoded recovery token.', - }), - recoveryToken, - }) - .meta({ - title: 'App Attest v2 bind', - description: - 'Attests a key for the install id with a recovery token. Rebinding the same install to a new key requires the recovery token enrolled earlier.', - }), - z - .object({ - protocolVersion: z.literal(2), - operation: z.literal('enroll'), - operationId, - uuid: installId, - keyId, - challenge, - assertion: z.string().max(65_536).meta({ - description: - 'Assertion by the active key over SHA-256 of `witnesswork.app-attest|2|enroll|||||`.', - }), - recoveryToken, - }) - .meta({ - title: 'App Attest v2 enroll', - description: - 'Adds a recovery token to an install already bound by a legacy key.', - }), -]) - -const attestResponseSchema = z.union([ - z.object({ ok: z.literal(true) }).meta({ title: 'App Attest v1 (legacy)' }), - z - .object({ - ok: z.literal(true), - protocolVersion: z.literal(2), - operationId, - status: z - .enum(['bound', 'already_bound', 'rotated', 'recovery_enrolled']) - .meta({ - description: - '`bound`: first key; `already_bound`: this key was already active; `rotated`: a new key replaced the old one with the recovery token; `recovery_enrolled`: enroll succeeded.', - }), - recoveryEnrolled: z.boolean(), - }) - .meta({ title: 'App Attest v2' }), -]) - -export const attestDocs = describeRoute({ - tags: [TAG], - summary: 'Register an App Attest key', - description: `iOS only. Binds a Secure Enclave key to the install id (v1 or v2 \`bind\`), or enrolls a v2 recovery token with the active key (\`enroll\`), using a challenge from \`POST /notes-import/challenge\`. - -The attested App ID must be \`.\` for an accepted bundle id (the App Store app, plus the TestFlight Beta app on production); the key stays bound to the one it matched. Play Integrity has no registration step: a body with \`attestationProvider: "play-integrity"\` is a 400 with \`reason: unsupported_protocol\`.`, - requestBody: jsonBody(attestRequestSchema), - responses: { - 200: jsonResponse('Registered.', attestResponseSchema), - 400: niError( - 'Invalid JSON, missing fields, a Play Integrity body, or (v2) an invalid field or recovery token. Route-level checks answer `bad_request`; v2 checks in the lifecycle answer `attestation_failed` with `reason: invalid_request` or `unsupported_protocol`.', - ['bad_request', 'attestation_failed'] - ), - 401: niError( - `The challenge, attestation, or recovery token didn’t verify: \`challenge_not_found\`, \`challenge_expired\`, \`attestation_invalid\`, \`recovery_not_enrolled\` (\`action: enroll_recovery\`), \`recovery_token_mismatch\`, \`identity_not_bound\`. ${ATTESTATION_NOTE}`, - ['attestation_failed', ...V1_CODES] - ), - 409: niError( - '`operationId` was already used with different data (`operation_conflict`).', - ['attestation_failed'] - ), - ...rateLimitedResponse, - 500: niError('Unexpected failure.', ['server_error']), - 503: niError('App Attest storage is unavailable. `Retry-After: 30`.', [ - 'server_error', - ]), - }, -}) - -// --- POST /notes-import/verify --------------------------------------------- - -const verifyRequestSchema = z.union([ - z - .object({ - protocolVersion: z.literal(1).optional(), - uuid: z.string(), - accountId, - keyId: z.string(), - challenge: z.string(), - assertion: z.string().meta({ - description: - 'Assertion over SHA-256 of `||`, or `|||` when `accountId` is sent.', - }), - contentHash: sha256Hex('Any value the client picks'), - }) - .meta({ title: 'App Attest v1 (legacy)' }), - z - .object({ - uuid: installId, - accountId, - ...appAttestV2AssertionFields('notes-import-verify'), - contentHash: sha256Hex('Any value the client picks'), - requestHash: sha256Hex('Must equal `contentHash`'), - }) - .meta({ title: 'App Attest v2' }), - z - .object({ - uuid: installId, - accountId, - ...playIntegrityFields('notes-import-verify'), - contentHash: sha256Hex('Any value the client picks'), - requestHash: sha256Hex('Must equal `contentHash`'), - }) - .meta({ title: 'Play Integrity' }), -]) - -const verifyResponseSchema = z.union([ - z - .object({ ok: z.literal(true) }) - .meta({ title: 'App Attest v1 (or dev bypass)' }), - z - .object({ - ok: z.literal(true), - protocolVersion: z.literal(2), - operationId, - }) - .meta({ title: 'App Attest v2' }), - z - .object({ - ok: z.literal(true), - attestationProvider: z.literal('play-integrity'), - protocolVersion: z.literal(1), - operationId, - }) - .meta({ title: 'Play Integrity' }), -]) - -export const verifyDocs = describeRoute({ - tags: [TAG], - summary: 'Verify attestation (diagnostic)', - description: `An attested no-op for the app’s developer tools. Runs the same verification as the metered routes, consuming the challenge and advancing the sign count, but touches no allowance and runs no model. - -There is no payload, so \`requestHash\` must equal \`contentHash\` (v2 and Play Integrity), with purpose \`notes-import-verify\`. Not gated by the kill switch.`, - parameters: [devBypassHeader], - requestBody: jsonBody(verifyRequestSchema), - responses: { - 200: jsonResponse('The attestation verified.', verifyResponseSchema), - 400: niError( - 'Invalid JSON, missing or malformed fields, `requestHash` ≠ `contentHash`, or an invalid v2 / Play Integrity request (`reason: invalid_request`).', - ['bad_request', 'attestation_failed'] - ), - ...attestationResponses, - ...rateLimitedResponse, - 500: niError('Unexpected failure.', ['server_error']), - 503: niError(storageUnavailable, ['server_error']), - }, -}) - // --- Import request (kickoff and legacy) ----------------------------------- const contextSchema = z diff --git a/src/notesImport/route.appAttest.test.ts b/src/notesImport/route.appAttest.test.ts index 180eeec..3037427 100644 --- a/src/notesImport/route.appAttest.test.ts +++ b/src/notesImport/route.appAttest.test.ts @@ -2,12 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { makeMemoryKv } from '../test/memoryKv' import { sha256Hex } from '../crypto' import type { AppContext, Environment } from '../types' +import { handleNotesImportKickoffRequest } from './route' import { - handleAttestRequest, - handleChallengeRequest, - handleNotesImportKickoffRequest, - handleNotesImportVerifyRequest, -} from './route' + handleAttestationChallengeRequest, + handleAttestationRegisterRequest, + handleAttestationVerifyRequest, +} from '../attestation/route' const UUID = '65A8B00C-DA7A-4E0A-BA5C-8E3D1B8C5F1C' const CONTENT_HASH = 'a'.repeat(64) @@ -121,10 +121,10 @@ describe('App Attest route compatibility', () => { NOTES_KV: kv, } as unknown as Environment const requests = [ - () => handleChallengeRequest(context(environment, { body: invalidBody })), - () => handleAttestRequest(context(environment, { body: invalidBody })), + () => handleAttestationChallengeRequest(context(environment, { body: invalidBody })), + () => handleAttestationRegisterRequest(context(environment, { body: invalidBody })), () => - handleNotesImportVerifyRequest( + handleAttestationVerifyRequest( context(environment, { body: invalidBody }) ), () => @@ -149,7 +149,7 @@ describe('App Attest route compatibility', () => { NOTES_KV: kv, } as unknown as Environment - const response = await handleChallengeRequest(context(environment)) + const response = await handleAttestationChallengeRequest(context(environment)) const body = (await response.json()) as Record expect(response.status).toBe(200) @@ -187,7 +187,7 @@ describe('App Attest route compatibility', () => { requestHash: GOLDEN_REQUEST_HASH, } - const response = await handleChallengeRequest( + const response = await handleAttestationChallengeRequest( context(environment, { body }) ) @@ -211,7 +211,7 @@ describe('App Attest route compatibility', () => { attestation: 'legacy-attestation', } - const response = await handleAttestRequest(context(environment, { body })) + const response = await handleAttestationRegisterRequest(context(environment, { body })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ ok: true }) @@ -246,7 +246,7 @@ describe('App Attest route compatibility', () => { } vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const response = await handleAttestRequest(context(environment, { body })) + const response = await handleAttestationRegisterRequest(context(environment, { body })) expect(response.status).toBe(401) await expect(response.json()).resolves.toEqual({ @@ -274,7 +274,7 @@ describe('App Attest route compatibility', () => { contentHash: CONTENT_HASH, } - const response = await handleNotesImportVerifyRequest( + const response = await handleAttestationVerifyRequest( context(environment, { body }) ) @@ -326,7 +326,7 @@ describe('App Attest route compatibility', () => { } vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const response = await handleNotesImportVerifyRequest( + const response = await handleAttestationVerifyRequest( context(environment, { body }) ) @@ -369,7 +369,7 @@ describe('App Attest route compatibility', () => { requestHash: CONTENT_HASH, } - const response = await handleNotesImportVerifyRequest( + const response = await handleAttestationVerifyRequest( context(environment, { body }) ) @@ -391,7 +391,7 @@ describe('App Attest route compatibility', () => { requestHash: CONTENT_HASH, } - const response = await handleNotesImportVerifyRequest( + const response = await handleAttestationVerifyRequest( context(environment, { body, headers: { 'x-ww-dev-bypass': 'bypass-token' }, @@ -429,7 +429,7 @@ describe('App Attest route compatibility', () => { requestHash: 'b'.repeat(64), } - const response = await handleNotesImportVerifyRequest( + const response = await handleAttestationVerifyRequest( context(environment, { body }) ) @@ -711,7 +711,7 @@ describe('App Attest route compatibility', () => { retryAfter: 60, }) - const challenge = await handleChallengeRequest( + const challenge = await handleAttestationChallengeRequest( context(environment, { body: { protocolVersion: 2, @@ -729,7 +729,7 @@ describe('App Attest route compatibility', () => { expect(challenge.status).toBe(200) expect(issueChallenge).toHaveBeenCalledTimes(1) - const attest = await handleAttestRequest( + const attest = await handleAttestationRegisterRequest( context(environment, { body: { protocolVersion: 2, @@ -746,7 +746,7 @@ describe('App Attest route compatibility', () => { expect(attest.status).toBe(200) await expect(attest.json()).resolves.toMatchObject({ status: 'rotated' }) - const verify = await handleNotesImportVerifyRequest( + const verify = await handleAttestationVerifyRequest( context(environment, { body: { protocolVersion: 2, diff --git a/src/notesImport/route.playIntegrity.test.ts b/src/notesImport/route.playIntegrity.test.ts index 0a79e17..df30524 100644 --- a/src/notesImport/route.playIntegrity.test.ts +++ b/src/notesImport/route.playIntegrity.test.ts @@ -26,12 +26,14 @@ import { resetGoogleAuthState } from '../googleAuth' import { computePlayIntegrityRequestBinding } from '../playIntegrity/protocol' import { getNotesImportStatus, type StatusKv } from './status' import { - handleAttestRequest, - handleChallengeRequest, handleNotesImportKickoffRequest, handleNotesImportRequest, - handleNotesImportVerifyRequest, } from './route' +import { + handleAttestationChallengeRequest, + handleAttestationRegisterRequest, + handleAttestationVerifyRequest, +} from '../attestation/route' const UUID = '65A8B00C-DA7A-4E0A-BA5C-8E3D1B8C5F1C' const ACCOUNT_ID = 'account-id-1' @@ -173,7 +175,7 @@ const setup = async ( ? { CONTENT_HASH, REQUEST_HASH: CONTENT_HASH } : { CONTENT_HASH, REQUEST_HASH } ) - const response = await handleChallengeRequest(context(env, { body: fields })) + const response = await handleAttestationChallengeRequest(context(env, { body: fields })) expect(response.status).toBe(200) const { challenge } = (await response.json()) as { challenge: string } pendingBinding = await computePlayIntegrityRequestBinding({ @@ -362,7 +364,7 @@ describe('Play Integrity on the Notes Import routes', () => { notesText: NOTES_TEXT, context: NOTES_CONTEXT, }) - for (const handler of [handleNotesImportRequest, handleAttestRequest]) { + for (const handler of [handleNotesImportRequest, handleAttestationRegisterRequest]) { const response = await handler(context(env, { body })) expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ @@ -375,7 +377,7 @@ describe('Play Integrity on the Notes Import routes', () => { const { env, index, signedBody } = await setup() const body = await signedBody('notes-import-verify', {}) - const response = await handleNotesImportVerifyRequest(context(env, { body })) + const response = await handleAttestationVerifyRequest(context(env, { body })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ @@ -393,7 +395,7 @@ describe('Play Integrity on the Notes Import routes', () => { ...env, PLAY_INTEGRITY_SERVICE_ACCOUNT_JSON: undefined, } as Environment - const response = await handleChallengeRequest( + const response = await handleAttestationChallengeRequest( context(unconfigured, { body: challengeFields('notes-import-kickoff') }) ) expect(response.status).toBe(503) diff --git a/src/notesImport/route.ts b/src/notesImport/route.ts index 93a81e3..a08a8b5 100644 --- a/src/notesImport/route.ts +++ b/src/notesImport/route.ts @@ -2,7 +2,6 @@ import type { AppContext, ErrorResponse } from '../types' import { HTTP_STATUS } from '../config' import { Sentry } from '../sentry' import { retryAfterHeaders } from '../errors' -import { PLAY_INTEGRITY_CHALLENGE_TTL_SECONDS } from '../playIntegrity/protocol' import { sha256Hex, timingSafeEqual, randomToken } from '../crypto' import { getNotesImportConfig, @@ -28,35 +27,30 @@ import { AppAttestError, NOTES_IMPORT_KICKOFF_PURPOSE, NOTES_IMPORT_TRANSCRIBE_PURPOSE, - NOTES_IMPORT_VERIFY_PURPOSE, type AppAttestAction, type AppAttestReason, type AppAttestAssertionPurpose, } from '../appAttest' -import { appAttestHttpCode } from '../appAttest/errors' import { computeNotesImportRequestHash, computeNotesImportTranscribeRequestHash, - isAppAttestAssertionPurpose, - isAppAttestChallenge, - isAppAttestKeyId, - isAppAttestOperationId, - isAppAttestUuid, - type V2AssertionChallengeRequest, - type V2AssertionFinalRequest, } from '../appAttest/protocol' -import { - PlayIntegrityError, - isPlayIntegrityRequest, - issuePlayIntegrityChallenge, - parsePlayIntegrityAssertionRequest, - parsePlayIntegrityChallengeRequest, - verifyPlayIntegrityAssertion, - type PlayIntegrityReason, -} from '../playIntegrity' +import { isPlayIntegrityRequest, type PlayIntegrityReason } from '../playIntegrity' import type { PlayIntegrityAction } from '../playIntegrity/errors' +import { + appAttestFailureResponse, + asRecord, + asString, + attestationBadRequest as appAttestBadRequest, + isLowercaseSha256, + isValidAccountId, + parseV2AssertionFinalRequest, + requireAttestation, + verifyPlayIntegrityRecord, + verifyV2Assertion, +} from '../attestation/http' import { isEmptyImportResult, type NotesImportContext } from './schema' -import { handleAdminResetRequest, isValidMeterId } from './admin' +import { handleAdminResetRequest } from './admin' import { buildAllowanceDenial, type NotesImportKickoffResponse, @@ -157,256 +151,6 @@ const errorDetail = (e: unknown): string => { } } -const asString = (v: unknown): string | null => - typeof v === 'string' && v.length > 0 ? v : null - -const asRecord = (value: unknown): Record | null => - value != null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null - -const appAttestBadRequest = (ctx: AppContext, message: string): Response => - err( - ctx, - HTTP_STATUS.BAD_REQUEST, - message, - 'bad_request', - undefined, - undefined, - 'invalid_request', - 'none' - ) - -const appAttestFailureResponse = ( - ctx: AppContext, - error: AppAttestError, - input: unknown -): Response => { - // Stable reason only: request bodies may contain a recovery token. - console.warn('notes-import App Attest rejected:', error.reason) - if (error.reason === 'storage_unavailable') Sentry.captureException(error) - - const version = appAttestProtocolVersion(input) - const status = version === 1 && error.status < 500 ? 401 : error.status - const code = appAttestHttpCode(version, error.reason) - return err( - ctx, - status, - error.message, - code, - undefined, - undefined, - error.reason, - error.action - ) -} - -const readOptionalJson = async ( - ctx: AppContext -): Promise<{ ok: true; value: unknown } | { ok: false }> => { - const text = await ctx.req.text() - if (!text.trim()) return { ok: true, value: undefined } - try { - return { ok: true, value: JSON.parse(text) as unknown } - } catch { - return { ok: false } - } -} - -/** - * Shape gate for the client-supplied shared account id (witness-work ADR - * 0011). It becomes a Durable Object name, a RevenueCat app-user-id path - * segment, and a `|`-delimited clientData field, so keep it to a bounded - * uuid-ish alphabet. Install ids (the value it defaults to on-device) are - * `Crypto.randomUUID()` strings, which this accepts with room to spare. - */ -const isValidAccountId = isValidMeterId - -const isLowercaseSha256 = (value: string): boolean => - /^[a-f0-9]{64}$/.test(value) - -const parseV2AssertionChallengeRequest = ( - record: Record -): V2AssertionChallengeRequest | null => { - const operationId = asString(record.operationId) - const uuid = asString(record.uuid) - const keyId = asString(record.keyId) - const purpose = asString(record.purpose) - const contentHash = asString(record.contentHash) - const requestHash = asString(record.requestHash) - const accountId = - record.accountId == null ? undefined : asString(record.accountId) - if ( - record.protocolVersion !== 2 || - record.operation !== 'assert' || - !operationId || - !isAppAttestOperationId(operationId) || - !uuid || - !isAppAttestUuid(uuid) || - !keyId || - !isAppAttestKeyId(keyId) || - !purpose || - !isAppAttestAssertionPurpose(purpose) || - (record.accountId != null && - (!accountId || !isValidAccountId(accountId))) || - !contentHash || - !isLowercaseSha256(contentHash) || - !requestHash || - !isLowercaseSha256(requestHash) - ) { - return null - } - return { - protocolVersion: 2, - operation: 'assert', - operationId, - uuid, - keyId, - purpose, - ...(accountId ? { accountId } : {}), - contentHash, - requestHash, - } -} - -const parseV2AssertionFinalRequest = ( - record: Record, - expectedPurpose: AppAttestAssertionPurpose -): V2AssertionFinalRequest | null => { - const challengeRequest = parseV2AssertionChallengeRequest(record) - const challenge = asString(record.challenge) - const assertion = asString(record.assertion) - if ( - !challengeRequest || - challengeRequest.purpose !== expectedPurpose || - !challenge || - !isAppAttestChallenge(challenge) || - !assertion || - assertion.length > 65_536 - ) { - return null - } - return { ...challengeRequest, challenge, assertion } -} - -const storageUnavailable = (cause: unknown): AppAttestError => - new AppAttestError('App Attest storage is temporarily unavailable', { - reason: 'storage_unavailable', - action: 'retry', - status: 503, - cause, - }) - -const issueV2AssertionChallenge = async ( - ctx: AppContext, - request: V2AssertionChallengeRequest -) => { - try { - const id = ctx.env.APP_ATTEST_IDENTITY.idFromName(request.uuid) - const result = await ctx.env.APP_ATTEST_IDENTITY.get(id).issueChallenge( - request - ) - if (!result.ok) throw AppAttestError.fromFailure(result.error) - return result.value - } catch (error) { - if (error instanceof AppAttestError) throw error - throw storageUnavailable(error) - } -} - -const verifyV2Assertion = async ( - ctx: AppContext, - request: V2AssertionFinalRequest -) => { - try { - const id = ctx.env.APP_ATTEST_IDENTITY.idFromName(request.uuid) - const result = await ctx.env.APP_ATTEST_IDENTITY.get(id).verifyAssertion( - request - ) - if (!result.ok) throw AppAttestError.fromFailure(result.error) - return result.value - } catch (error) { - if (error instanceof AppAttestError) throw error - throw storageUnavailable(error) - } -} - -const playIntegrityFailureResponse = ( - ctx: AppContext, - error: PlayIntegrityError -): Response => { - // Stable reason and policy detail only; never the token or verdict payload. - console.warn( - 'notes-import Play Integrity rejected:', - error.reason, - error.message - ) - if (error.reason === 'storage_unavailable') Sentry.captureException(error) - return err( - ctx, - error.status, - error.message, - error.status >= 500 ? 'server_error' : 'attestation_failed', - undefined, - undefined, - error.reason, - error.action, - error.status === 429 - ? PLAY_INTEGRITY_CHALLENGE_TTL_SECONDS - : error.status === 503 - ? UNAVAILABLE_RETRY_AFTER_SECONDS - : undefined - ) -} - -const playIntegrityDependencies = { - fetch: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init), - now: () => Date.now(), - report: (error: Error) => { - Sentry.captureException(error) - }, -} - -/** Android's protected-request check (ADR 0017); null when it passed. */ -const verifyPlayIntegrityRecord = async ( - ctx: AppContext, - record: Record, - purpose: AppAttestAssertionPurpose -): Promise => { - const request = parsePlayIntegrityAssertionRequest(record, purpose) - if (!request) { - return err( - ctx, - HTTP_STATUS.BAD_REQUEST, - 'Invalid Play Integrity request', - 'bad_request', - undefined, - undefined, - 'invalid_request', - 'none' - ) - } - try { - await verifyPlayIntegrityAssertion( - ctx.env, - request, - playIntegrityDependencies - ) - return null - } catch (e) { - if (e instanceof PlayIntegrityError) { - return playIntegrityFailureResponse(ctx, e) - } - Sentry.captureException(e) - return err( - ctx, - HTTP_STATUS.INTERNAL_SERVER_ERROR, - 'Auth error', - 'server_error' - ) - } -} - const subKey = (token: string) => `notes-import:sub:${token}` /** @@ -420,253 +164,6 @@ export async function handleNotesImportStatusRequest(ctx: AppContext) { return ctx.json(status, 200, { 'Cache-Control': 'no-store' }) } -/** POST /notes-import/challenge — issue an App Attest challenge (v1 or v2). */ -export async function handleChallengeRequest(ctx: AppContext) { - const parsed = await readOptionalJson(ctx) - if (!parsed.ok) return appAttestBadRequest(ctx, 'Invalid JSON') - if (parsed.value !== undefined && !asRecord(parsed.value)) { - return appAttestBadRequest(ctx, 'Invalid JSON body') - } - const record = asRecord(parsed.value) - if (record && isPlayIntegrityRequest(record)) { - const request = parsePlayIntegrityChallengeRequest(record) - if (!request) { - return appAttestBadRequest(ctx, 'Invalid Play Integrity challenge') - } - try { - return ctx.json(await issuePlayIntegrityChallenge(ctx.env, request)) - } catch (e) { - if (e instanceof PlayIntegrityError) { - return playIntegrityFailureResponse(ctx, e) - } - Sentry.captureException(e) - return err( - ctx, - HTTP_STATUS.INTERNAL_SERVER_ERROR, - 'Challenge error', - 'server_error' - ) - } - } - const isV2Assertion = - record?.protocolVersion === 2 && record.operation === 'assert' - const v2AssertionRequest = isV2Assertion - ? parseV2AssertionChallengeRequest(record) - : null - if (isV2Assertion && !v2AssertionRequest) { - return appAttestBadRequest(ctx, 'Invalid App Attest v2 assertion challenge') - } - - try { - const response = v2AssertionRequest - ? await issueV2AssertionChallenge(ctx, v2AssertionRequest) - : await appAttestLifecycle(ctx.env).issueChallenge(parsed.value) - return ctx.json(response) - } catch (e) { - if (e instanceof AppAttestError) { - return appAttestFailureResponse(ctx, e, parsed.value) - } - Sentry.captureException(e) - return err( - ctx, - HTTP_STATUS.INTERNAL_SERVER_ERROR, - 'Challenge error', - 'server_error' - ) - } -} - -/** POST /notes-import/attest — bind a key or enroll v2 recovery. */ -export async function handleAttestRequest(ctx: AppContext) { - let parsed: unknown - try { - parsed = await ctx.req.json() - } catch { - return appAttestBadRequest(ctx, 'Invalid JSON') - } - const body = asRecord(parsed) - if (!body) return appAttestBadRequest(ctx, 'Invalid JSON body') - // Play Integrity has no key to register; every request carries a token. - if (isPlayIntegrityRequest(body)) { - return err( - ctx, - HTTP_STATUS.BAD_REQUEST, - 'Play Integrity has no registration step', - 'bad_request', - undefined, - undefined, - 'unsupported_protocol', - 'none' - ) - } - - // Keep the deployed v1 request gate and top-level error text unchanged. - if (appAttestProtocolVersion(body) === 1) { - const keyId = asString(body.keyId) - const attestation = asString(body.attestation) - const challenge = asString(body.challenge) - const uuid = asString(body.uuid) - if (!keyId || !attestation || !challenge || !uuid) { - return appAttestBadRequest( - ctx, - 'Missing keyId, attestation, challenge, or uuid' - ) - } - } - - try { - const response = await appAttestLifecycle(ctx.env).register(body) - return ctx.json(response) - } catch (e) { - if (e instanceof AppAttestError) { - return appAttestFailureResponse(ctx, e, body) - } - Sentry.captureException(e) - return err( - ctx, - HTTP_STATUS.INTERNAL_SERVER_ERROR, - 'Attestation error', - 'server_error' - ) - } -} - -/** - * POST /notes-import/verify — attested no-op for the app's dev-tools - * diagnostics. Runs the exact verifyAssertion path the metered endpoints use - * (challenge consumption and sign-count advance included) but touches no - * credits and no inference, so the Tools screen can prove the full App Attest - * boundary server-side. This no-payload probe requires `requestHash` to equal - * its client-chosen `contentHash`; real kickoffs instead hash their canonical - * Notes Import payload. - */ -export async function handleNotesImportVerifyRequest(ctx: AppContext) { - let parsed: unknown - try { - parsed = await ctx.req.json() - } catch { - return appAttestBadRequest(ctx, 'Invalid JSON') - } - const body = asRecord(parsed) - if (!body) return appAttestBadRequest(ctx, 'Invalid JSON body') - - const config = getNotesImportConfig(ctx.env) - const bypassHeader = ctx.req.header('x-ww-dev-bypass') ?? '' - const devBypass = - config.devBypassToken != null && - timingSafeEqual(bypassHeader, config.devBypassToken) - if (devBypass) { - const version = appAttestProtocolVersion(body) - if (version === 2) { - const operationId = asString(body.operationId) - const contentHash = asString(body.contentHash) - const requestHash = asString(body.requestHash) - if ( - !operationId || - !isAppAttestOperationId(operationId) || - body.operation !== 'assert' || - body.purpose !== NOTES_IMPORT_VERIFY_PURPOSE || - !contentHash || - !isLowercaseSha256(contentHash) || - requestHash !== contentHash - ) { - return appAttestBadRequest(ctx, 'Invalid App Attest v2 bypass probe') - } - return ctx.json({ ok: true, protocolVersion: 2, operationId }) - } - return ctx.json({ ok: true }) - } - - if (isPlayIntegrityRequest(body)) { - // Same no-payload probe contract as App Attest v2 verify. - if (body.requestHash !== body.contentHash) { - return appAttestBadRequest( - ctx, - 'requestHash must equal contentHash for Play Integrity verify' - ) - } - const failure = await verifyPlayIntegrityRecord( - ctx, - body, - NOTES_IMPORT_VERIFY_PURPOSE - ) - if (failure) return failure - return ctx.json({ - ok: true, - attestationProvider: 'play-integrity', - protocolVersion: 1, - operationId: body.operationId, - }) - } - - const uuid = asString(body.uuid) - const keyId = asString(body.keyId) - const challenge = asString(body.challenge) - const assertion = asString(body.assertion) - const contentHash = asString(body.contentHash) - if (!uuid || !keyId || !challenge || !assertion || !contentHash) { - return appAttestBadRequest( - ctx, - 'Missing uuid, keyId, challenge, assertion, or contentHash' - ) - } - // Same shape gates as kickoff: both values end up in the `|`-delimited - // signed clientData, so keep them to their bounded alphabets. - const accountId = body.accountId != null ? asString(body.accountId) : null - if (body.accountId != null && (!accountId || !isValidAccountId(accountId))) { - return appAttestBadRequest(ctx, 'Invalid accountId') - } - if (!/^[a-f0-9]{64}$/.test(contentHash)) { - return appAttestBadRequest(ctx, 'Invalid contentHash') - } - - const version = appAttestProtocolVersion(body) - let v2Request: V2AssertionFinalRequest | null = null - if (version === 2) { - const requestHash = asString(body.requestHash) - if ( - !requestHash || - !isLowercaseSha256(requestHash) || - requestHash !== contentHash - ) { - return appAttestBadRequest( - ctx, - 'requestHash must equal contentHash for App Attest verify' - ) - } - v2Request = parseV2AssertionFinalRequest( - body, - NOTES_IMPORT_VERIFY_PURPOSE - ) - if (!v2Request) { - return appAttestBadRequest(ctx, 'Invalid App Attest v2 verify assertion') - } - } - - try { - const response = v2Request - ? await verifyV2Assertion(ctx, v2Request) - : await appAttestLifecycle(ctx.env).verifyAssertion(body, { - uuid, - accountId: accountId ?? undefined, - contentHash, - purpose: NOTES_IMPORT_VERIFY_PURPOSE, - }) - return ctx.json(response) - } catch (e) { - if (e instanceof AppAttestError) { - return appAttestFailureResponse(ctx, e, body) - } - Sentry.captureException(e) - return err( - ctx, - HTTP_STATUS.INTERNAL_SERVER_ERROR, - 'Verify error', - 'server_error' - ) - } -} - interface NotesImportBody { uuid: string /** Shared account id (ADR 0011); absent on pre-account clients. */ @@ -1591,44 +1088,14 @@ export async function handleNotesImportTranscribeRequest(ctx: AppContext) { const devBypass = config.devBypassToken != null && timingSafeEqual(bypassHeader, config.devBypassToken) - if (!devBypass && isPlayIntegrityRequest(record)) { - const failure = await verifyPlayIntegrityRecord( + if (!devBypass) { + const failure = await requireAttestation( ctx, record, - NOTES_IMPORT_TRANSCRIBE_PURPOSE + NOTES_IMPORT_TRANSCRIBE_PURPOSE, + 'Page transcription requires App Attest v2 or Play Integrity' ) if (failure) return failure - } else if (!devBypass) { - const v2Request = - appAttestProtocolVersion(record) === 2 - ? parseV2AssertionFinalRequest(record, NOTES_IMPORT_TRANSCRIBE_PURPOSE) - : null - if (!v2Request) { - return err( - ctx, - HTTP_STATUS.BAD_REQUEST, - 'Page transcription requires App Attest v2 or Play Integrity', - 'bad_request', - undefined, - undefined, - 'unsupported_protocol', - 'none' - ) - } - try { - await verifyV2Assertion(ctx, v2Request) - } catch (e) { - if (e instanceof AppAttestError) { - return appAttestFailureResponse(ctx, e, record) - } - Sentry.captureException(e) - return err( - ctx, - HTTP_STATUS.INTERNAL_SERVER_ERROR, - 'Auth error', - 'server_error' - ) - } } // --- Entitlement, import allowance, and page limit -------------------- diff --git a/src/notesImport/status.ts b/src/notesImport/status.ts index 5c884b9..e118380 100644 --- a/src/notesImport/status.ts +++ b/src/notesImport/status.ts @@ -6,9 +6,10 @@ import { type NotesImportLimits, } from './config' import type { Environment } from '../types' -import { APP_ATTEST_PROTOCOL_VERSION } from '../appAttest/protocol' -import { playIntegrityConfig } from '../playIntegrity/config' -import { PLAY_INTEGRITY_PROTOCOL_VERSION } from '../playIntegrity/protocol' +import { + attestationProviders, + type AttestationProviders, +} from '../attestation/providers' /** The KV subset this module uses — keeps it trivially mockable in tests. */ export type StatusKv = Pick @@ -63,18 +64,7 @@ type NotesImportStatusState = | { available: true; limits?: PublicNotesImportLimits } | { available: false; reason?: string } -export interface NotesImportCapabilities { - appAttest: { - protocolVersions: readonly [1, typeof APP_ATTEST_PROTOCOL_VERSION] - } - /** - * Present only when this worker can verify Android requests (ADR 0017). - * Android keeps Notes Import closed while it is absent. - */ - playIntegrity?: { - protocolVersions: readonly [typeof PLAY_INTEGRITY_PROTOCOL_VERSION] - cloudProjectNumber: string - } +export interface NotesImportCapabilities extends AttestationProviders { /** * Import from Paper: `POST /notes-import/transcribe` reads one page image. * The app picks at most `maxPages` per batch and resizes each page to fit @@ -96,24 +86,14 @@ export type NotesImportStatusResponse = NotesImportStatusState & { minAppVersion?: string } -const notesImportCapabilities = (env: Environment): NotesImportCapabilities => { - const playIntegrity = playIntegrityConfig(env) - return { - appAttest: { protocolVersions: [1, APP_ATTEST_PROTOCOL_VERSION] }, - transcribe: { - maxPages: TRANSCRIBE_MAX_PAGES, - maxBytes: resolveTranscribeMaxBytes(env), - }, - ...(playIntegrity - ? { - playIntegrity: { - protocolVersions: [PLAY_INTEGRITY_PROTOCOL_VERSION], - cloudProjectNumber: playIntegrity.cloudProjectNumber, - }, - } - : {}), - } -} +const notesImportCapabilities = (env: Environment): NotesImportCapabilities => ({ + // Also at GET /attestation/v1/status; kept here for deployed apps. + ...attestationProviders(env), + transcribe: { + maxPages: TRANSCRIBE_MAX_PAGES, + maxBytes: resolveTranscribeMaxBytes(env), + }, +}) const ENABLED_KEY = 'notes-import:enabled' const PROVIDER_CACHE_KEY = 'notes-import:provider-health' diff --git a/src/openapi/coreDocs.ts b/src/openapi/coreDocs.ts index 8ece031..9864b56 100644 --- a/src/openapi/coreDocs.ts +++ b/src/openapi/coreDocs.ts @@ -128,7 +128,7 @@ export const aasaDocs = describeRoute({ tags: ['Links'], summary: 'Apple App Site Association', description: - 'Tells iOS which paths open the app: `/c#…` (shared contacts), `/c/…` (contacts from older builds), and `/b#1…` (buddy invites), for the App Store, Beta, and development bundle ids.', + 'Tells iOS which paths open the app, for the App Store, Beta, and development bundle ids: `/contacts#…` (shared contacts), `/buddies/invite#1…` (buddy invites), and `/accounts/handoff#1…` (Set Up Another Device), plus the short paths in links already shared (`/c#…`, `/c/…`, `/b#1…`, `/h#1…`), which stay for good.', responses: { 200: { description: 'The AASA document.', @@ -141,9 +141,13 @@ export const aasaDocs = describeRoute({ { appIDs: ['TEAMID.com.leviwilkerson.jwtime'], components: [ + { '/': '/contacts', '#': '?*' }, + { '/': '/buddies/invite', '#': '1?*' }, + { '/': '/accounts/handoff', '#': '1?*' }, { '/': '/c', '#': '?*' }, { '/': '/c/*' }, { '/': '/b', '#': '1?*' }, + { '/': '/h', '#': '1?*' }, ], }, ], @@ -164,20 +168,51 @@ const langParameter = { description: 'Page language. Falls back to `Accept-Language`, then English.', } +/** + * A link page's short path (`/c`, `/b`, `/h`): the same page, in links people + * already sent. Served for good; new links use the readable path. + */ +const shortPathDocs = ( + path: string, + summary: string, + description: string, + response: string, + parameters: (typeof langParameter)[] = [] +) => + describeRoute({ + tags: ['Links'], + summary: `${summary} (short path)`, + description: `The same page as \`${path}\`, at the short path older app versions put in links. Those links are already in people’s messages, so this path is served for good; new links use \`${path}\`.\n\n${description}`, + deprecated: true, + parameters, + responses: { 200: html(response) }, + }) + +const CONTACT_LINK_DESCRIPTION = + 'Shown when someone opens a shared-contact link without the app. The contact rides in the URL fragment (`/contacts#`), which never reaches the worker; the page’s script turns it into an “Open app” link.' + export const contactLinkDocs = describeRoute({ tags: ['Links'], summary: 'Shared contact fallback page', - description: - 'Shown when someone opens a shared-contact link without the app. The contact rides in the URL fragment (`/c#`), which never reaches the worker; the page’s script turns it into an “Open app” link.', + description: CONTACT_LINK_DESCRIPTION, parameters: [langParameter], responses: { 200: html('A localized landing page.') }, }) +export const shortContactLinkDocs = shortPathDocs( + '/contacts', + 'Shared contact fallback page', + CONTACT_LINK_DESCRIPTION, + 'A localized landing page.', + [langParameter] +) + export const legacyContactLinkDocs = describeRoute({ tags: ['Links'], summary: 'Shared contact fallback page (legacy)', description: - 'The same page for links shared by older app versions, which put the payload in the path. The payload only ever goes into the “Open app” link, never into meta tags.', + 'The same page for links shared by the oldest app versions, which put the payload in the path. Served for good, like `/c`. The payload only ever goes into the “Open app” link, never into meta tags.', + deprecated: true, parameters: [ { name: 'payload', @@ -191,22 +226,42 @@ export const legacyContactLinkDocs = describeRoute({ responses: { 200: html('A localized landing page.') }, }) +const BUDDY_INVITE_DESCRIPTION = + 'Shown when someone opens a buddy invite (`/buddies/invite#1`) without the app, with App Store and Google Play buttons. The invite secret stays in the fragment and never reaches the worker.' + export const buddyInvitePageDocs = describeRoute({ tags: ['Links'], summary: 'Buddy invite fallback page', - description: - 'Shown when someone opens a buddy invite (`/b#1`) without the app, with App Store and Google Play buttons. The invite secret stays in the fragment and never reaches the worker.', + description: BUDDY_INVITE_DESCRIPTION, responses: { 200: html('The invite landing page.') }, }) +export const shortBuddyInvitePageDocs = shortPathDocs( + '/buddies/invite', + 'Buddy invite fallback page', + BUDDY_INVITE_DESCRIPTION, + 'The invite landing page.' +) + +const HANDOFF_DESCRIPTION = + 'Shown when a phone’s camera opens a Set Up Another Device QR code (`/accounts/handoff#1<…>`) instead of WitnessWork: install or open the app on the new phone and scan the code from inside it, with App Store and Google Play buttons. Localized like `/contacts` (`?lang=`, then `Accept-Language`). The handoff secret stays in the fragment and never reaches the worker.' + export const handoffPageDocs = describeRoute({ tags: ['Links'], summary: 'Set Up Another Device fallback page', - description: - 'Shown when a phone’s camera opens a Set Up Another Device QR code (`/h#1<…>`) instead of WitnessWork: install or open the app on the new phone and scan the code from inside it, with App Store and Google Play buttons. Localized like `/c` (`?lang=`, then `Accept-Language`). The handoff secret stays in the fragment and never reaches the worker.', + description: HANDOFF_DESCRIPTION, + parameters: [langParameter], responses: { 200: html('The handoff landing page.') }, }) +export const shortHandoffPageDocs = shortPathDocs( + '/accounts/handoff', + 'Set Up Another Device fallback page', + HANDOFF_DESCRIPTION, + 'The handoff landing page.', + [langParameter] +) + export const notesImportAdminResetDocs = describeRoute({ tags: ['Admin'], summary: 'Reset a Notes Import meter', diff --git a/src/openapi/route.ts b/src/openapi/route.ts index 6f21c27..d4dfc53 100644 --- a/src/openapi/route.ts +++ b/src/openapi/route.ts @@ -20,6 +20,11 @@ export const OPENAPI_TAGS = [ description: 'HERE Geocoding & Search proxy. The worker injects its own `apiKey` and forwards every other query parameter unchanged.', }, + { + name: 'Attestation', + description: + 'Device attestation for the whole API: App Attest on iOS, Play Integrity on Android. Get a challenge, register an App Attest key, and prove the path with `verify`; each protected route then checks an assertion signed for its own `purpose`. These routes lived under `/notes-import/` until 2026-10; the old paths still work, marked deprecated.', + }, { name: 'Notes Import', description: @@ -51,7 +56,7 @@ export const OPENAPI_TAGS = [ { name: 'Links', description: - 'Universal-link support: the AASA file and the no-app fallback pages for shared contacts and buddy invites.', + 'Universal-link support: the AASA file and the no-app fallback pages for shared contacts (`/contacts`), buddy invites (`/buddies/invite`), and Set Up Another Device (`/accounts/handoff`). The short paths (`/c`, `/b`, `/h`) in links already shared are served for good.', }, { name: 'Admin', @@ -66,11 +71,11 @@ const DOCUMENTATION = { version: '1', description: `The backend for the [WitnessWork](https://github.com/leviFrosty/witness-work) app: a Cloudflare Worker built with Hono. Source: [ww-api](${REPO_URL}). -**Errors.** Every JSON error uses one envelope: \`{ "ok": false, "error", "code", "retryAfter"? }\`. Read \`code\`; \`error\` repeats it, except on Notes Import and route planning, where it is a human-readable message. Every 429 and 503 sends \`Retry-After\`. +**Errors.** Every JSON error uses one envelope: \`{ "ok": false, "error", "code", "retryAfter"? }\`. Read \`code\`; \`error\` repeats it, except on attestation, Notes Import, and route planning, where it is a human-readable message. Every 429 and 503 sends \`Retry-After\`. **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, 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, 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/openapi/schemas.ts b/src/openapi/schemas.ts index d102bd6..0d55dd6 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -35,7 +35,7 @@ export const apiErrorSchema = z ok: z.literal(false), error: z.string().meta({ description: - 'The same as `code`, except on Notes Import and route planning, where it is a human-readable message.', + 'The same as `code`, except on attestation, Notes Import, and route planning, where it is a human-readable message.', }), code: z .string() diff --git a/src/rateLimit.ts b/src/rateLimit.ts index 11c4c83..f1dfe29 100644 --- a/src/rateLimit.ts +++ b/src/rateLimit.ts @@ -18,9 +18,11 @@ import { apiError } from './errors' * Address search and geocoding stay in one `places` bucket because both spend * HERE transactions. Behind each family sit stronger, identity-keyed limits * (Notes Import: App Attest or Play Integrity, allowances, the concurrency - * cap; route planning: the per-account quota), which a client-supplied install - * or account id can't replace here: before those checks it is unauthenticated, - * so keying on it would let one caller mint fresh buckets at will. + * cap; attestation: the per-install challenge cap; route planning: the + * per-account quota), which a client-supplied install or account id can't + * replace here: before those checks it is unauthenticated, so keying on it + * would let one caller mint fresh buckets at will. The attestation routes' + * old `/notes-import/*` paths still count in `notes-import`. */ /** The binding's window (`[ratelimits.simple] period`), so the longest wait. */ @@ -29,6 +31,7 @@ export const RATE_LIMIT_PERIOD_SECONDS = 60 export type RateLimitFamily = | 'places' | 'notes-import' + | 'attestation' | 'route-planning' | 'admin' diff --git a/src/sentry.test.ts b/src/sentry.test.ts index f04f0f3..af06165 100644 --- a/src/sentry.test.ts +++ b/src/sentry.test.ts @@ -184,13 +184,13 @@ describe('trace sampling', () => { createSentryConfig({ APP_ATTEST_ENVIRONMENT: development ? 'development' : 'production' } as Environment) .tracesSampler!({ name, transactionContext: { name }, parentSampled }) - it.each(['GET /notes-import/status', 'GET /geocode', 'GET /autocomplete', 'GET /health', 'GET /c/private-payload', 'GET /unknown'])( + it.each(['GET /notes-import/status', 'GET /attestation/v1/status', 'GET /geocode', 'GET /autocomplete', 'GET /health', 'GET /c/private-payload', 'GET /contacts', 'GET /unknown'])( 'samples routine transaction %s at 10%, including sampled parents', (name) => { expect(sample(name)).toBe(0.1) expect(sample(name, false, true)).toBe(0.1) } ) - it.each(['POST /notes-import', 'POST /notes-import/kickoff', 'POST /notes-import/challenge', 'POST /notes-import/attest', 'GET /notes-import/id/events'])( + it.each(['POST /notes-import', 'POST /notes-import/kickoff', 'POST /notes-import/challenge', 'POST /notes-import/attest', 'GET /notes-import/id/events', 'POST /attestation/v1/challenge', 'POST /attestation/v1/register', 'POST /attestation/v1/verify'])( 'retains import transaction %s even with an unsampled parent', (name) => { expect(sample(name, false, false)).toBe(1) } diff --git a/src/sentry.ts b/src/sentry.ts index fddadc8..5096cd1 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -101,7 +101,11 @@ export function createSentryConfig(environment: Environment): CloudflareOptions const isImportOperation = path === '/notes-import' || (path?.startsWith('/notes-import/') && path !== '/notes-import/status'); - return isImportOperation ? 1 : 0.1; + // Attestation was part of Notes Import's operation traffic; it stays at + // 100% under its own paths. + const isAttestationOperation = + path?.startsWith('/attestation/') && path !== '/attestation/v1/status'; + return isImportOperation || isAttestationOperation ? 1 : 0.1; }, sendDefaultPii: false, beforeSend: scrubEvent,