diff --git a/.agents/skills/verify-ww-api/SKILL.md b/.agents/skills/verify-ww-api/SKILL.md index 1bc1b81..98685dd 100644 --- a/.agents/skills/verify-ww-api/SKILL.md +++ b/.agents/skills/verify-ww-api/SKILL.md @@ -70,7 +70,7 @@ The seed decides each case's mutation and its parameters. Ids, keys, and nonces There are 19 generators: -- **Bad signatures:** tampered signature, tampered payload, wrong key or wrong role, a signature reused on another op, malformed `s`. +- **Bad signatures:** tampered signature, tampered payload, wrong key or wrong role, a signature reused on another op, malformed `signature`. - **Replay and time:** sequential and parallel replay, stale `ts`. - **Bad payloads:** a wrong-typed field, a missing field, malformed envelopes (bad JSON, base64, UTF-8), unicode and huge strings. - **Size and caps:** blob sizes at and one byte over every limit, envelopes over 256 KiB (declared and chunked), slot, invite, template, kind, and expiry caps. diff --git a/.agents/skills/verify-ww-api/features/buddies-relay.md b/.agents/skills/verify-ww-api/features/buddies-relay.md index 501f39e..187b49f 100644 --- a/.agents/skills/verify-ww-api/features/buddies-relay.md +++ b/.agents/skills/verify-ww-api/features/buddies-relay.md @@ -16,8 +16,8 @@ Buddies lets two publishers share upcoming Plans through a blind relay. Each per - `leave` removes the slot, its card, and its events from both inboxes through `slot/remove` and `slot/leave`. - `delete` wipes the inbox with `inbox/delete`. Sync then returns 404 `not_found`, and only the same key can register the inbox again. - `kill-switch` makes every op return 503 `disabled` when KV `buddies:enabled` is `false`, except `inbox/delete`, `slot/remove`, and `slot/leave`. -- `photos` stores encrypted note photos: `blob/put` (raw sealed bytes, owner-signed envelope in `x-buddies-p`/`x-buddies-s`, `blobId` = SHA-256 of the bytes, at most 1 MiB) writes R2 object `v1//`; `blob/get` (unsigned `{p}` with `inboxId`, `blobId`, `token`) streams it back with `Cache-Control: no-store`, and every miss is the same 404; `blob/delete` (owner) removes them. `inbox/sync` reports `capabilities.photos`. KV `buddies:photos` (`BUDDIES_PHOTOS=on` in dev) switches them, answering 503 `photos_disabled` when off. -- `live` upgrades `GET /buddies/v1/inbox/live` (signed headers `x-buddies-p`/`x-buddies-s`) to a WebSocket that sends `hello`, then `changed` with the inbox `seq` after each write, answers `ping` with `pong`, and closes with 4001 `gone` on `inbox/delete`. A plain GET gets 426 and the kill switch 503. +- `photos` stores encrypted note photos: `blob/put` (raw sealed bytes, owner-signed envelope in `x-buddies-payload`/`x-buddies-signature`, `blobId` = SHA-256 of the bytes, at most 1 MiB) writes R2 object `v1//`; `blob/get` (unsigned `{payload}` with `inboxId`, `blobId`, `token`) streams it back with `Cache-Control: no-store`, and every miss is the same 404; `blob/delete` (owner) removes them. `inbox/sync` reports `capabilities.photos`. KV `buddies:photos` (`BUDDIES_PHOTOS=on` in dev) switches them, answering 503 `photos_disabled` when off. +- `live` upgrades `GET /buddies/v1/inbox/live` (signed headers `x-buddies-payload`/`x-buddies-signature`) to a WebSocket that sends `hello`, then `changed` with the inbox `seq` after each write, answers `ping` with `pong`, and closes with 4001 `gone` on `inbox/delete`. A plain GET gets 426 and the kill switch 503. ## How to get to it (user POV) @@ -35,7 +35,7 @@ Preconditions: - **Pair two people.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "pairing happy path"`. This registers Levi and Maria, creates, fetches, and claims an invite, confirms, and exchanges cards, a roster, leave, and delete. It passes with a read-back after every write. - **Ownership after delete-all.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "delete-all"`. Other keys racing and following `inbox/delete` get 409 `conflict`, the buddy's `card/put` gets 404 `not_found`, the owner's key registers again (200, empty sync), and the buddy then gets 410 `gone` until the slot is restored. The 180-day wipe can't be reached over HTTP; unit tests in `src/buddies/relay.test.ts` cover it. - **Unsigned probe.** Fetch an unknown invite. Run `curl -s -X POST $URL/buddies/v1/invite/fetch -H "cf-connecting-ip: 198.18.3.1" -d "{\"p\":\"$(printf '{"inviteId":"AAAAAAAAAAAAAAAAAAAAAA"}' | base64 | tr '+/' '-_' | tr -d '=')\"}"`. The body is `{"ok":false,"error":"not_found","code":"not_found"}` with status 404. -- **Malformed envelope.** Run `curl -s -X POST $URL/buddies/v1/inbox/sync -H "cf-connecting-ip: 198.18.3.2" -d '{"p":"!!"}'`. The body is `{"ok":false,"error":"bad_request","code":"bad_request"}` with status 400. +- **Malformed envelope.** Run `curl -s -X POST $URL/buddies/v1/inbox/sync -H "cf-connecting-ip: 198.18.3.2" -d '{"payload":"!!"}'`. The body is `{"ok":false,"error":"bad_request","code":"bad_request"}` with status 400. - **Limits and abuse.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "relay limits"`. Past 120 `invite/fetch` a minute from one IPv6 /64 (rotating addresses), and past 60 `inbox/register` from one caller, requests get 429 `rate_limited` with `Retry-After: 60`; a flood of bad-signature syncs at someone's inbox gets 429 while its owner still syncs; the 4th open invite gets 429; unknown ops get 404. The stored-event caps, the 2,000-a-day registration quota, and the per-signer budgets need hours or days of writes, so unit tests cover them (`src/buddies/limits.test.ts`). - **Live socket.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "live socket"`. It opens a socket with `openLive` (a raw handshake in `src/test/e2e.ts`, so refusals keep their status and body), reads `hello` and `changed` back against `inbox/sync`, checks `ping`/`pong`, the 4001 close, and the 426/401/409/400 refusals. - **Photo blobs.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "photo blobs"`. A 200 KiB blob round-trips byte for byte; a retry keeps the first token and only a later expiry moves it; a wrong token, an unknown blob, and an unknown inbox all get the same 404; mismatched bytes are 400, `bytes` or a body past 1 MiB 413 `too_large`, a stranger's signature 401, a resent envelope 409 `replay`; `blob/delete` and `inbox/delete` make reads 404. To see objects really leave R2, count rows in `/v3/r2/miniflare-R2BucketObject/*.sqlite` (`SELECT COUNT(*) FROM _mf_objects`) after a run. Caps (300 blobs, 150 MiB, 100 uploads a day), expiry by the alarm, and R2 failures are unit-tested in `src/buddies/blobs.test.ts`. diff --git a/AGENTS.md b/AGENTS.md index 0026c6c..cabcae7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -266,8 +266,8 @@ whole host, `/b` included, for Android App Links. Clients are iOS and Android `slot/leave` work even when disabled. - **Photo blobs** (photos in Plan notes; contract in the protocol's "Photo blobs"): `POST /buddies/v1/blob/put` takes the raw sealed bytes as its body - with the owner-signed envelope (op `blob/put`) in `x-buddies-p` / - `x-buddies-s`; the Worker reads at most 1 MiB, recomputes + with the owner-signed envelope (op `blob/put`) in `x-buddies-payload` / + `x-buddies-signature`; the Worker reads at most 1 MiB, recomputes `blobId = b64u(SHA-256(body))`, and hands the bytes to the sender's inbox DO, which checks the signature, nonce, and caps and writes R2 object `v1//`. `blob/get` (unsigned; the read token is the @@ -317,8 +317,8 @@ whole host, `/b` included, for Android App Links. Clients are iOS and Android added in place to older inboxes). Invitations and answers (`isImmediatePushKind` in `contracts.ts`) skip the 60 s per-slot spacing but still count toward the daily cap. - **Live socket**: `GET /buddies/v1/inbox/live` upgrades to a WebSocket. The - owner-signed envelope (op `inbox/live`) rides in the `x-buddies-p` and - `x-buddies-s` headers. The inbox DO accepts it with the Hibernation API + owner-signed envelope (op `inbox/live`) rides in the `x-buddies-payload` + and `x-buddies-signature` headers. The inbox DO accepts it with the Hibernation API (`ping` gets `pong` without waking it, at most 10 sockets per inbox) and sends `{"type":"changed","seq":…}` after each write that changes `inbox/sync`. Sentry events drop `x-buddies-*` headers. Under `wrangler @@ -421,10 +421,10 @@ Shared helpers for the self-signed ops are in `src/accounts/signed.ts`. - **Handoff** (Set Up Another Device): `POST /accounts/v1/handoff/open|claim| status|send|receive|done|cancel`, code in `src/accounts/handoff.ts`. The old - phone's ops (`open`, `status`, `send`, and `cancel` with `p`/`s`) are signed + phone's ops (`open`, `status`, `send`, and `cancel` with `payload`/`signature`) are signed by the account key like `account/register` and need a registered account (`unknown_account` otherwise; a suspended one may still hand off). The new - phone's (`claim`, `receive`, `done`, and `cancel` without `p`) are plain JSON + phone's (`claim`, `receive`, `done`, and `cancel` without `payload`) are plain JSON carrying `claimToken`. One `AccountHandoff` SQLite DO per `channelId` (binding `ACCOUNT_HANDOFF`, migration `v7`, `src/accounts/handoffDO.ts`) holds the opening account, `verifier = b64u(SHA-256(claimToken))`, the new @@ -442,7 +442,8 @@ Shared helpers for the self-signed ops are in `src/accounts/signed.ts`. tier. - **Backup**: `POST /accounts/v1/backup/put|list|get|delete`, code in `src/accounts/backup.ts`. All four are signed; `put` sends its envelope in - `x-ww-p`/`x-ww-s` (Sentry drops both) and the blob as the raw body. R2 + `x-ww-payload`/`x-ww-signature` (Sentry drops both, and the legacy + `x-ww-p`/`x-ww-s`) and the blob as the raw body. R2 bucket `ACCOUNT_BACKUPS` (`ww-account-backups` prod, `ww-account-backups-dev` dev), one object per device slot at `v1//` with `customMetadata.uploadedAt` (epoch ms). `put` @@ -583,10 +584,45 @@ need the token). route's `openapi.ts` in the same PR**. - `src/openapi/openapi.test.ts` fails on any route without docs, and on any operation without a tag, summary, or responses. -- Signed Buddies/accounts ops document the envelope `{p, s}` and list the - decoded `p` fields as a table (`signedBody`). +- Signed Buddies/accounts ops document the envelope `{payload, signature}` + and list the decoded `payload` fields as a table (`signedBody`). +- The legacy short names `p`/`s` (and `x-buddies-p`/`-s`, `x-ww-p`/`-s`) are + accepted for shipped builds but appear in the docs only as a note; never + add a new short name. - The document is built once per isolate. Scalar's UI loads from jsDelivr. +## API design: readable, standard REST + +This project is open source. Anyone can read this repo, the app's repo, the +generated `/docs`, and `/openapi.json`, so hiding the API's shape buys no +security. Short or cryptic names (`p`, `s`, single letters, unexplained +abbreviations) only make the API harder to read, debug, and maintain. +Security comes from signatures, attestation, capability secrets, rate limits, +and end-to-end encryption, never from obscure names. + +When adding or changing an endpoint: + +- **Name things for a human reader.** Spell fields, headers, and query + parameters out in full camelCase (`signature`, not `s`; `payload`, not + `p`; `publicKey`, not `pub`). Abbreviate only where every developer knows + the term (`id`, `url`, `nonce`), and give every number its unit in the + name or its description (`durationSeconds`, `expiresAt` in epoch ms). + Some shipped names predate this (`ts`, `*Pub`, `seq`); don't copy them + into new routes. +- **Follow REST and HTTP conventions.** Resource-oriented paths, the right + method and status code, `Content-Type` and `Retry-After` honoured, JSON + bodies, errors in the shared `{ ok, error, code }` envelope. Where a route + deliberately departs from REST (Buddies' and accounts' `POST /{op}` keeps + ids out of URLs, which production logs), document why in its `openapi.ts`. +- **Document as you go.** Every field gets a description in the route's + `openapi.ts`; `/docs` should be enough to call the API without reading the + source. +- **Renaming a shipped field:** accept both names on the server, document + only the new one (mention the old one as legacy), switch the app, and keep + the alias as long as supported builds send it. Inside signed payloads the + server verifies the exact bytes it got, so an alias there only needs the + parser to read either name. + ## Checks before deploy ```bash diff --git a/README.md b/README.md index 4563b22..b8d968b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,38 @@ A lightweight Cloudflare Workers api and proxy service for HERE API endpoints, iOS universal links, etc. with Sentry monitoring. +## Open source, open API + +WW API is open source, like the [WitnessWork app](https://github.com/leviFrosty/witness-work) +it serves. Anyone can read the code, the protocol docs in [`docs/`](docs/), +and the generated API reference at [`/docs`](https://ww-proxy.leviwilkerson.com/docs). +Since the whole contract is public, there's nothing to gain by obscuring it: +short or cryptic field names, headers, or paths would only make the API harder +to read, debug, and build on. + +So the API aims to be standard REST, carefully maintained, and easy for people +to read: + +- **Plain names.** Fields, headers, and query parameters are spelled out in + full (`payload` and `signature`, not `p` and `s`). Units go in the name or + the description (`durationSeconds`, `expiresAt` in epoch ms). +- **Standard HTTP.** The right methods and status codes, JSON bodies, one + error envelope (`{ "ok": false, "error", "code" }`), and `Retry-After` on + every 429 and 503. Where a route departs from REST on purpose, its docs say + why. For example, the Buddies and accounts relays use `POST /{op}` so ids + stay out of URLs, which production logs. +- **Documented.** Every route and field is described in `/docs`, generated + from the routes, so you can call the API without reading the source. + +Security never depends on the API being hard to read. It comes from Ed25519 +signatures, App Attest / Play Integrity, capability secrets, rate limits, and +end-to-end encryption (the Buddies relay only ever sees ciphertext). + +Some names shipped before this rule. Those still work as legacy aliases (for +example, signed envelopes still accept `p`/`s` and the `x-buddies-p`/`-s` and +`x-ww-p`/`-s` headers from older app builds), but new code and docs use only +the full names. + ## Prerequisites - [pnpm](https://pnpm.io/) installed @@ -115,6 +147,22 @@ GitHub Actions will automatically deploy to Cloudflare Workers. Monitor deployme Full API reference: [`/docs`](https://ww-proxy.leviwilkerson.com/docs) (Scalar), generated from the routes; the raw OpenAPI 3.1 document is at [`/openapi.json`](https://ww-proxy.leviwilkerson.com/openapi.json). Locally: `http://localhost:8787/docs`. +### Signed requests (Buddies and accounts) + +Signed ops send a JSON envelope: + +```json +{ "payload": "", "signature": "" } +``` + +`signature` covers the op's context line (`ww-buddies/v1\n\n` or +`ww-account/v1\n\n`) followed by the exact decoded `payload` bytes. Routes +whose body is raw bytes, or that have no body (`blob/put`, `backup/put`, the +live WebSocket), send the same two values in `x-buddies-payload` / +`x-buddies-signature` or `x-ww-payload` / `x-ww-signature`. Each op in `/docs` +lists its decoded `payload` fields. The wire contract is +[`docs/buddies-protocol.md`](docs/buddies-protocol.md). + ### `/autocomplete` Proxies to HERE Autocomplete API. diff --git a/docs/buddies-protocol.md b/docs/buddies-protocol.md index 33caeab..61bf6f0 100644 --- a/docs/buddies-protocol.md +++ b/docs/buddies-protocol.md @@ -24,13 +24,14 @@ Since the MVP: shared Plans and Follow-up invitations, requests to join, and bad ### Request envelope ```json -{ "p": "", "s": "" } +{ "payload": "", "signature": "" } ``` -- The signed message is `UTF-8("ww-buddies/v1\n" + op + "\n")` followed by the **decoded bytes of `p`**. The server verifies against those exact bytes; it never re-serializes JSON. +- The signed message is `UTF-8("ww-buddies/v1\n" + op + "\n")` followed by the **decoded bytes of `payload`**. The server verifies against those exact bytes; it never re-serializes JSON. - `op` is the path segment after `/buddies/v1/`, e.g. `inbox/sync`. - Every signed payload carries `ts` (ms; a fractional value is accepted and floored) and `nonce` (`b64u` of 16 random bytes). The server rejects `|now − ts| > 300 000` with `stale`, and a nonce seen within the last 10 minutes for the same inbox with `replay`. -- Unsigned ops (`invite/fetch`, `invite/claim`) send `{ "p": "…" }` only; `ts`/`nonce` are not required there. +- Unsigned ops (`invite/fetch`, `invite/claim`) send `{ "payload": "…" }` only; `ts`/`nonce` are not required there. +- **Legacy names:** builds before the rename send `p` and `s` (and the headers `x-buddies-p` / `x-buddies-s`). The relay accepts both spellings indefinitely; when both are present the long name wins. New clients send the long names. ### Which key verifies @@ -116,7 +117,7 @@ Payload fields listed are **in addition** to `ts` and `nonce` for signed ops. While the app is in the foreground it keeps a WebSocket open to its own inbox, so a buddy's claim, confirmation, invitation, reply, or request to join shows up within a second instead of at the next foreground or push. The socket only says _that_ something changed; the app then runs `inbox/sync` as usual. -- **Request:** `GET {BASE}/buddies/v1/inbox/live` with `Upgrade: websocket`. Owner-signed exactly like a POST owner op, with op `inbox/live` and payload `{ inboxId, ts, nonce }`, but the envelope travels in headers so no id reaches a URL: `x-buddies-p` (the `p` value) and `x-buddies-s` (the `s` value). Same stale and replay checks. Not a POST op (`POST /inbox/live` is 404). +- **Request:** `GET {BASE}/buddies/v1/inbox/live` with `Upgrade: websocket`. Owner-signed exactly like a POST owner op, with op `inbox/live` and payload `{ inboxId, ts, nonce }`, but the envelope travels in headers so no id reaches a URL: `x-buddies-payload` (the `payload` value) and `x-buddies-signature` (the `signature` value). Same stale and replay checks. Not a POST op (`POST /inbox/live` is 404). - **Refusals (before upgrading):** kill switch off → `503 disabled`; too many requests from this caller → `429 rate_limited` (with `Retry-After`); not a WebSocket upgrade → `426 upgrade_required`; then the usual `bad_request`, `bad_signature`, `stale`, `replay`, `not_found`. A refused upgrade is retried with the usual reconnect backoff. - **Messages (server → app):** `{ "type": "hello", "seq": N }` right after the upgrade, where `N` is the inbox's current `seq` (what a full `inbox/sync` returns); then `{ "type": "changed", "seq": N }` after every committed write that changes what `inbox/sync` returns: `event/put` (new events only), `card/put`, `roster/put`, a delivered claim, and `slot/add`, `slot/remove`, `slot/leave` that changed a slot. No content, ever. Slot changes don't advance `seq`, so the app syncs on every `changed` and on a `hello` past its cursor. - **Keepalive:** the app sends `ping` about every 25 s; the relay answers `pong` without waking the inbox's Durable Object. No `pong` within 10 s means the connection is dead and the app reconnects. Anything else the app sends is ignored. @@ -153,8 +154,8 @@ Plan notes can carry photos. A photo is far too big for an event (8 KB), so the | Op | Signed | Request | Response | | ------------- | --------------------- | --------------------------------------------------------------------------------------------- | ------------------------------ | -| `blob/put` | Owner, in headers | headers `x-buddies-p`, `x-buddies-s`; payload `inboxId`, `blobId`, `bytes`, `expiresAt`, `readTokenHash`; raw body | `{ ok, expiresAt }` | -| `blob/get` | — | `{ "p": b64u(JSON { inboxId, blobId, token }) }` | the raw bytes (200) | +| `blob/put` | Owner, in headers | headers `x-buddies-payload`, `x-buddies-signature`; payload `inboxId`, `blobId`, `bytes`, `expiresAt`, `readTokenHash`; raw body | `{ ok, expiresAt }` | +| `blob/get` | — | `{ "payload": b64u(JSON { inboxId, blobId, token }) }` | the raw bytes (200) | | `blob/delete` | Owner | `inboxId`, `blobIds` (1–50) | `{ ok }` | #### `blob/put` @@ -163,14 +164,14 @@ Plan notes can carry photos. A photo is far too big for an event (8 KB), so the POST {BASE}/buddies/v1/blob/put Content-Type: application/octet-stream Content-Length: -x-buddies-p: -x-buddies-s: +x-buddies-payload: +x-buddies-signature: ``` -- **Payload** (JSON, then UTF-8, then `b64u` into `x-buddies-p`): `{ "inboxId": "<22>", "blobId": "<43>", "bytes": , "expiresAt": , "readTokenHash": "<43>", "ts": , "nonce": "<22>" }`. Field order doesn't matter; the relay verifies the bytes it got. -- **Signature:** an owner op like any other, with op `blob/put`: Ed25519 by the owner key over the bytes `UTF-8("ww-buddies/v1\nblob/put\n") ‖ decoded(x-buddies-p)`, `b64u` into `x-buddies-s`. Same `ts` (±5 min, `stale`) and `nonce` (`replay`) rules. Because the signed `blobId` is the body's SHA-256, which the relay recomputes, the signature covers the bytes too. In the app this is `envelope('blob/put', ownerSeed, { inboxId, blobId, bytes, expiresAt, readTokenHash })`, sent as headers as `openLive` does. +- **Payload** (JSON, then UTF-8, then `b64u` into `x-buddies-payload`): `{ "inboxId": "<22>", "blobId": "<43>", "bytes": , "expiresAt": , "readTokenHash": "<43>", "ts": , "nonce": "<22>" }`. Field order doesn't matter; the relay verifies the bytes it got. +- **Signature:** an owner op like any other, with op `blob/put`: Ed25519 by the owner key over the bytes `UTF-8("ww-buddies/v1\nblob/put\n") ‖ decoded(x-buddies-payload)`, `b64u` into `x-buddies-signature`. Same `ts` (±5 min, `stale`) and `nonce` (`replay`) rules. Because the signed `blobId` is the body's SHA-256, which the relay recomputes, the signature covers the bytes too. In the app this is `envelope('blob/put', ownerSeed, { inboxId, blobId, bytes, expiresAt, readTokenHash })`, sent as headers as `openLive` does. - **`bytes`:** the body's exact length, 1 to 1,048,576. Over that is `413 too_large` before the body is read. `Content-Length`, when sent, must equal `bytes` (over 1 MiB: `too_large`; otherwise different: `bad_request`). The relay stops reading one chunk past `bytes`: more than 1 MiB is `too_large`, any other length mismatch is `bad_request`. - **`expiresAt`:** integer ms with `now < expiresAt ≤ now + 90 days` (the relay's clock), else `bad_request`. The app keeps a photo until a month after its Plan ends, which covers a Plan 8 weeks out; for a later Plan it asks for 90 days and uploads the photo again as the Plan nears. - **Checks, in order:** `disabled`, `photos_disabled`, the per-caller limit (`rate_limited`), envelope and payload shape (`bad_request`), signature shape (`bad_signature`), `stale`, `too_large`, the body (`too_large`, `bad_request`), `SHA-256(body) ≠ blobId` (`bad_request`), then in the inbox: unknown inbox (`not_found`), signature (`bad_signature`), `replay`, no slots (`no_buddies`: a photo is for buddies to read, so an inbox with none can't store one), the caps (`rate_limited`). @@ -183,10 +184,10 @@ x-buddies-s: POST {BASE}/buddies/v1/blob/get Content-Type: application/json -{ "p": "", "blobId": "<43>", "token": "" })>" } +{ "payload": "", "blobId": "<43>", "token": "" })>" } ``` -- The same unsigned envelope as `invite/fetch` (no `s`, `ts`, or `nonce`). +- The same unsigned envelope as `invite/fetch` (no `signature`, `ts`, or `nonce`). - **200:** the exact bytes put, with `Content-Type: application/octet-stream`, `Content-Length`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. The app may check `b64u(SHA-256(body)) = blobId` before opening it; the AEAD tag catches tampering either way. - **One `404 not_found`** for an unknown inbox or blob, a deleted or expired blob, one whose upload hasn't finished, and a wrong token: same status, same body. The relay compares token hashes in constant time. - Not owner activity, and it changes nothing in the inbox. diff --git a/scripts/verify/buddies-wire.mjs b/scripts/verify/buddies-wire.mjs index 5255158..ce6023b 100644 --- a/scripts/verify/buddies-wire.mjs +++ b/scripts/verify/buddies-wire.mjs @@ -33,7 +33,7 @@ export const WIRE_LIMITS = { } /** `blob/put` carries its signed envelope in these headers; the body is the blob. */ -export const ENVELOPE_HEADERS = { payload: 'x-buddies-p', signature: 'x-buddies-s' } +export const ENVELOPE_HEADERS = { payload: 'x-buddies-payload', signature: 'x-buddies-signature' } /** Per-caller requests per minute by edge tier (BUDDIES_ABUSE_LIMITS.edge). */ export const WIRE_EDGE_LIMITS = { @@ -95,14 +95,14 @@ export const signingKeyFromSeed = async (seed) => { } } -/** `{p, s}` over exact payload bytes (`payload` may be a pre-serialized string). */ +/** `{payload, signature}` over exact payload bytes (`payload` may be a pre-serialized string). */ export const signedEnvelope = async (op, payload, key) => { const bytes = encoder.encode(typeof payload === 'string' ? payload : JSON.stringify(payload)) - return { p: b64u(bytes), s: b64u(await key.sign(signedMessage(op, bytes))) } + return { payload: b64u(bytes), signature: b64u(await key.sign(signedMessage(op, bytes))) } } export const unsignedEnvelope = (payload) => ({ - p: b64u(encoder.encode(typeof payload === 'string' ? payload : JSON.stringify(payload))), + payload: b64u(encoder.encode(typeof payload === 'string' ? payload : JSON.stringify(payload))), }) export const sha256 = async (bytes) => new Uint8Array(await subtle.digest('SHA-256', bytes)) diff --git a/scripts/verify/buddies-wire.test.ts b/scripts/verify/buddies-wire.test.ts index b326a78..c109e96 100644 --- a/scripts/verify/buddies-wire.test.ts +++ b/scripts/verify/buddies-wire.test.ts @@ -33,11 +33,11 @@ it('knows every op', () => { it('signs exactly what the relay verifies', async () => { const key = await wire.signingKeyFromSeed(new Uint8Array(32).fill(7)) const envelope = await wire.signedEnvelope('inbox/sync', { since: 0 }, key) - const payloadBytes = wire.fromB64u(envelope.p) + const payloadBytes = wire.fromB64u(envelope.payload) expect(wire.signedMessage('inbox/sync', payloadBytes)).toEqual( signedMessage('inbox/sync', payloadBytes) ) expect( - await verifyBuddiesSignature(key.publicKey, 'inbox/sync', payloadBytes, wire.fromB64u(envelope.s)) + await verifyBuddiesSignature(key.publicKey, 'inbox/sync', payloadBytes, wire.fromB64u(envelope.signature)) ).toBe(true) }) diff --git a/scripts/verify/dev.mjs b/scripts/verify/dev.mjs index 3f6c52f..4d4d0e5 100755 --- a/scripts/verify/dev.mjs +++ b/scripts/verify/dev.mjs @@ -440,7 +440,7 @@ async function doctor() { method: 'POST', headers: { 'content-type': 'application/json', 'cf-connecting-ip': '198.51.100.1' }, body: JSON.stringify({ - p: Buffer.from(JSON.stringify({ inviteId: 'doctorProbe00000000000' })).toString('base64url'), + payload: Buffer.from(JSON.stringify({ inviteId: 'doctorProbe00000000000' })).toString('base64url'), }), }) if (probe.status === 404 && probe.body?.error === 'not_found') ok('buddies relay enabled') @@ -458,7 +458,7 @@ async function doctor() { method: 'POST', headers: { 'content-type': 'application/json', 'cf-connecting-ip': '198.51.100.3' }, body: JSON.stringify({ - p: Buffer.from(JSON.stringify({ inboxId: 'doctorProbe00000000000', blobId: zeros, token: zeros })).toString('base64url'), + payload: Buffer.from(JSON.stringify({ inboxId: 'doctorProbe00000000000', blobId: zeros, token: zeros })).toString('base64url'), }), }) if (probe.status === 404 && probe.body?.error === 'not_found') ok('buddies photos enabled') diff --git a/scripts/verify/fuzz-buddies.mjs b/scripts/verify/fuzz-buddies.mjs index f15d742..2f7b305 100755 --- a/scripts/verify/fuzz-buddies.mjs +++ b/scripts/verify/fuzz-buddies.mjs @@ -374,7 +374,7 @@ const putBlob = async (owner, blob, { fields = {}, body = blob.bytes, key = owne return send({ op: 'blob/put', body, - headers: { 'content-type': 'application/octet-stream', [ENVELOPE_HEADERS.payload]: envelope.p, [ENVELOPE_HEADERS.signature]: envelope.s }, + headers: { 'content-type': 'application/octet-stream', [ENVELOPE_HEADERS.payload]: envelope.payload, [ENVELOPE_HEADERS.signature]: envelope.signature }, expect, error, note: note ?? `blob/put ${body.length} bytes`, @@ -401,7 +401,7 @@ const GENERATORS = { 'tampered-signature': async (rng, canary) => { const op = rng.pick(CANARY_TARGET_OPS) const env = await signed(op, keyFor(op, canary), await validPayload(op, canary)) - env.s = b64u(flipBit(fromB64u(env.s), rng)) + env.signature = b64u(flipBit(fromB64u(env.signature), rng)) await send({ op, body: env, expect: [401], error: 'bad_signature', note: `bit-flipped signature on ${op}` }) }, @@ -413,7 +413,7 @@ const GENERATORS = { const forged = { ...payload, nonce: rid() } if ('since' in forged) forged.since = rng.int(1, 1_000) if ('blob' in forged) forged.blob = blob(64) - env.p = b64u(new TextEncoder().encode(JSON.stringify(forged))) + env.payload = b64u(new TextEncoder().encode(JSON.stringify(forged))) await send({ op, body: env, expect: [401], error: 'bad_signature', note: `payload swapped under ${op} signature` }) }, @@ -511,28 +511,32 @@ const GENERATORS = { /** Envelope-level garbage: bad JSON, wrong shapes, bad base64, bad UTF-8. */ 'malformed-envelope': async (rng, canary) => { const op = rng.pick(ALL_OPS) - const goodP = (await signed('inbox/sync', canary.owner.key, await validPayload('inbox/sync', canary))).p + const goodPayload = (await signed('inbox/sync', canary.owner.key, await validPayload('inbox/sync', canary))).payload const cases = [ ['raw', '{not json'], ['raw', ''], ['raw', 'null'], ['raw', '[]'], ['raw', '42'], - ['raw', '"p"'], + ['raw', '"payload"'], + ['raw', '{"payload":'], ['raw', '{"p":'], ['raw', '['.repeat(5_000) + ']'.repeat(5_000)], - ['raw', `{"p":"${'A'.repeat(100)}"${',"x":1'.repeat(2_000)}}`], - ['json', { p: 1 }], + ['raw', `{"payload":"${'A'.repeat(100)}"${',"x":1'.repeat(2_000)}}`], + ['json', { payload: 1 }], + ['json', { payload: '' }], + ['json', { payload: null, signature: 'x' }], ['json', { p: '' }], ['json', { p: null, s: 'x' }], - ['json', { p: 'not base64!' }], - ['json', { p: 'A' }], - ['json', { p: `${goodP}==` }], - ['json', { p: b64u(Buffer.from('not json')) }], - ['json', { p: b64u(Buffer.from('[1,2]')) }], - ['json', { p: b64u(Buffer.from('"str"')) }], - ['json', { p: b64u(Buffer.from([0xff, 0xfe, 0x7b, 0x7d])) }], - ['json', { p: b64u(Buffer.from([0xef, 0xbb, 0xbf, 0x7b, 0x7d])) }], + ['json', { payload: 'not base64!' }], + ['json', { payload: 'A' }], + ['json', { payload: `${goodPayload}==` }], + ['json', { p: `${goodPayload}==` }], + ['json', { payload: b64u(Buffer.from('not json')) }], + ['json', { payload: b64u(Buffer.from('[1,2]')) }], + ['json', { payload: b64u(Buffer.from('"str"')) }], + ['json', { payload: b64u(Buffer.from([0xff, 0xfe, 0x7b, 0x7d])) }], + ['json', { payload: b64u(Buffer.from([0xef, 0xbb, 0xbf, 0x7b, 0x7d])) }], ['bytes', Uint8Array.from([0xff, 0xfe, 0xfd])], ['bom', null], ] @@ -560,12 +564,12 @@ const GENERATORS = { }) }, - /** Valid payload, malformed `s` (missing, wrong type, wrong length, bad alphabet). */ + /** Valid payload, malformed `signature` (missing, wrong type, wrong length, bad alphabet). */ 'malformed-signature': async (rng, canary) => { const op = rng.pick(CANARY_TARGET_OPS) const env = await signed(op, keyFor(op, canary), await validPayload(op, canary)) - const s = rng.pick([undefined, null, 7, '', env.s.slice(1), `${env.s}A`, `${env.s.slice(0, 85)}!`, `${env.s.slice(0, 84)}==`, 'A'.repeat(86), {}]) - await send({ op, body: { p: env.p, s }, expect: [401], error: 'bad_signature', note: `s=${clip(JSON.stringify(s) ?? 'undefined', 40)}` }) + const signature = rng.pick([undefined, null, 7, '', env.signature.slice(1), `${env.signature}A`, `${env.signature.slice(0, 85)}!`, `${env.signature.slice(0, 84)}==`, 'A'.repeat(86), {}]) + await send({ op, body: { payload: env.payload, signature }, expect: [401], error: 'bad_signature', note: `signature=${clip(JSON.stringify(signature) ?? 'undefined', 40)}` }) }, /** Blobs at the decoded-size limit succeed; one byte over is rejected. */ @@ -607,7 +611,7 @@ const GENERATORS = { 'oversized-envelope': async (rng, canary) => { const op = rng.pick(ALL_OPS) const bytes = L.requestBytes + rng.int(1, 64 * 1024) - const filler = `{"p":"${'A'.repeat(bytes)}"}` + const filler = `{"payload":"${'A'.repeat(bytes)}"}` if (rng.bool()) { await send({ op, body: filler, headers: { 'content-type': 'application/json' }, expect: [400], error: 'bad_request', note: `${filler.length}-byte envelope` }) } else { diff --git a/src/accounts/accounts.test.ts b/src/accounts/accounts.test.ts index c88e260..204a91f 100644 --- a/src/accounts/accounts.test.ts +++ b/src/accounts/accounts.test.ts @@ -80,8 +80,8 @@ const accountEnvelope = async ( ) => { const bytes = encoder.encode(JSON.stringify(payload)) return { - p: b64u(bytes), - s: b64u(await key.sign(message(op, bytes))), + payload: b64u(bytes), + signature: b64u(await key.sign(message(op, bytes))), } } diff --git a/src/accounts/backup.test.ts b/src/accounts/backup.test.ts index 6403353..c2e7f8f 100644 --- a/src/accounts/backup.test.ts +++ b/src/accounts/backup.test.ts @@ -59,8 +59,8 @@ const envelope = async ( }) ) return { - p: b64u(bytes), - s: b64u(await signer.sign(accountSignedMessage(op, bytes))), + payload: b64u(bytes), + signature: b64u(await signer.sign(accountSignedMessage(op, bytes))), } } @@ -105,7 +105,7 @@ interface PutOptions { const put = async (key: SigningKey, options: PutOptions = {}) => { const bytes = options.bytes ?? randomBytes(4_096) - const { p, s } = await envelope( + const { payload, signature } = await envelope( 'backup/put', key, { @@ -119,8 +119,8 @@ const put = async (key: SigningKey, options: PutOptions = {}) => { const headers: Record = { 'content-type': 'application/octet-stream', 'cf-connecting-ip': options.ip ?? freshIp(), - 'x-ww-p': p, - 'x-ww-s': s, + 'x-ww-payload': payload, + 'x-ww-signature': signature, } for (const [name, value] of Object.entries(options.headers ?? {})) { if (value == null) delete headers[name] @@ -197,6 +197,27 @@ describe('backup', () => { expect((await list(key)).body.slots).toEqual([]) }) + it('still accepts the short x-ww-p / x-ww-s headers older builds send', async () => { + const { key } = await register() + const bytes = randomBytes(1_000) + const { payload, signature } = await envelope('backup/put', key, { + slotId: randomId(), + size: bytes.byteLength, + sha256: await sha256B64u(bytes), + }) + expect( + await put(key, { + bytes, + headers: { + 'x-ww-payload': null, + 'x-ww-signature': null, + 'x-ww-p': payload, + 'x-ww-s': signature, + }, + }) + ).toMatchObject({ status: 200, body: { ok: true } }) + }) + it('keeps each account to its own slots', async () => { const alice = await register() const bob = await register() @@ -287,7 +308,7 @@ describe('backup', () => { // A declared Content-Length is refused before the body or signature is read. expect( await put(key, { - headers: { 'content-length': String(20 * MIB + 1), 'x-ww-s': 'nope' }, + headers: { 'content-length': String(20 * MIB + 1), 'x-ww-signature': 'nope' }, }) ).toMatchObject(tooLarge) // A signed size over the cap. @@ -333,7 +354,7 @@ describe('backup', () => { expect( await put(key, { signer: await SigningKey.generate() }) ).toMatchObject({ status: 401, body: { code: 'bad_signature' } }) - expect(await put(key, { headers: { 'x-ww-p': null } })).toMatchObject({ + expect(await put(key, { headers: { 'x-ww-payload': null } })).toMatchObject({ status: 400, body: { code: 'bad_request' }, }) diff --git a/src/accounts/backup.ts b/src/accounts/backup.ts index 3ad442d..2512766 100644 --- a/src/accounts/backup.ts +++ b/src/accounts/backup.ts @@ -104,7 +104,7 @@ const hex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') /** - * `backup/put`: the envelope in `x-ww-p`/`x-ww-s`, the blob as the raw body. + * `backup/put`: the envelope in `x-ww-payload`/`x-ww-signature`, the blob as the raw body. * A declared `Content-Length` over the cap is refused before anything is * read; otherwise the body is read up to the signed `size` and checked * against `size` and `sha256` before it replaces the slot. diff --git a/src/accounts/contracts.ts b/src/accounts/contracts.ts index 5e40c60..b429498 100644 --- a/src/accounts/contracts.ts +++ b/src/accounts/contracts.ts @@ -10,8 +10,8 @@ import { * account is a random `accountId` the server mints and the public key of the * account signing key, which every device derives from the User's account key. * - * Requests use the Buddies envelope `{ "p": b64u(JSON), "s": b64u(Ed25519) }` - * under their own signing context, so no account signature can pass for a + * Requests use the Buddies envelope + * `{ "payload": b64u(JSON), "signature": b64u(Ed25519) }` under their own signing context, so no account signature can pass for a * Buddies one or the other way round. */ @@ -32,6 +32,11 @@ export type AccountsOp = (typeof ACCOUNTS_OPS)[number] /** `backup/put` sends its signed envelope in these headers; the body is the blob. */ export const ACCOUNT_ENVELOPE_HEADERS = { + payload: 'x-ww-payload', + signature: 'x-ww-signature', +} as const +/** The short header names older app builds send; still accepted. */ +export const LEGACY_ACCOUNT_ENVELOPE_HEADERS = { payload: 'x-ww-p', signature: 'x-ww-s', } as const @@ -77,7 +82,7 @@ const concat = (prefix: Uint8Array, rest: Uint8Array): Uint8Array => { return message } -/** The bytes an account op's signature covers: context, op, then `p` decoded. */ +/** The bytes an account op's signature covers: context, op, then `payload` decoded. */ export const accountSignedMessage = ( op: AccountsOp, payloadBytes: Uint8Array diff --git a/src/accounts/handoff.test.ts b/src/accounts/handoff.test.ts index 005f2a3..1d16b90 100644 --- a/src/accounts/handoff.test.ts +++ b/src/accounts/handoff.test.ts @@ -74,8 +74,8 @@ const signed = async ( }) ) return { - p: b64u(bytes), - s: b64u(await signer.sign(accountSignedMessage(op, bytes))), + payload: b64u(bytes), + signature: b64u(await signer.sign(accountSignedMessage(op, bytes))), } } diff --git a/src/accounts/handoff.ts b/src/accounts/handoff.ts index d4c2f93..1beae9c 100644 --- a/src/accounts/handoff.ts +++ b/src/accounts/handoff.ts @@ -1,6 +1,10 @@ import type { AppContext, Environment } from '../types' import { isPlainObject } from '../buddies/contracts' -import { parseEnvelope, readEnvelopeText } from '../buddies/envelope' +import { + isEnvelopeBody, + parseEnvelope, + readEnvelopeText, +} from '../buddies/envelope' import { buddiesErrorResponse } from '../buddies/errorResponse' import { ACCOUNT_LIMITS, @@ -139,7 +143,7 @@ const handleDone = guarded('handoff/done', async (c) => { }) /** - * Either phone: a signed envelope (`p`, `s`) is the old phone, anything else + * Either phone: a signed envelope (`payload`, `signature`) is the old phone, anything else * must be the new phone's `channelId` and `claimToken`. */ const handleCancel = guarded('handoff/cancel', async (c) => { @@ -152,7 +156,7 @@ const handleCancel = guarded('handoff/cancel', async (c) => { } catch { body = null } - if (isPlainObject(body) && 'p' in body) { + if (isPlainObject(body) && isEnvelopeBody(body)) { const verified = await verifyEnvelope( 'handoff/cancel', text == null ? null : parseEnvelope(text) diff --git a/src/accounts/openapi.ts b/src/accounts/openapi.ts index abf4992..e241045 100644 --- a/src/accounts/openapi.ts +++ b/src/accounts/openapi.ts @@ -28,7 +28,7 @@ const keySignedBody = (op: string) => }), nonce: buddiesId('A fresh random nonce'), }), - `A signed envelope; \`s\` is the account key’s signature over \`ww-account/v1\\n${op}\\n\` followed by \`p\`’s bytes.` + `A signed envelope; \`signature\` is the account key’s signature over \`ww-account/v1\\n${op}\\n\` followed by the decoded \`payload\` bytes.` ) const signatureErrors = { @@ -427,14 +427,14 @@ const keyPayloadFields = (op: string, fields: z.ZodRawShape) => nonce: buddiesId('A fresh random nonce'), ...fields, }) - ) + `\n\nSigned over \`ww-account/v1\\n${op}\\n\` followed by \`p\`’s bytes.` + ) + `\n\nSigned over \`ww-account/v1\\n${op}\\n\` followed by the decoded \`payload\` bytes.` export const backupPutDocs = describeRoute({ tags: ['Accounts'], summary: 'Upload a backup', - description: `Replaces this device’s slot with an end-to-end encrypted backup (\`0x01 ‖ nonce[24] ‖ XChaCha20-Poly1305\`). The body is the raw blob, at most 20 MiB; the signed envelope (op \`backup/put\`) rides in the \`x-ww-p\` and \`x-ww-s\` headers. + description: `Replaces this device’s slot with an end-to-end encrypted backup (\`0x01 ‖ nonce[24] ‖ XChaCha20-Poly1305\`). The body is the raw blob, at most 20 MiB; the signed envelope (op \`backup/put\`) rides in the \`x-ww-payload\` and \`x-ww-signature\` headers. -**Decoded \`x-ww-p\`:** +**Decoded \`x-ww-payload\`:** ${keyPayloadFields('backup/put', { slotId, @@ -448,14 +448,14 @@ ${keyPayloadFields('backup/put', { Limits: 20 MiB a blob, 8 slots an account, one upload a slot a minute, and the Buddies photo-upload tier (60 a minute per caller IP). ${backupNotes}`, parameters: [ { - name: 'x-ww-p', + name: 'x-ww-payload', in: 'header' as const, required: true, schema: { type: 'string' as const }, - description: 'The payload JSON, UTF-8 encoded, base64url.', + description: 'The request’s fields as UTF-8 JSON, base64url.', }, { - name: 'x-ww-s', + name: 'x-ww-signature', in: 'header' as const, required: true, schema: { type: 'string' as const }, diff --git a/src/accounts/signed.ts b/src/accounts/signed.ts index 176b51b..34191bc 100644 --- a/src/accounts/signed.ts +++ b/src/accounts/signed.ts @@ -9,7 +9,7 @@ import { } from '../buddies/contracts' import { type Envelope, - envelopeFromParts, + envelopeFromHeaders, parseEnvelope, readEnvelopeText, verifyEd25519, @@ -27,6 +27,7 @@ import { import { ACCOUNT_ENVELOPE_HEADERS, ACCOUNT_FIELD_PARSERS, + LEGACY_ACCOUNT_ENVELOPE_HEADERS, type AccountFields, type AccountKeyPayload, type AccountsOp, @@ -86,16 +87,17 @@ export const verifiedBody = async ( return verifyEnvelope(op, text == null ? null : parseEnvelope(text)) } -/** A self-signed op whose envelope rides in `x-ww-p` and `x-ww-s`. */ +/** A self-signed op whose envelope rides in `x-ww-payload` and `x-ww-signature`. */ export const verifiedHeaders = ( c: AppContext, op: K ): Promise> => verifyEnvelope( op, - envelopeFromParts( - c.req.header(ACCOUNT_ENVELOPE_HEADERS.payload), - c.req.header(ACCOUNT_ENVELOPE_HEADERS.signature) + envelopeFromHeaders( + c.req.raw.headers, + ACCOUNT_ENVELOPE_HEADERS, + LEGACY_ACCOUNT_ENVELOPE_HEADERS ) ) diff --git a/src/buddies/blobs.test.ts b/src/buddies/blobs.test.ts index fe9cb0f..ef6d5cf 100644 --- a/src/buddies/blobs.test.ts +++ b/src/buddies/blobs.test.ts @@ -283,8 +283,8 @@ describe('blob/put and blob/get', () => { const liveSigned = await blobPutHeaders(payload, owner.key) const signedAsOther = { ...liveSigned, - 'x-buddies-s': (await envelope('inbox/live', payload, owner.key)) - .s as string, + 'x-buddies-signature': (await envelope('inbox/live', payload, owner.key)) + .signature as string, } expect(await put(signedAsOther)).toEqual({ status: 401, @@ -293,12 +293,12 @@ describe('blob/put and blob/get', () => { // A payload swapped under the owner's signature. const forged = { ...liveSigned, - 'x-buddies-p': ( + 'x-buddies-payload': ( await blobPutHeaders( { ...payload, expiresAt: payload.expiresAt + 1 }, owner.key ) - )['x-buddies-p'], + )['x-buddies-payload'], } expect(await put(forged)).toEqual({ status: 401, @@ -311,11 +311,11 @@ describe('blob/put and blob/get', () => { }) expect( await put({ - 'x-buddies-p': '!!', - 'x-buddies-s': liveSigned['x-buddies-s'], + 'x-buddies-payload': '!!', + 'x-buddies-signature': liveSigned['x-buddies-signature'], }) ).toEqual({ status: 400, body: relayError('bad_request') }) - expect(await put({ ...liveSigned, 'x-buddies-s': 'A'.repeat(85) })).toEqual( + expect(await put({ ...liveSigned, 'x-buddies-signature': 'A'.repeat(85) })).toEqual( { status: 401, body: relayError('bad_signature') } ) // Stale on either side. diff --git a/src/buddies/contracts.ts b/src/buddies/contracts.ts index 2e4b1eb..b1965af 100644 --- a/src/buddies/contracts.ts +++ b/src/buddies/contracts.ts @@ -40,14 +40,19 @@ export const BUDDIES_UNSIGNED_OPS = ['invite/fetch', 'invite/claim'] as const */ export const BUDDIES_LIVE_OP = 'inbox/live' /** - * Request headers that carry a signed envelope's `p` and `s` when the body - * can't: the live upgrade (no body) and `blob/put` (raw bytes). + * Request headers that carry a signed envelope's `payload` and `signature` + * when the body can't: the live upgrade (no body) and `blob/put` (raw bytes). */ export const BUDDIES_ENVELOPE_HEADERS = { + payload: 'x-buddies-payload', + signature: 'x-buddies-signature', +} as const +/** The short header names older app builds send; still accepted. */ +export const LEGACY_BUDDIES_ENVELOPE_HEADERS = { payload: 'x-buddies-p', signature: 'x-buddies-s', } as const -/** Request headers that carry the live upgrade's `p` and `s`. */ +/** Request headers that carry the live upgrade's `payload` and `signature`. */ export const BUDDIES_LIVE_HEADERS = BUDDIES_ENVELOPE_HEADERS /** @@ -719,7 +724,7 @@ export const BUDDIES_PAYLOAD_PARSERS: PayloadParsers = { /** The authenticated parts of a signed request, forwarded to the inbox DO. */ export interface SignedCall { - /** Decoded bytes of the envelope's `p`, exactly as the client signed them. */ + /** Decoded bytes of the envelope's `payload`, exactly as the client signed them. */ payloadBytes: Uint8Array signature: Uint8Array } diff --git a/src/buddies/envelope.test.ts b/src/buddies/envelope.test.ts index 2116a88..e539e98 100644 --- a/src/buddies/envelope.test.ts +++ b/src/buddies/envelope.test.ts @@ -135,26 +135,36 @@ describe('verifyBuddiesSignature', () => { describe('parseEnvelope', () => { it('returns the decoded payload bytes, the parsed payload, and the raw signature', () => { const payload = '{"inboxId":"abc","since":0}' - const parsed = parseEnvelope(JSON.stringify({ p: b64u(payload), s: 'sig' })) + const parsed = parseEnvelope( + JSON.stringify({ payload: b64u(payload), signature: 'sig' }) + ) expect(parsed).not.toBeNull() expect(new TextDecoder().decode(parsed?.payloadBytes)).toBe(payload) expect(parsed?.payload).toEqual({ inboxId: 'abc', since: 0 }) expect(parsed?.signature).toBe('sig') }) + it('accepts the legacy short names `p` and `s`', () => { + const payload = '{"inboxId":"abc"}' + const parsed = parseEnvelope(JSON.stringify({ p: b64u(payload), s: 'sig' })) + expect(parsed?.payload).toEqual({ inboxId: 'abc' }) + expect(parsed?.signature).toBe('sig') + }) + it.each([ ['non-JSON', 'nope'], ['an array', '[]'], - ['a missing p', '{"s":"x"}'], - ['an empty p', '{"p":""}'], - ['padded base64', JSON.stringify({ p: `${b64u('{"a":1}')}=` })], - ['standard base64', JSON.stringify({ p: '+/+/' })], - ['a payload that is not JSON', JSON.stringify({ p: b64u('hello') })], - ['a payload that is an array', JSON.stringify({ p: b64u('[1]') })], - ['a payload that is null', JSON.stringify({ p: b64u('null') })], + ['a missing payload', '{"signature":"x"}'], + ['an empty payload', '{"payload":""}'], + ['an empty legacy p', '{"p":""}'], + ['padded base64', JSON.stringify({ payload: `${b64u('{"a":1}')}=` })], + ['standard base64', JSON.stringify({ payload: '+/+/' })], + ['a payload that is not JSON', JSON.stringify({ payload: b64u('hello') })], + ['a payload that is an array', JSON.stringify({ payload: b64u('[1]') })], + ['a payload that is null', JSON.stringify({ payload: b64u('null') })], [ 'invalid UTF-8', - JSON.stringify({ p: b64u(Uint8Array.from([0x7b, 0xff, 0x7d])) }), + JSON.stringify({ payload: b64u(Uint8Array.from([0x7b, 0xff, 0x7d])) }), ], ])('rejects %s', (_, text) => { expect(parseEnvelope(text)).toBeNull() @@ -199,7 +209,7 @@ describe('readEnvelopeText', () => { /** * The signed envelope from witness-work's known-answer file * (`src/features/buddies/lib/testing/cryptoVectors.json`), which iOS and - * Android check in Hermes. The relay's WebCrypto Ed25519 must accept the same + * Android check in Hermes. It uses the legacy short names `p` and `s`. The relay's WebCrypto Ed25519 must accept the same * bytes the app's @noble/curves signs. */ const APP_VECTOR = { diff --git a/src/buddies/envelope.ts b/src/buddies/envelope.ts index d53a6fe..9402237 100644 --- a/src/buddies/envelope.ts +++ b/src/buddies/envelope.ts @@ -4,6 +4,7 @@ import { BUDDIES_LIMITS, BUDDIES_LIVE_OP, BUDDIES_PAYLOAD_PARSERS, + LEGACY_BUDDIES_ENVELOPE_HEADERS, decodeB64u, decodeSignature, fail, @@ -16,22 +17,24 @@ import { } from './contracts' /** - * Request envelope `{ "p": b64u(UTF-8 JSON payload), "s": b64u(Ed25519 sig) }`. + * Request envelope + * `{ "payload": b64u(UTF-8 JSON payload), "signature": b64u(Ed25519 sig) }`. * The live socket's upgrade and `blob/put` carry the same two values in - * headers instead. + * headers instead. Older app builds send the short names `p` and `s` (and + * `x-buddies-p` / `x-buddies-s`); those are still accepted. * - * The signature covers `UTF-8("ww-buddies/v1\n" + op + "\n") ‖ decoded(p)`: the - * exact bytes the client sent, never a re-serialization of the parsed JSON. + * The signature covers `UTF-8("ww-buddies/v1\n" + op + "\n") ‖ decoded(payload)`: + * the exact bytes the client sent, never a re-serialization of the parsed JSON. */ const SIGNING_CONTEXT = 'ww-buddies/v1\n' const encoder = new TextEncoder() export interface Envelope { - /** Decoded bytes of `p`, exactly as signed. */ + /** Decoded bytes of `payload`, exactly as signed. */ payloadBytes: Uint8Array payload: Record - /** Raw `s`; unsigned ops ignore it. */ + /** Raw `signature`; unsigned ops ignore it. */ signature: unknown } @@ -85,13 +88,25 @@ export const parseEnvelope = (text: string): Envelope | null => { // Never surface the parser message: V8 quotes the offending input. return null } - return isPlainObject(body) ? envelopeFromParts(body.p, body.s) : null + return isPlainObject(body) + ? envelopeFromParts(body.payload ?? body.p, body.signature ?? body.s) + : null } -/** Decodes `p` and its JSON; `s` passes through. Null means `bad_request`. */ -export const envelopeFromParts = (p: unknown, s: unknown): Envelope | null => { - if (typeof p !== 'string' || !p) return null - const payloadBytes = decodeB64u(p) +/** True when a JSON body is a signed envelope (either naming). */ +export const isEnvelopeBody = (body: Record): boolean => + 'payload' in body || 'p' in body + +/** + * Decodes `payload` and its JSON; `signature` passes through. Null means + * `bad_request`. + */ +export const envelopeFromParts = ( + encodedPayload: unknown, + signature: unknown +): Envelope | null => { + if (typeof encodedPayload !== 'string' || !encodedPayload) return null + const payloadBytes = decodeB64u(encodedPayload) if (!payloadBytes) return null let payload: unknown @@ -105,7 +120,7 @@ export const envelopeFromParts = (p: unknown, s: unknown): Envelope | null => { return null } if (!isPlainObject(payload)) return null - return { payloadBytes, payload, signature: s } + return { payloadBytes, payload, signature } } /** @@ -129,9 +144,24 @@ export const isWebSocketUpgrade = (request: Request): boolean => request.headers.get('upgrade')?.trim().toLowerCase() === 'websocket' /** - * A signed call whose envelope rides in the `x-buddies-p` and `x-buddies-s` - * headers (`inbox/live`, `blob/put`). Both the Worker (to route) and the inbox - * DO (to verify) read it, so neither trusts the other's parse. + * Reads an envelope from request headers: the long names, else the legacy + * short ones older app builds send. + */ +export const envelopeFromHeaders = ( + headers: Headers, + names: { payload: string; signature: string }, + legacy: { payload: string; signature: string } +): Envelope | null => + envelopeFromParts( + headers.get(names.payload) ?? headers.get(legacy.payload), + headers.get(names.signature) ?? headers.get(legacy.signature) + ) + +/** + * A signed call whose envelope rides in the `x-buddies-payload` and + * `x-buddies-signature` headers (`inbox/live`, `blob/put`). Both the Worker + * (to route) and the inbox DO (to verify) read it, so neither trusts the + * other's parse. */ const readHeaderCall = < K extends typeof BUDDIES_LIVE_OP | typeof BUDDIES_BLOB_PUT_OP, @@ -140,9 +170,10 @@ const readHeaderCall = < headers: Headers, now: number ): BuddiesResult> => { - const envelope = envelopeFromParts( - headers.get(BUDDIES_ENVELOPE_HEADERS.payload), - headers.get(BUDDIES_ENVELOPE_HEADERS.signature) + const envelope = envelopeFromHeaders( + headers, + BUDDIES_ENVELOPE_HEADERS, + LEGACY_BUDDIES_ENVELOPE_HEADERS ) return envelope ? signedCall(op, envelope, now) : fail('bad_request') } diff --git a/src/buddies/live.test.ts b/src/buddies/live.test.ts index e927d3f..37b40d8 100644 --- a/src/buddies/live.test.ts +++ b/src/buddies/live.test.ts @@ -129,29 +129,41 @@ describe('opening a live socket', () => { expect( await h.live({ upgrade: 'websocket', - 'x-buddies-p': sync.p, - 'x-buddies-s': sync.s!, + 'x-buddies-payload': sync.payload, + 'x-buddies-signature': sync.signature!, }) ).toEqual(error(401, 'bad_signature')) const fresh = await liveHeaders(fields(), owner.key) - const { 'x-buddies-s': _signature, ...unsigned } = fresh + const { 'x-buddies-signature': _signature, ...unsigned } = fresh expect(await h.live(unsigned)).toEqual(error(401, 'bad_signature')) - expect(await h.live({ ...fresh, 'x-buddies-s': 'x' })).toEqual( + expect(await h.live({ ...fresh, 'x-buddies-signature': 'x' })).toEqual( error(401, 'bad_signature') ) expect(await h.live({ upgrade: 'websocket' })).toEqual( error(400, 'bad_request') ) - expect(await h.live({ ...fresh, 'x-buddies-p': '!!' })).toEqual( + expect(await h.live({ ...fresh, 'x-buddies-payload': '!!' })).toEqual( error(400, 'bad_request') ) expect(await owner.connect({ inboxId: 'short' })).toEqual( error(400, 'bad_request') ) - // Only the one good attempt got a socket. - expect(h.inboxes.storage(owner.inboxId).sockets()).toHaveLength(1) + // Older builds send the short header names; they still open a socket. + const legacy = await envelope('inbox/live', fields(), owner.key) + expect( + ( + await h.live({ + upgrade: 'websocket', + 'x-buddies-p': legacy.payload, + 'x-buddies-s': legacy.signature!, + }) + ).status + ).toBe(101) + + // Only the two good attempts got a socket. + expect(h.inboxes.storage(owner.inboxId).sockets()).toHaveLength(2) }) it('needs a GET WebSocket upgrade', async () => { diff --git a/src/buddies/openapi.ts b/src/buddies/openapi.ts index 2388766..afd578c 100644 --- a/src/buddies/openapi.ts +++ b/src/buddies/openapi.ts @@ -5,6 +5,7 @@ import { apiErrorSchema, staleErrorSchema, b64u, + unsignedEnvelopeSchema, buddiesId, errorResponse, fieldTable, @@ -104,7 +105,7 @@ const SIGNED_BY = { '**Signed by** the writer key registered for (`inboxId`, `slotId`) with `slot/add`.', self: '**Self-signed** by `ownerPub` in the payload (proof of possession).', unsigned: - '**Unsigned:** send `{ "p": … }` only, no `s`, `ts`, or `nonce`. Knowing the ids and secrets is the capability.', + '**Unsigned:** send `{ "payload": … }` only, no `signature`, `ts`, or `nonce`. Knowing the ids and secrets is the capability.', } as const const paragraphs = (...lines: string[]) => lines.join('\n\n') @@ -312,7 +313,7 @@ export const buddiesOpDocs: Record = { 'Links the inbox to an account. Absent from builds before accounts.', }), }), - 'Self-signed: `s` verifies against the payload’s own `ownerPub`.' + 'Self-signed: `signature` verifies against the payload’s own `ownerPub`.' ), responses: { 200: jsonResponse( @@ -724,10 +725,8 @@ export const buddiesOpDocs: Record = { KILL_SWITCH_OFF ), requestBody: jsonBody( - z.object({ - p: b64u('The payload JSON, UTF-8 encoded'), - }), - `Unsigned envelope.\n\n**Decoded \`p\`:**\n\n${fieldTable( + unsignedEnvelopeSchema, + `Unsigned envelope.\n\n**Decoded \`payload\`:**\n\n${fieldTable( z.object({ inviteId: buddiesId('The invite, from the link secret') }) )}` ), @@ -762,10 +761,8 @@ export const buddiesOpDocs: Record = { 'App Attest is not required yet.' ), requestBody: jsonBody( - z.object({ - p: b64u('The payload JSON, UTF-8 encoded'), - }), - `Unsigned envelope.\n\n**Decoded \`p\`:**\n\n${fieldTable( + unsignedEnvelopeSchema, + `Unsigned envelope.\n\n**Decoded \`payload\`:**\n\n${fieldTable( z.object({ inviteId: buddiesId('The invite'), claimSecret: bytes32('The claim secret derived from the link'), @@ -793,18 +790,18 @@ export const buddiesOpDocs: Record = { const envelopeHeaders = (op: string, payload: z.ZodType) => [ { - name: 'x-buddies-p', + name: 'x-buddies-payload', in: 'header' as const, required: true, schema: { type: 'string' as const }, - description: `The envelope’s \`p\`: the payload JSON, UTF-8, base64url without padding.\n\n**Decoded:**\n\n${fieldTable(payload)}`, + description: `The envelope’s \`payload\`: the request’s fields as UTF-8 JSON, base64url without padding.\n\n**Decoded:**\n\n${fieldTable(payload)}`, }, { - name: 'x-buddies-s', + name: 'x-buddies-signature', in: 'header' as const, required: true, schema: { type: 'string' as const }, - description: `The envelope’s \`s\`: Ed25519 signature by the owner key over \`ww-buddies/v1\\n${op}\\n\` followed by the decoded \`x-buddies-p\` bytes (base64url, no padding).`, + description: `The envelope’s \`signature\`: Ed25519 signature by the owner key over \`ww-buddies/v1\\n${op}\\n\` followed by the decoded \`x-buddies-payload\` bytes (base64url, no padding).`, }, ] @@ -838,7 +835,7 @@ export const buddiesLiveDocs = describeRoute({ 101: { description: 'Switching protocols: the socket is open.' }, ...refusals({ ...ownerRefusals('read'), - bad_request: 'Missing or malformed `x-buddies-p`, or a bad payload.', + bad_request: 'Missing or malformed `x-buddies-payload`, or a bad payload.', upgrade_required: 'Not a WebSocket upgrade (`Upgrade: websocket`).', }), }, @@ -927,8 +924,8 @@ export const buddiesBlobGetDocs = describeRoute({ PHOTOS_SWITCH ), requestBody: jsonBody( - z.object({ p: b64u('The payload JSON, UTF-8 encoded') }), - `Unsigned envelope.\n\n**Decoded \`p\`:**\n\n${fieldTable( + unsignedEnvelopeSchema, + `Unsigned envelope.\n\n**Decoded \`payload\`:**\n\n${fieldTable( z.object({ inboxId: buddiesId('The sender’s inbox'), blobId: bytes32('The blob'), diff --git a/src/buddies/relay.test.ts b/src/buddies/relay.test.ts index 279a5a3..9497f20 100644 --- a/src/buddies/relay.test.ts +++ b/src/buddies/relay.test.ts @@ -120,19 +120,35 @@ describe('signed envelopes', () => { owner.key ) // Valid payload bytes from one request, signature from another. - expect(await h.post('/inbox/sync', { p: tampered.p, s: signed.s })).toEqual( - { - status: 401, - body: relayError('bad_signature'), - } - ) - expect(await h.post('/inbox/sync', { p: signed.p })).toEqual({ + expect( + await h.post('/inbox/sync', { + payload: tampered.payload, + signature: signed.signature, + }) + ).toEqual({ + status: 401, + body: relayError('bad_signature'), + }) + expect(await h.post('/inbox/sync', { payload: signed.payload })).toEqual({ status: 401, body: relayError('bad_signature'), }) expect((await h.post('/inbox/sync', signed)).status).toBe(200) }) + it('still accepts the short envelope names older builds send', async () => { + const owner = await Owner.create(h) + const signed = await envelope( + 'inbox/sync', + { inboxId: owner.inboxId, since: 0, ts: Date.now(), nonce: randomId() }, + owner.key + ) + expect( + (await h.post('/inbox/sync', { p: signed.payload, s: signed.signature })) + .status + ).toBe(200) + }) + it('binds the signature to the op in the path', async () => { const owner = await Owner.create(h) const signedForSync = await envelope( diff --git a/src/buddies/route.ts b/src/buddies/route.ts index 7960afc..8d151b0 100644 --- a/src/buddies/route.ts +++ b/src/buddies/route.ts @@ -382,8 +382,8 @@ const handleBuddiesOp = async ( /** * Upgrades to the owner's live socket. The signed envelope rides in the - * `x-buddies-p` / `x-buddies-s` headers because a WebSocket upgrade has no - * body (and ids never go in URLs). The Worker counts it as a read and checks + * `x-buddies-payload` / `x-buddies-signature` headers because a WebSocket + * upgrade has no body (and ids never go in URLs). The Worker counts it as a read and checks * its shape and `ts` to find the inbox; the inbox DO verifies it and accepts * the socket. */ @@ -428,8 +428,8 @@ const refuseBlobOp = async ( /** * `POST blob/put`: an owner-signed upload of one sealed photo. The envelope - * rides in the `x-buddies-p` / `x-buddies-s` headers (op `blob/put`) and the - * body is the raw sealed bytes. `bytes` is checked before the body is read + * rides in the `x-buddies-payload` / `x-buddies-signature` headers (op + * `blob/put`) and the body is the raw sealed bytes. `bytes` is checked before the body is read * and the read stops past it; the body's SHA-256 must be `blobId`, so the * signature covers the bytes too. The inbox DO verifies the signature, takes * the nonce, applies the caps, and writes the object. @@ -463,7 +463,7 @@ const handleBlobPut = async (c: AppContext): Promise => { } /** - * `POST blob/get`: an unsigned `{ "p": … }` envelope whose payload names the + * `POST blob/get`: an unsigned `{ "payload": … }` envelope whose payload names the * sender's inbox, the blob, and the read token (the capability). Answers the * raw sealed bytes, or one `not_found` for an unknown inbox or blob, an * expired blob, and a wrong token alike. diff --git a/src/e2e/accounts.e2e.test.ts b/src/e2e/accounts.e2e.test.ts index 711cc9a..e7fba9c 100644 --- a/src/e2e/accounts.e2e.test.ts +++ b/src/e2e/accounts.e2e.test.ts @@ -35,8 +35,8 @@ const signedEnvelope = async ( }) ) return { - p: b64u(bytes), - s: b64u(await signer.sign(accountSignedMessage(op, bytes))), + payload: b64u(bytes), + signature: b64u(await signer.sign(accountSignedMessage(op, bytes))), } } @@ -170,14 +170,14 @@ describe('POST /accounts/v1/handoff/*', () => { describe('POST /accounts/v1/backup/*', () => { const put = async (key: SigningKey, slotId: string, bytes: Uint8Array) => { - const { p, s } = await signedEnvelope('backup/put', key, { + const { payload, signature } = await signedEnvelope('backup/put', key, { slotId, size: bytes.byteLength, sha256: await sha256B64u(bytes), }) return httpBytes('/accounts/v1/backup/put', { bytes, - headers: { 'x-ww-p': p, 'x-ww-s': s }, + headers: { 'x-ww-payload': payload, 'x-ww-signature': signature }, }) } @@ -212,14 +212,14 @@ describe('POST /accounts/v1/backup/*', () => { it('refuses a body whose sha256 differs', async () => { const key = await registered() const bytes = randomBytes(1_000) - const { p, s } = await signedEnvelope('backup/put', key, { + const { payload, signature } = await signedEnvelope('backup/put', key, { slotId: randomId(), size: bytes.byteLength, sha256: await sha256B64u(randomBytes(1_000)), }) const res = await httpBytes('/accounts/v1/backup/put', { bytes, - headers: { 'x-ww-p': p, 'x-ww-s': s }, + headers: { 'x-ww-payload': payload, 'x-ww-signature': signature }, }) expect(res.status).toBe(400) expect(res.body.code).toBe('bad_request') diff --git a/src/openapi/route.ts b/src/openapi/route.ts index 1647515..6f21c27 100644 --- a/src/openapi/route.ts +++ b/src/openapi/route.ts @@ -68,6 +68,8 @@ const DOCUMENTATION = { **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\`. +**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.`, license: { name: 'See repository', url: REPO_URL }, }, diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 5713ece..d102bd6 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -139,19 +139,31 @@ export const buddiesId = (description: string) => .regex(/^[A-Za-z0-9_-]{22}$/) .meta({ description: `${description} (22-character base64url id)` }) +/** An envelope's `payload` field. */ +export const envelopePayload = () => + b64u('The request’s fields as UTF-8 JSON, then base64url-encoded') + /** - * The signed envelope Buddies and accounts ops send: `p` is the payload JSON, - * `s` an Ed25519 signature over the op's signing context, the op, and `p`'s - * bytes. + * The signed envelope Buddies and accounts ops send: `payload` is the request + * JSON, `signature` an Ed25519 signature over the op's signing context, the + * op, and `payload`'s decoded bytes. Older app builds send them as `p` and + * `s`, which the server still accepts; they aren't documented as fields. */ export const signedEnvelopeSchema = z .object({ - p: b64u('The payload JSON, UTF-8 encoded'), - s: b64u( - 'Ed25519 signature over `\\n\\n` followed by the decoded `p` bytes' + payload: envelopePayload(), + signature: b64u( + 'Ed25519 signature over `\\n\\n` followed by the decoded `payload` bytes' ), }) .strict() + .meta({ id: 'SignedEnvelope' }) + +/** The envelope unsigned Buddies ops send: `payload` only. */ +export const unsignedEnvelopeSchema = z + .object({ payload: envelopePayload() }) + .strict() + .meta({ id: 'UnsignedEnvelope' }) type JsonSchema = { type?: string | string[] @@ -209,12 +221,12 @@ export const fieldTable = (schema: Schema): string => ].join('\n') /** - * A signed envelope whose decoded `p` must match `payload`. The payload's + * A signed envelope whose decoded `payload` must match `payload`. Its * fields are listed as a table in the body's description, since the * envelope hides them inside base64. */ export const signedBody = (payload: Schema, description: string) => jsonBody( signedEnvelopeSchema, - `${description}\n\n**Decoded \`p\`:**\n\n${fieldTable(payload)}` + `${description}\n\n**Decoded \`payload\`:**\n\n${fieldTable(payload)}` ) diff --git a/src/sentry.test.ts b/src/sentry.test.ts index d9fd1c1..f04f0f3 100644 --- a/src/sentry.test.ts +++ b/src/sentry.test.ts @@ -123,6 +123,8 @@ describe('Sentry scrubbers', () => { it('drops the Buddies live-socket auth headers from events and transactions', () => { const headers = () => ({ + 'x-buddies-payload': 'eyJpbmJveElkIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSJ9', + 'X-Buddies-Signature': 'c2lnbmF0dXJl', 'x-buddies-p': 'eyJpbmJveElkIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSJ9', 'X-Buddies-S': 'c2lnbmF0dXJl', upgrade: 'websocket', @@ -150,6 +152,8 @@ describe('Sentry scrubbers', () => { request: { url: 'https://ww-proxy.test/accounts/v1/backup/put', headers: { + 'x-ww-payload': 'eyJhY2NvdW50UHViIjoiQUFBQSJ9', + 'X-WW-Signature': 'c2lnbmF0dXJl', 'x-ww-p': 'eyJhY2NvdW50UHViIjoiQUFBQSJ9', 'X-WW-S': 'c2lnbmF0dXJl', 'x-ww-dev-bypass': 'kept', diff --git a/src/sentry.ts b/src/sentry.ts index 913a77f..fddadc8 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -48,10 +48,11 @@ function scrubStrings(value: unknown, seen = new WeakSet()): unknown { /** * The Buddies live socket sends its signed envelope (which holds the inbox * id) in `x-buddies-*` request headers, and accounts' `backup/put` sends its - * envelope (account key and slot id) in `x-ww-p`/`x-ww-s`. The SDK copies - * request headers into events, so drop them outright. + * envelope (account key and slot id) in `x-ww-payload`/`x-ww-signature` (or + * the legacy `x-ww-p`/`x-ww-s`). The SDK copies request headers into events, + * so drop them outright. */ -const BUDDIES_HEADER = /^(x-buddies-|x-ww-[ps]$)/i; +const BUDDIES_HEADER = /^(x-buddies-|x-ww-(p|s|payload|signature)$)/i; function dropBuddiesHeaders(event: Event): void { const headers = event.request?.headers; diff --git a/src/test/buddiesClient.ts b/src/test/buddiesClient.ts index 6f8a05c..745b057 100644 --- a/src/test/buddiesClient.ts +++ b/src/test/buddiesClient.ts @@ -66,11 +66,11 @@ export class SigningKey { } export interface Envelope { - p: string - s?: string + payload: string + signature?: string } -/** Builds `{p, s}` over exact payload bytes (optionally pre-serialized). */ +/** Builds `{payload, signature}` over exact payload bytes (optionally pre-serialized). */ export const envelope = async ( op: BuddiesSigningOp, payload: Record | string, @@ -79,39 +79,43 @@ export const envelope = async ( const bytes = encoder.encode( typeof payload === 'string' ? payload : JSON.stringify(payload) ) - return { p: b64u(bytes), s: b64u(await key.sign(signedMessage(op, bytes))) } + return { + payload: b64u(bytes), + signature: b64u(await key.sign(signedMessage(op, bytes))), + } } /** - * Headers for the `inbox/live` upgrade: the signed envelope's `p` and `s`. + * Headers for the `inbox/live` upgrade: the signed envelope's `payload` and + * `signature`. * `payload` must hold `inboxId`, `ts`, and `nonce`. */ export const liveHeaders = async ( payload: Record | string, key: SigningKey ): Promise> => { - const { p, s } = await envelope(BUDDIES_LIVE_OP, payload, key) + const signed = await envelope(BUDDIES_LIVE_OP, payload, key) return { upgrade: 'websocket', - [BUDDIES_LIVE_HEADERS.payload]: p, - [BUDDIES_LIVE_HEADERS.signature]: s ?? '', + [BUDDIES_LIVE_HEADERS.payload]: signed.payload, + [BUDDIES_LIVE_HEADERS.signature]: signed.signature ?? '', } } /** - * Headers for `blob/put`: the signed envelope (op `blob/put`) as `x-buddies-p` - * and `x-buddies-s`. `payload` must hold `inboxId`, `blobId`, `bytes`, + * Headers for `blob/put`: the signed envelope (op `blob/put`) as + * `x-buddies-payload` and `x-buddies-signature`. `payload` must hold `inboxId`, `blobId`, `bytes`, * `expiresAt`, `readTokenHash`, `ts`, and `nonce`. */ export const blobPutHeaders = async ( payload: Record | string, key: SigningKey ): Promise> => { - const { p, s } = await envelope(BUDDIES_BLOB_PUT_OP, payload, key) + const signed = await envelope(BUDDIES_BLOB_PUT_OP, payload, key) return { 'content-type': 'application/octet-stream', - [BUDDIES_ENVELOPE_HEADERS.payload]: p, - [BUDDIES_ENVELOPE_HEADERS.signature]: s ?? '', + [BUDDIES_ENVELOPE_HEADERS.payload]: signed.payload, + [BUDDIES_ENVELOPE_HEADERS.signature]: signed.signature ?? '', } } @@ -145,7 +149,7 @@ export const photoBlob = async (size = 2_048): Promise => { export const unsignedEnvelope = ( payload: Record ): Envelope => ({ - p: b64u(encoder.encode(JSON.stringify(payload))), + payload: b64u(encoder.encode(JSON.stringify(payload))), }) /** Random lowercase hex, `size` bytes (an APNs device token). */ diff --git a/src/test/e2e.ts b/src/test/e2e.ts index b82e3d3..f2c8cba 100644 --- a/src/test/e2e.ts +++ b/src/test/e2e.ts @@ -250,7 +250,7 @@ export const sendSigned = async ( export const sendUnsigned = (op: string, payload: Record) => buddies(op, unsignedEnvelope(payload)) -/** `blob/get`: an unsigned `{p}` envelope; raw bytes back on 200. */ +/** `blob/get`: an unsigned `{payload}` envelope; raw bytes back on 200. */ export const getBlob = (payload: Record) => httpBytes('/buddies/v1/blob/get', { json: unsignedEnvelope(payload) })