From 4771ea0d2794e8c158f988fcddc13aef65cc9cd5 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Thu, 15 Oct 2026 23:32:09 -0400 Subject: [PATCH 1/2] feat: use readable payload and signature names in signed envelopes - Buddies and accounts signed envelopes now use `payload` and `signature` instead of `p` and `s` - Headers renamed to `x-buddies-payload`/`x-buddies-signature` and `x-ww-payload`/`x-ww-signature` - Legacy `p`/`s` fields and `x-buddies-p`/`-s`, `x-ww-p`/`-s` headers still accepted for shipped builds; the long name wins if both are sent - OpenAPI docs, protocol doc, README, AGENTS.md, and verify scripts updated to the new names - AGENTS.md and README gain API naming guidance: spell out names, never add new short names --- .agents/skills/verify-ww-api/SKILL.md | 2 +- .../verify-ww-api/features/buddies-relay.md | 6 +- AGENTS.md | 54 ++++++++++++--- README.md | 48 +++++++++++++ docs/buddies-protocol.md | 25 +++---- scripts/verify/buddies-wire.mjs | 8 +-- scripts/verify/buddies-wire.test.ts | 4 +- scripts/verify/dev.mjs | 4 +- scripts/verify/fuzz-buddies.mjs | 42 ++++++------ src/accounts/accounts.test.ts | 4 +- src/accounts/backup.test.ts | 35 ++++++++-- src/accounts/backup.ts | 2 +- src/accounts/contracts.ts | 11 ++- src/accounts/handoff.test.ts | 4 +- src/accounts/handoff.ts | 10 ++- src/accounts/openapi.ts | 14 ++-- src/accounts/signed.ts | 12 ++-- src/buddies/blobs.test.ts | 14 ++-- src/buddies/contracts.ts | 13 ++-- src/buddies/envelope.test.ts | 30 ++++++--- src/buddies/envelope.ts | 67 ++++++++++++++----- src/buddies/live.test.ts | 26 +++++-- src/buddies/openapi.ts | 31 ++++----- src/buddies/relay.test.ts | 30 +++++++-- src/buddies/route.ts | 10 +-- src/e2e/accounts.e2e.test.ts | 12 ++-- src/openapi/route.ts | 2 + src/openapi/schemas.ts | 28 +++++--- src/sentry.test.ts | 4 ++ src/sentry.ts | 7 +- src/test/buddiesClient.ts | 32 +++++---- src/test/e2e.ts | 2 +- 32 files changed, 404 insertions(+), 189 deletions(-) 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..4d070d7 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,8 +584,43 @@ 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. + +## 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. - The document is built once per isolate. Scalar's UI loads from jsDelivr. ## Checks before deploy 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) }) From 5ddcf5d5f7b3f919f3390cc33afe6bc5c53df2d4 Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Fri, 16 Oct 2026 00:02:30 -0400 Subject: [PATCH 2/2] docs: keep the OpenAPI isolate note under API docs --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4d070d7..cabcae7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -589,6 +589,7 @@ need the token). - 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 @@ -621,7 +622,6 @@ When adding or changing an endpoint: 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. -- The document is built once per isolate. Scalar's UI loads from jsDelivr. ## Checks before deploy