From 7c9639113259d57d1a78c632afbb16d529e052e9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 1 Oct 2026 01:10:06 -0700 Subject: [PATCH 1/4] Deliver sealed push through the Hosted Relay The self-host push routes on relay.dormouse.sh over Postgres, with a WebCrypto Web Push sender (RFC 8291 aes128gcm, pinned to the Appendix A vector; RFC 8292 VAPID ES256), endpoints limited to the known push services, account-scoped rotation and caps, and per-preview VAPID keys. Push is HTTPS from the Burrow, independent of terminal transport. Co-Authored-By: Claude Opus 5.5 --- .github/audit/hosted.md | 25 +- docs/specs/hosted.md | 28 +- docs/specs/hosted.rationale.md | 7 + docs/specs/relay.md | 15 +- docs/specs/remote-network.md | 3 +- docs/specs/security-hosted.md | 10 +- docs/specs/security-remote.md | 2 + hosted/README.md | 29 +- hosted/package.json | 2 +- hosted/scripts/preview.mjs | 47 +- hosted/scripts/preview.test.mjs | 32 +- hosted/scripts/production.test.mjs | 40 +- hosted/scripts/workers.mjs | 4 +- hosted/server/bindings.ts | 11 +- .../dormouse-migrations/003_relay_push.sql | 23 + hosted/server/relay-api.ts | 38 +- hosted/server/relay-auth.ts | 16 + hosted/server/relay-push.ts | 489 +++++++++++++++ hosted/server/tests/boundary.test.ts | 43 +- hosted/server/tests/bundle.ts | 11 + hosted/server/tests/relay-push.test.ts | 174 ++++++ hosted/server/tests/relay.test.ts | 559 +++++++++++++++++- relay/src/app.ts | 90 +-- relay/src/push-endpoint.ts | 13 +- relay/src/push.ts | 70 +-- relay/src/state.ts | 28 +- remote-lib-common/src/index.ts | 1 + remote-lib-common/src/remote/relay-common.ts | 107 ++++ remote-lib-common/src/remote/web-push.ts | 413 +++++++++++++ remote-lib-common/test/vectors/README.md | 14 +- .../test/vectors/rfc8291-appendix-a.json | 16 + remote-lib-common/test/web-push.test.mjs | 234 ++++++++ scripts/spec-word-budgets.json | 4 +- 33 files changed, 2330 insertions(+), 268 deletions(-) create mode 100644 hosted/server/dormouse-migrations/003_relay_push.sql create mode 100644 hosted/server/relay-push.ts create mode 100644 hosted/server/tests/relay-push.test.ts create mode 100644 remote-lib-common/src/remote/web-push.ts create mode 100644 remote-lib-common/test/vectors/rfc8291-appendix-a.json create mode 100644 remote-lib-common/test/web-push.test.mjs diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 8bb58fdf9..0732db336 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -17,12 +17,15 @@ unreachable, report those two checks as `UNVERIFIABLE`. Read `docs/specs/hosted.md`, `docs/specs/one-time.md` (its "Wire contract", "Hosted rendezvous", and "Phone page"), `docs/specs/relay.md` (its "HTTP API", -"Setup tokens and the pairing QR", "WebAuthn without a WebAuthn library", and -"Routing", whose semantics the Hosted Relay keeps), `hosted/server/`, `hosted/src/`, +"Setup tokens and the pairing QR", "WebAuthn without a WebAuthn library", +"Routing", "Web Push", and "State files", whose semantics the Hosted Relay +keeps), `hosted/server/`, `hosted/src/`, `hosted/scripts/`, `hosted/wrangler.jsonc`, `hosted/wrangler.relay.jsonc`, `hosted/wrangler.voice.jsonc`, `remote-lib-common/src/remote/one-time-wire.ts`, -`remote-lib-common/src/remote/relay-common.ts`, the Pocket build the relay +`remote-lib-common/src/remote/relay-common.ts`, +`remote-lib-common/src/remote/web-push.ts` and its test +`remote-lib-common/test/web-push.test.mjs`, the Pocket build the relay serves (`lib/vite.pocket.config.ts`, `lib/pocket/`), the phone page it serves — `lib/vite.one-time.config.ts`, `lib/one-time/`, `lib/src/remote/one-time-app/`, and `lib/scripts/assert-pocket-worker.mjs` — and @@ -98,7 +101,8 @@ Be adversarial, and go past the `FAIL IF` list. Ask specifically: `hosted/server/bindings.ts` and each Wrangler config: the relay and voice Workers must hold and pass no auth secret and never import Better Auth, and the account and relay no ElevenLabs key, and the account and voice no - `RELAY_ENROLL_SECRET`. The relay's Hyperdrive reaches only + `RELAY_ENROLL_SECRET` or VAPID private key, which must be a relay Worker + secret and never a `vars` entry. The relay's Hyperdrive reaches only its own tables and the entitlement's user row. - **Can one account reach another's Relay rows?** Trace every query in `hosted/server/relay-api.ts`: a session or Burrow token of account B must not @@ -122,6 +126,19 @@ Be adversarial, and go past the `FAIL IF` list. Ask specifically: approver; and a removed Burrow's row must be gone, its token opening nothing on any relay route. Look for a user code predictable without the secret, an unlimited approval loop, and a table an unauthenticated caller can grow. +- **Can push leak text, cross an account, or reach somewhere it should not?** + Trace a send through `relayPushRoutes` in `hosted/server/relay-push.ts`: the + Relay must forward exactly the sealed envelope's three fields plus the + token's `burrowId`, read and log no notification text, and reach only the + calling Burrow's rows. A session must not register against, read back, or + delete another account's rows, even holding its `deliveryId`, and an + upsert's endpoint rotation, its caps, and the 404/410 prune must stay inside + the account. Every fetch must go to an endpoint `knownPushEndpoint` admits, + follow no redirect, and read only a bounded reason. Check the sender against + RFC 8291 and RFC 8292 yourself: the test's expected bytes must come from the + RFC, not the code, and a JWT's `aud` must be the endpoint's origin. Look for + an endpoint string that parses to an allowlisted host in one place and + another host in another. - **Can a Hosted login become terminal access, or an account become someone else's?** `authPolicy` must keep explicit linking and independent logins; a callback whose initiating login was revoked must fail; an unused or unknown diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index e90f758c8..0cf086491 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -10,7 +10,7 @@ | Worker | Origin | Serves | Holds | |---|---|---|---| | `dormouse-hosted` | `https://hosted.dormouse.sh` | the account frontend, `/api/auth/*`, `/api/providers`, `/api/ready`, voice tokens, the Relay's account routes ("Burrow enrollment") | the login cookie, auth secrets, Hyperdrive, the approval rate limit, a binding to the relay's `RelayRoom` | -| `dormouse-relay` | `https://relay.dormouse.sh` | the Hosted Relay, its sockets, and Pocket ("Relay", "Relay sockets"), the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `OneTimeRoom`, `RelayRoom`, the one-time, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `RELAY_ENROLL_SECRET` | +| `dormouse-relay` | `https://relay.dormouse.sh` | the Hosted Relay, its sockets, and Pocket ("Relay", "Relay sockets"), the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `OneTimeRoom`, `RelayRoom`, the one-time, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `RELAY_ENROLL_SECRET`, the VAPID pair | | `dormouse-voice` | `https://voice.dormouse.sh` | speak and the history sweep ("Managed voice") | `ELEVENLABS_API_KEY`, Hyperdrive | Every Worker answers `/api/health`, 404s anything else under its non-page prefixes (`/api`, and the relay's `/ws` too), `/dev/*`, or `/__test/*`, and answers a thrown request 503 under `secureHeaders`. The account falls back to its SPA assets, the relay to Pocket's. The 421 origin gate, each Worker's bindings mapper, and the cookie routes' exact-`Origin` check are `docs/specs/security-hosted.md` -> "Origin boundary" (rationale). @@ -103,7 +103,11 @@ The relay Worker serves the self-host Relay's HTTP API to many accounts: the pat | `GET /api/burrows` | The session's account's Burrows, each `online` while it holds a live socket in the account's `RelayRoom` ("Relay sockets") | | `POST /api/burrow/setup-token` | 401 for a removed Burrow, 403 `NOT_ENTITLED_ERROR` for an owner not entitled | | `POST /api/burrow/enroll` | Always 401 `UNAUTHORIZED_ERROR`: Hosted has no setup password; a Burrow enrolls by device code ("Burrow enrollment") | -| `GET /api/push/config` | `{ applicationServerKey: null }`: push is off; every other push route and `GET /api/hello` (the self-host installers' probe) are 404 | +| `GET /api/push/config` | The VAPID public key, or `null` when push is off ("Push") | +| `POST /api/push/subscribe` | 404 for a Burrow not the session's account's; 400 `endpoint must be a known push service` off the allowlist ("Push") | +| `POST /api/push/subscriptions/query`, `DELETE /api/push/subscriptions/:deliveryId` | Only rows of the session's account's Burrows | +| `POST /api/push/send` | A recipient repeating an earlier one's `deliveryId` is not sent again and counts as `unknown` (rationale) | +| `GET /api/hello` | 404: the self-host installers' probe | | `GET /*` | Pocket (below) | A session-gated route answers a session of an account no longer entitled with the expired session's 401 `UNAUTHORIZED_ERROR`, so Pocket returns to sign-in. @@ -115,9 +119,17 @@ A session-gated route answers a session of an account no longer entitled with th - **Must answer 429 with `Retry-After` past the per-address limit on `signin/*` (`RELAY_SIGNIN_LIMIT`) and `setup/begin`/`finish` (`RELAY_SETUP_LIMIT`)**, before the body limit and any database read: 30 a minute, a ceremony's two routes sharing one budget (rationale). - **Must restore a token a refused `finish` spent on its original expiry, within the Burrow's cap, and never once that expiry has passed.** +**Push.** The push routes keep `docs/specs/relay.md` -> "Web Push" and its "State files" upsert rules; a send is HTTPS from the Burrow to the relay Worker, independent of terminal transport. + +- **Must keep subscriptions in Postgres** (`hosted/server/dormouse-migrations/003_relay_push.sql`), keyed `(burrowId, deliveryId)`, every field bounded as self-host bounds it, deleted with their Burrow. **Must read the addresses a delivery moves off, drop rows, and prune 404/410 among the account's rows only.** +- **Must cap subscriptions at `MAX_PUSH_SUBSCRIPTIONS_PER_BURROW` and `MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT`** in place of self-host's file total, evicting the oldest `subscribedAt`, never the row just written, in the upsert's transaction under the account's advisory lock (rationale). +- **Must register and fetch only a known Web Push service's endpoint** (`knownPushEndpoint`) in place of the self-host DNS guard: `https:` on the default port, no credentials, at most `MAX_PUSH_ENDPOINT_LENGTH`, and host `fcm.googleapis.com` or `updates.push.services.mozilla.com`, or one under `.push.apple.com` or `.notify.windows.com` (rationale). **Never follow a redirect**: a 3xx is `failed`. A refusal's log reads at most 1 KiB of its body. +- **Must send through `webPushRequest`** (`remote-lib-common/src/remote/web-push.ts`), WebCrypto with no `web-push`: one RFC 8291 `aes128gcm` record and an RFC 8292 `ES256` VAPID JWT, `aud` the endpoint's origin, `exp` `VAPID_JWT_LIFETIME_S` (12 hours) ahead, `sub` the relay's `APP_ORIGIN`. The route bounds each delivery by `PUSH_SEND_DEADLINE_MS`, aborting its fetch. +- **Push is disabled, not half-working**: without both `RELAY_VAPID_PUBLIC_KEY` and `RELAY_VAPID_PRIVATE_KEY`, with a private key that does not sign for its public point, or without a subject (`defaultVapidSubject`: an https, non-loopback `APP_ORIGIN`), the config route answers `null` and subscribe and send 503. The pair is a relay Worker secret; previews derive theirs ("PR previews"), and the dev loop has none. + **Pocket at the root.** `build` stages `lib/dist-pocket` at the root of the relay's assets and the one-time page beside it, checking both shells. Pocket is served per `docs/specs/pocket-app.md` -> "Serving the built bundle", except that a path naming no file (no extension, outside `/diagnostics`) gets the shell in one asset fetch. -Source of truth: `relayApiRoutes` / `sweepExpired` in `hosted/server/relay-api.ts`; `sessionByToken` / `burrowByToken` / `requireSession` / `requireBurrow` in `hosted/server/relay-auth.ts`; `hosted/server/dormouse-migrations/002_relay.sql`; `triggers` and `ratelimits` in `hosted/wrangler.relay.jsonc`; `pocketRoutes` in `hosted/server/pocket.ts`; `relayRules` / `relayPathKind` in `hosted/server/headers.ts`; `stageRelay` in `hosted/scripts/stage-relay.mjs`; `checkRegistration` / `verifySigninAssertion` in `remote-lib-common/src/remote/relay-common.ts`. Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, `hosted/scripts/stage-relay.test.mjs`, and `remote-lib-common/test/relay-common.test.mjs`. +Source of truth: `relayApiRoutes` / `sweepExpired` in `hosted/server/relay-api.ts`; `sessionByToken` / `burrowByToken` / `requireSession` / `requireBurrow` / `locked` in `hosted/server/relay-auth.ts`; `hosted/server/dormouse-migrations/002_relay.sql`; `relayPushRoutes` / `upsertSubscription` / `knownPushEndpoint` / `deliverPush` / `pushConfigOf` in `hosted/server/relay-push.ts`; `hosted/server/dormouse-migrations/003_relay_push.sql`; `vapidSigner` / `encryptWebPush` in `remote-lib-common/src/remote/web-push.ts`; `triggers` and `ratelimits` in `hosted/wrangler.relay.jsonc`; `pocketRoutes` in `hosted/server/pocket.ts`; `relayRules` / `relayPathKind` in `hosted/server/headers.ts`; `stageRelay` in `hosted/scripts/stage-relay.mjs`; `checkRegistration` / `verifySigninAssertion` and the push bounds in `remote-lib-common/src/remote/relay-common.ts`. Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, `hosted/scripts/stage-relay.test.mjs`, `remote-lib-common/test/relay-common.test.mjs`, and `remote-lib-common/test/web-push.test.mjs` (the RFC 8291 Appendix A vector). ## Relay sockets @@ -176,7 +188,7 @@ Source of truth: `relayApiRoutes` in `hosted/server/relay-api.ts`; `relayAccount **Must run local development with `dor tool hosted` inside Dormouse.** A single `http://localhost:` origin, bound to loopback on an OS-assigned port unless `PORT` pins one, serves Vite and Node auth, with a disposable development database, and the voice token and Relay account routes but never speak, which every Hosted build reaches only at `https://voice.dormouse.sh`. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. `dor tool one-time` runs the relay Worker on loopback without a database, so its Relay routes answer 503 (`docs/specs/one-time.md` -> "Dev loop"). -**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Root `pnpm test` runs the `hosted/scripts/*.test.mjs` deploy suites and `test:miniflare`, the Docker-free Miniflare suites (the rendezvous, Pocket's serving, and the three Workers' boundary); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts`, `relay.test.ts`, and `relay-room.test.ts` needing Docker. The test entry alone injects the packed Better Auth deterministic module. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. +**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Root `pnpm test` runs the `hosted/scripts/*.test.mjs` deploy suites and `test:miniflare`, the Docker-free suites (the rendezvous, Pocket's serving, the three Workers' boundary, and push egress); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts`, `relay.test.ts`, and `relay-room.test.ts` needing Docker. The test entry alone injects the packed Better Auth deterministic module. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. **Must keep production, test, and preview databases and credentials separate.** The development and preview entries are email-only. Production configuration and operator steps live in `hosted/README.md`. @@ -186,17 +198,17 @@ Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `host **Must deploy only verified same-repository PR merge revisions touching Hosted or its shared build inputs.** Drafts qualify; forks receive no deployment credentials. Changed paths include rename sources and all API pages. Deployment runs serialize per PR without cancellation; close/merge cleanup ignores path filtering and tolerates absent resources. -**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive all three share, and a Neon branch from an empty dedicated preview project**, all reused until close. Preview configs exclude production routes, triggers, and credentials; runtime bindings cannot enable OAuth, Postmark, or ElevenLabs. **Must give each relay preview its own Durable Object namespaces, the account preview's `RELAY_ROOM` binding its relay preview's, each preview preview-only rate-limit namespaces, and the relay preview an `ACCOUNT_ORIGIN` naming its account preview**, and delete every preview Worker with `force`. A preview's one secret (the account's `AUTH_SECRET`, the relay's `RELAY_ENROLL_SECRET`) is the HMAC of `PREVIEW_AUTH_SECRET` with its Worker's name (`previewSecrets`). The smoke checks what production's does ("Production releases"), with the captured-mail login in place of providers, retrying each part on its own. +**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive all three share, and a Neon branch from an empty dedicated preview project**, all reused until close. Preview configs exclude production routes, triggers, and credentials; runtime bindings cannot enable OAuth, Postmark, or ElevenLabs. **Must give each relay preview its own Durable Object namespaces, the account preview's `RELAY_ROOM` binding its relay preview's, each preview preview-only rate-limit namespaces, and the relay preview an `ACCOUNT_ORIGIN` naming its account preview**, and delete every preview Worker with `force`. A preview's secrets derive from `PREVIEW_AUTH_SECRET` and its Worker's name (`previewSecrets`): the account's `AUTH_SECRET` and the relay's `RELAY_ENROLL_SECRET` are their HMAC, and the relay's VAPID scalar the HMAC of `/vapid` (`previewVapidKeys`), so its subscriptions survive the PR's redeploys. The smoke checks what production's does ("Production releases"), with the captured-mail login in place of providers, retrying each part on its own. **Must run cleanup from the base branch's checkout, never the closed PR's.** **Must capture preview mail in Postgres and expose escaped text only.** The public inbox shows the newest 100 messages from the last 24 hours, prunes expired rows on capture, and accepts only the preview's configured origin. No test clock is deployed. Preview data is disposable; it is not access-controlled. -Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workflows/hosted-preview.yml`; `previewConfig` / `previewSecrets` / `prepare` / `cleanup` in `hosted/scripts/preview.mjs`; `smokeAll` in `hosted/scripts/preview-smoke.mjs`; `postgresInbox` in `hosted/server/preview-inbox.ts`; `hosted/server/preview-worker.ts`; `hosted/server/voice-preview-worker.ts`. Pinned by `hosted/scripts/preview.test.mjs`, `hosted/scripts/changed.test.mjs`, and `hosted/server/tests/workers.test.ts`. +Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workflows/hosted-preview.yml`; `previewConfig` / `previewSecrets` / `previewVapidKeys` / `prepare` / `cleanup` in `hosted/scripts/preview.mjs`; `smokeAll` in `hosted/scripts/preview-smoke.mjs`; `postgresInbox` in `hosted/server/preview-inbox.ts`; `hosted/server/preview-worker.ts`; `hosted/server/voice-preview-worker.ts`. Pinned by `hosted/scripts/preview.test.mjs`, `hosted/scripts/changed.test.mjs`, and `hosted/server/tests/workers.test.ts`. ## Production releases -**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's pinned name and origin and lone custom domain, and the relay's `ACCOUNT_ORIGIN` to the account's origin; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`, the relay's `RELAY_ENROLL_SECRET`). Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy relay, voice, then account, stopping at a failure; the relay must pass its revision check and `oneTimeSmoke` before the next deploy (rationale). Production has no public candidate URL. +**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's pinned name and origin and lone custom domain, and the relay's `ACCOUNT_ORIGIN` to the account's origin; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`, the relay's `RELAY_ENROLL_SECRET` and its VAPID pair). Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy relay, voice, then account, stopping at a failure; the relay must pass its revision check and `oneTimeSmoke` before the next deploy (rationale). Production has no public candidate URL. **Must only append Durable Object migrations**: a deployed tag is never edited or removed, and Cloudflare refuses a rollback across one, so each is a rollback floor. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay has its own `v1` (`OneTimeRoom`) and `v2` (`RelayRoom`). A deploy restarts every room, dropping links still waiting or mid-handshake, and every relay socket, which its end reconnects; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and runs `oneTimeSmoke` on the relay once the relay's revision check passes, whatever the account's outcome; a failed smoke reports every failed part. @@ -211,4 +223,4 @@ Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / 1. Deploy the configured providers and pass real production acceptance. pgstencil includes the Microsoft fix; personal and work/school callbacks need acceptance. 2. Add per-browser login listing/revocation, sign-out-everywhere, and account recovery before broad paid use. Revisit the fixed 24-hour login lifetime for daily voice use. 3. Managed voice beyond the admin slice: a real entitlement or licence replacing `ADMIN_EMAIL`, credentials scoped for non-admin accounts, per-account quotas, usage accounting, and spending bounds beyond the fixed daily cap, and explicit text/redaction disclosure. -4. Hosted Relay beyond "Relay", "Relay sockets", and "Burrow enrollment": desktop enrollment and push — **saas-multitenant** in `docs/specs/relay.md` and **remote-network** in `docs/specs/remote-network.md`. Account login never replaces Burrow pairing and authorization. Paid security claims require independent review. +4. Hosted Relay beyond "Relay", "Relay sockets", and "Burrow enrollment": desktop enrollment — **saas-multitenant** in `docs/specs/relay.md` and **remote-network** in `docs/specs/remote-network.md`. Account login never replaces Burrow pairing and authorization. Paid security claims require independent review. diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 3ba087188..31a9a7ef3 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -40,6 +40,13 @@ Sweep interval (2026-09-30): hourly, not the voice sweep's five minutes. Each pa Rate limits (2026-09-30): `signin/*` and `setup/begin`/`finish` are the unauthenticated routes that reach Postgres. A ceremony's two routes share one budget, so 30 a minute per address is 15 ceremonies, far above one person's retries and enough that a burst costs Postgres little. Like the one-time limits, they are keyed per address (an IPv6 /64), so they bound one caller, not a botnet. +Push (2026-10-01): + +- Per-account cap (`MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT`, 256) in place of self-host's total: a self-host Relay is one account, so its total already was a per-account bound, and the same 256 keeps the two Relays' ceilings equal. A global cap across accounts would let one account's subscribe loop evict every other account's phones; keyed by the account, a caller only ever evicts its own. 256 is eight laptops' worth at the per-Burrow cap, far above the phones a person pairs, and the per-Burrow cap still stops one Burrow from holding them all. +- Endpoint allowlist instead of the DNS guard: a Worker's `fetch` resolves and connects inside Cloudflare's network, so the Relay can neither see nor pin the address a hostname resolves to, which is the whole of the self-host guard. Cloudflare's egress cannot reach a customer tailnet either way, so the risk left is the Worker as a blind POST relay at an arbitrary public host, carrying a VAPID JWT for that host. Every browser Pocket runs in subscribes at one of four services: Chrome and Android at FCM (`fcm.googleapis.com`), Firefox at autopush (`updates.push.services.mozilla.com`), Safari at APNs, which Apple documents as `https://*.push.apple.com`, and Edge on Windows at WNS (`*.notify.windows.com`). A browser that adds a service needs a line here before it can register, which is the intended failure. A redirect is failed rather than followed, so a push service's answer cannot steer the request off the allowlist. +- A repeated recipient is sent once: Workers Free allows 50 subrequests per invocation, and with each `deliveryId` sent at most once a send makes at most `MAX_PUSH_SUBSCRIPTIONS_PER_BURROW` (32) fetches, whatever `recipients` holds, plus its database connection. The Burrow names each ACL record once, so only a malformed send repeats one. +- Preview VAPID pairs derive from the preview secret and the Worker's name, as its other secrets do, so a PR's subscriptions survive redeploys and no production key reaches a preview. + ## Relay sockets A `RelayRoom` that opened a Postgres connection through Hyperdrive could not be evicted afterwards: `unsafeEvictDurableObject` timed out on "it still has active references" even after the client had ended and its socket was closed, while an object that opened and closed a bare socket to the same host evicted normally (measured in Miniflare 5.20260908, 2026-10). Reading the rows in a Worker invocation of their own, through `ctx.exports`, leaves the object hibernatable. diff --git a/docs/specs/relay.md b/docs/specs/relay.md index ed093b207..e18a09621 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -282,7 +282,8 @@ stale row rather than leave one per rotation: wrote**. Eviction covers every Burrow, so a hand-edited file over the cap converges on the next write. -Source of truth: `relay/src/state.ts`. +Source of truth: `relay/src/state.ts`; the caps and field bounds in +`remote-lib-common/src/remote/relay-common.ts`. ## WebAuthn without a WebAuthn library @@ -369,8 +370,9 @@ the body, never on the caller**: a correct credential inside an over-long body i still 413. **One route is exempt**, its legitimate body being larger: `/api/push/send`, whose `MAX_PUSH_SEND_BODY_BYTES` is *derived* from `MAX_PUSH_QUERY_DELIVERY_IDS` and `MAX_SEALED_PUSH_LENGTH` so it cannot drift -from what a maximal fan-out costs. Source of truth: `relay/src/app.ts`, pinned -by `relay/test/body-limit.test.mjs`. +from what a maximal fan-out costs. Source of truth: `relay/src/app.ts` and +`MAX_PUSH_SEND_BODY_BYTES` in `remote-lib-common/src/remote/relay-common.ts`, +pinned by `relay/test/body-limit.test.mjs`. **Must admit Burrow enrollment through one process-global bucket before body parsing**, at `BURROW_ENROLL_ATTEMPT_BURST` and `BURROW_ENROLL_ATTEMPT_REFILL_MS`; @@ -385,7 +387,7 @@ Burrow, or session bearer requests would give public traffic a resource sink for tokens nobody can guess (rationale); the delayed route is the one the bucket already bounds. Burrow tokens still use a constant-time full-row scan. **Must reject a `burrowToken` outside its minted 32-byte base64url shape before -reading `burrows.json`**, as `isDeliveryId` guards push routes. **That read is +reading `burrows.json`**, as `isPushDeliveryId` guards push routes. **That read is cached against the file's stat**, so a well-shaped guess buys no `readFile` or `JSON.parse`; a hand edit still revokes, the stat being the gate rather than a TTL. Source of truth: `readCached` in `relay/src/state.ts`. @@ -544,7 +546,8 @@ Relay's Web Push dependency. Burrow and webview halves: Source of truth: `relay/src/push-endpoint.ts`, wired into registration by the push routes in `relay/src/app.ts` and into delivery by `relay/src/push.ts`, -which also holds `defaultVapidSubject` / `assertVapidSubject`. +which also holds `assertVapidSubject`; `defaultVapidSubject` in +`remote-lib-common/src/remote/web-push.ts`. ## Routing @@ -1151,7 +1154,7 @@ Unstaged but adjacent: origin migration (re-binding the passkey and enrollments after a Tailscale node rename), and the revocation UI staged in [remote-security-model.md](./remote-security-model.md) `## Future`. -**Scope: saas-multitenant** — the managed Relay on `relay.dormouse.sh` beyond the account-scoped routes, Pocket, the device-code enrollment, and the per-account relay sockets [hosted.md](./hosted.md) → "Relay", "Relay sockets", and "Burrow enrollment" serve: push. The **remote-network** scope in [remote-network.md](./remote-network.md) owns the deployment, transport, and network restriction design. +**Scope: saas-multitenant** — the managed Relay on `relay.dormouse.sh` beyond the account-scoped routes, push, Pocket, the device-code enrollment, and the per-account relay sockets [hosted.md](./hosted.md) → "Relay", "Relay sockets", and "Burrow enrollment" serve: the Hosted transport below. The **remote-network** scope in [remote-network.md](./remote-network.md) owns the deployment, transport, and network restriction design. ### From single-owner to multi-tenant diff --git a/docs/specs/remote-network.md b/docs/specs/remote-network.md index 6d5082b99..63f9caac2 100644 --- a/docs/specs/remote-network.md +++ b/docs/specs/remote-network.md @@ -101,7 +101,7 @@ Source of truth: `NetworkSettings`, `NetworkPhones`, `NetworkUpdates`, `connecti **Scope: remote-network** — build in order: 1. **Anywhere on a phone**: **Must measure iOS Safari's offer size and gathering time, and the Burrow with STUN blocked**, before changing a budget. -2. **Hosted persistent**: desktop enrollment, the path rule for paired phones, and push, beyond the routes and sockets in `docs/specs/hosted.md` -> "Relay", "Relay sockets", and "Burrow enrollment", with **saas-multitenant** in `docs/specs/relay.md`, its connections in `connectionsFor`; Local networks and Anywhere then cover paired phones. +2. **Hosted persistent**: desktop enrollment and the path rule for paired phones, beyond the routes, push, and sockets in `docs/specs/hosted.md` -> "Relay", "Relay sockets", and "Burrow enrollment", with **saas-multitenant** in `docs/specs/relay.md`, its connections in `connectionsFor`, the push row among them with desktop enrollment; Local networks and Anywhere then cover paired phones. ### Allowed networks @@ -112,6 +112,5 @@ Source of truth: `NetworkSettings`, `NetworkPhones`, `NetworkUpdates`, `connecti - **Under Local networks a paired phone's session is direct-only**, with the one-time rule: an application message off the Relay ends it unread. **May fall back to Hosted relaying under Anywhere.** - **Must choose Pocket's direct-peer factory by deployment** ("Anywhere"), one bundle serving both; `lib/src/remote/pocket-app/App.tsx` hard-codes `selfHostDirectPeer`. - **Must start `BurrowRuntime` on the level's `directPeeringFor`, restarting it on any change `samePaths` sees** ("Anywhere"). -- **Must accept sealed push independently of terminal transport**, under `docs/specs/remote-security-model.md` -> "Push sealing". - **Must enroll a Hosted build's Burrow by device code from the service** (`docs/specs/hosted.md` -> "Burrow enrollment"): begin and poll every `interval`, validate the begin answer, show the user code, and stop on `NOT_ENTITLED_ERROR`. The Burrow composes the verification URL itself from `ENROLL_PAGE_PATH` and the user code, never trusting the Relay's `verificationUrl`, and opens it only on the user's click and only at `https://hosted.dormouse.sh` in a release Hosted build; a dev Hosted build may follow the `verificationUrl` origin, as `DORMOUSE_RELAY_IS_HOSTED` relaxes the relay origin. `isEnrollment` in `lib/src/remote/burrow/enrollment.ts` stays the one guard of the enrollment shape. - **Never enroll Hosted into a customer's tailnet** or mint per-customer hostnames. diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index cf2fd99d0..02c309b0b 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -8,7 +8,7 @@ - **FAIL IF** a Worker routes a request whose URL origin is not its own `APP_ORIGIN`, a sibling's included, rather than answering 421; inspect `workerApp` in `hosted/server/worker-app.ts`. - **FAIL IF** a cookie route admits any `Origin` but its own exactly, sibling origins under `dormouse.sh` included — they are same-site, so the browser sends them the `SameSite=Lax` login cookie — or a state-changing auth request skips the CSRF check, or any Worker grants credentialed CORS; inspect `cookieAdmin` in `hosted/server/account-gate.ts` and the packed adapter. -- **FAIL IF** the relay or voice Worker's bindings mapper passes an auth secret (`AUTH_SECRET`, a provider credential, or `POSTMARK_SERVER_TOKEN`), the account's or relay's passes `ELEVENLABS_API_KEY`, the account's or voice's passes `RELAY_ENROLL_SECRET`, or the relay or voice entry imports Better Auth; inspect `hosted/server/bindings.ts` and each entry's import graph. +- **FAIL IF** the relay or voice Worker's bindings mapper passes an auth secret (`AUTH_SECRET`, a provider credential, or `POSTMARK_SERVER_TOKEN`), the account's or relay's passes `ELEVENLABS_API_KEY`, the account's or voice's passes `RELAY_ENROLL_SECRET` or `RELAY_VAPID_PRIVATE_KEY`, or the relay or voice entry imports Better Auth; inspect `hosted/server/bindings.ts` and each entry's import graph. - **FAIL IF** authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`. - **FAIL IF** the account origin's policy permits third-party scripts, framing, inline script execution, or any worker (`worker-src 'none'`); a voice response, or a relay response under a `RELAY_NON_PAGE_PREFIXES` prefix (`/api`, `/ws`), carries any policy but `RUNS_NOTHING_POLICY`; any response but a 101 WebSocket upgrade bypasses `secureHeaders`, a misconfigured deployment's error included; or a response is cached past its class: immutable only for a content-hashed file under the account's `/assets/` or the relay's `/assets/` and `/connect/assets/`, `no-cache` only on Pocket's other paths, `no-store` everywhere else, the account's SPA shell included. Inspect `secureHeaders` / `accountRules` / `relayRules` / `relayPathKind` in `hosted/server/headers.ts`, binding resolution in `hosted/server/worker-app.ts`, and asset routing in `hosted/wrangler.jsonc` and `hosted/wrangler.relay.jsonc`. - **FAIL IF** marketing scripts, analytics, provider avatars, or remote fonts enter the Hosted frontend; inspect the frontend import graph and deployed response when available. @@ -43,7 +43,7 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. ## Relay boundary -**The Hosted Relay** on the relay Worker, and its account routes on the account Worker: `docs/specs/hosted.md` -> "Relay", "Relay sockets", and "Burrow enrollment" own them; these are the checks on them. Inspect `relayApiRoutes` in `hosted/server/relay-api.ts`, `hosted/server/relay-auth.ts`, `relaySocketRoutes` in `hosted/server/relay-sockets.ts`, `RelayRoom` in `hosted/server/relay-room.ts`, the frame layer in `remote-lib-common/src/remote/relay-routing.ts`, `relayAccountRoutes` in `hosted/server/relay-account.ts`, `cookieAdmin` in `hosted/server/account-gate.ts`, and `hosted/server/dormouse-migrations/002_relay.sql`. +**The Hosted Relay** on the relay Worker, and its account routes on the account Worker: `docs/specs/hosted.md` -> "Relay", "Relay sockets", and "Burrow enrollment" own them; these are the checks on them. Inspect `relayApiRoutes` in `hosted/server/relay-api.ts`, `hosted/server/relay-auth.ts`, `relaySocketRoutes` in `hosted/server/relay-sockets.ts`, `RelayRoom` in `hosted/server/relay-room.ts`, the frame layer in `remote-lib-common/src/remote/relay-routing.ts`, `relayAccountRoutes` in `hosted/server/relay-account.ts`, `cookieAdmin` in `hosted/server/account-gate.ts`, `hosted/server/relay-push.ts`, and `hosted/server/dormouse-migrations/002_relay.sql` and `003_relay_push.sql`. - **FAIL IF** a session, Burrow, or setup token is stored other than as its SHA-256, an enrollment device code is stored at all, or an account's Relay rows outlive its user row. - **FAIL IF** a query reading a Burrow, passkey, presence nonce, or setup token is not scoped to the caller's account (the session's user, or the Burrow token's owner), a passkey registers to any account but the minting Burrow's owner, or a setup challenge redeems with another Burrow's token. @@ -54,6 +54,10 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. - **FAIL IF** enrollment begin or poll admits a request carrying `Origin`, or either reaches the database before its per-address limit. - **FAIL IF** an approval admits a request without a login, with an `Origin` other than the account's own exactly, from a login older than `LOGIN_FRESH_AGE_MS` or one whose creation time it cannot read, for an account but the entitled admin, or past the per-account attempt limit (`RELAY_APPROVE_LIMIT`, counted before the body is read). - **FAIL IF** a table a caller can grow has no cap keyed by whoever grows it (approvals: the per-account attempt limit), or a capped write deletes another key's rows; `signin/*` or `setup/begin`/`finish` reaches the database before its per-address limit; or a production `namespace_id` reaches `PREVIEW_RATELIMIT_OFFSET`. +- **FAIL IF** the push send reads, logs, or forwards notification text, or forwards anything but `{ burrowId, v, salt, ct }` copied field by field with the `burrowId` from the Burrow token; a session-gated push route registers against, reports, or deletes a row outside the session's account, or reports a `deliveryId` the caller did not present; or a push upsert, cap, or prune touches another account's rows. Inspect `relayPushRoutes` / `upsertSubscription`. +- **FAIL IF** a push endpoint is registered or fetched that `knownPushEndpoint` does not admit (`https:`, default port, no credentials, a listed push service's host), or a delivery follows a redirect or reads more than a bounded reason of a response body; inspect `deliverPush`. +- **FAIL IF** `RELAY_VAPID_PRIVATE_KEY` is anything but a relay Worker secret, a Wrangler `vars` entry or a preview config included, or push answers a key while the private key does not sign for it; inspect `pushConfigOf`, `vapidSigner` in `remote-lib-common/src/remote/web-push.ts`, and `hosted/scripts/preview.mjs`. +- **FAIL IF** the WebCrypto sender stops reproducing the RFC 8291 Appendix A message byte for byte in `remote-lib-common/test/web-push.test.mjs`, or that test takes an expected value from the code under test. - **FAIL IF** the relay Worker reads a cookie, asks auth, or reads a user column but the entitlement's; inspect the relay bundle's imports. - **FAIL IF** `RelayRoom` stores, logs, or decodes a frame or its `ct`, reads a frame other than through the shared frame layer (`readClientFrame`, `readBurrowFrame`), which parses only the routing envelope through the shared guards and copies `ct` field by field and reads it nowhere else, parses a frame before measuring its UTF-8 bytes against the shared `MAX_RELAY_FRAME_BYTES`, or stores anything but its account id; `scripts/e2e-lint.mjs` holds the name, parse, decode, log, storage, and attachment half textually in both modules. - **FAIL IF** a `RelayRoom` is named from anything but the account an authenticated token resolved to, or serves a request or RPC naming an account other than the one it stored. @@ -61,7 +65,7 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. - **FAIL IF** the object accepts a Burrow socket without rechecking its row (enrolled, the account's, owner entitled) under `blockConcurrencyWhile`, or awaits any row read past `RELAY_ROW_READ_TIMEOUT_MS`, so a stalled database resets every socket of the account; a removed Burrow's live socket outlives its removal's `closeBurrow`, or a removed or de-entitled Burrow's outlives the next sweep, at most `RELAY_ROOM_SWEEP_MS` while any Burrow socket is held; or a route reaches `closeBurrow`, `onlineBurrows`, `RelayRows`, or any other `RelayRoom` method but the two upgrades. - **FAIL IF** a Pocket path's response lacks Pocket's policy (`pocketContentSecurityPolicy` in `remote-lib-common/src/remote/relay-common.ts`, taken only from an `APP_ORIGIN` that is exactly an origin), any response but a Pocket path's allows the camera, `/connect/` included, or a response is classified on any path but the decoded one Hono routes on, so `/%63onnect/` would take Pocket's; inspect `relayRules` / `relayPathKind` and `secureHeaders` in `hosted/server/headers.ts`. -Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-room.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/pocket.test.ts`, and `hosted/server/tests/boundary.test.ts`. +Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.test.ts`, `hosted/server/tests/relay-room.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, and `remote-lib-common/test/web-push.test.mjs`. ## Deployment boundary diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index cf26be5a6..ff694985b 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -216,6 +216,8 @@ CGNAT, link-local, documentation, benchmark, multicast, reserved, IPv4-mapped, unique-local, and site-local ranges — rejecting a hostname wholesale if *any* answer is blocked, and handing the socket the exact address it checked so rebinding cannot create a second unchecked resolution. +The Hosted Relay, which cannot pin a resolution, admits only known push services' hosts +instead (`docs/specs/security-hosted.md` -> "Relay boundary"). - **FAIL IF** `relay/src/push-endpoint.ts` stops rejecting non-public push endpoints at registration, stops applying `createPublicLookup` / `createPublicPushAgent` to delivery, or stops rejecting a hostname whose DNS answers are mixed public and blocked. - **FAIL IF** `/api/push/send` stops taking the `burrowId` from the Burrow's own token, begins selecting recipients when `recipients` is absent or empty, stops clamping them at `MAX_PUSH_QUERY_DELIVERY_IDS`, or if any read endpoint begins reporting on a delivery id the caller did not present. Possession of the 256-bit `deliveryId` is the whole authorization for the Client-facing push routes, so the Relay must never *list* one to a session. diff --git a/hosted/README.md b/hosted/README.md index 2af9e8ea0..ecae3eacf 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -300,8 +300,8 @@ expiry; do not grant tag bypass to the bot or Actions generally. ### Runtime secrets in the Workers Auth, mail, and OAuth secrets live in the account Worker, the enrollment -secret in the relay Worker, and the ElevenLabs key in the voice Worker, never -GitHub. In your +secret and the Web Push (VAPID) pair in the relay Worker, and the ElevenLabs +key in the voice Worker, never GitHub. In your own terminal, from `hosted/`, authenticate Wrangler to the production account and use its hidden prompt, never a command-line value: @@ -336,6 +336,31 @@ through the same prompts as `GITHUB_CLIENT_ID`, `GOOGLE_CLIENT_ID`, initial Worker stub; it does not activate account service. Set all required secrets before release; deployment preserves the ones already there. +Generate the relay's VAPID pair once, in a private directory, and load both +halves as relay secrets without printing them; keep a copy of the JSON in your +secret manager, then delete it: + +```sh +umask 077 +node --input-type=module -e ' +import { generateKeyPairSync } from "node:crypto"; +const { d, x, y } = generateKeyPairSync("ec", { namedCurve: "P-256" }) + .privateKey.export({ format: "jwk" }); +const point = Buffer.concat([Buffer.from([4]), Buffer.from(x, "base64url"), Buffer.from(y, "base64url")]); +console.log(JSON.stringify({ + RELAY_VAPID_PUBLIC_KEY: point.toString("base64url"), + RELAY_VAPID_PRIVATE_KEY: d, +}));' > vapid.json +pnpm exec wrangler secret bulk vapid.json --config wrangler.relay.jsonc +rm vapid.json +``` + +The public half is public (`GET /api/push/config` serves it); the private half +signs every push. Push stays off, not half-working, until both are set and +match. Rotating the pair makes every phone's subscription stale until Pocket +re-registers it, so rotate only on compromise. Previews derive their own pair +and never need this one. + Configure the sender and enable each ready provider in `OAUTH_PROVIDERS` in `wrangler.jsonc`, comma separated, reviewed in a PR. The checked-in file enables GitHub, Google, Microsoft, and Apple; `docs/specs/hosted.md` -> "Identity and login" owns what a name and a diff --git a/hosted/package.json b/hosted/package.json index 166911f13..37333a26b 100644 --- a/hosted/package.json +++ b/hosted/package.json @@ -12,7 +12,7 @@ "db:status": "tsx server/db.ts status", "preview:worker": "wrangler dev --local --local-protocol https", "test:deploy": "node --test scripts/*.test.mjs", - "test:miniflare": "vitest run server/tests/one-time.test.ts server/tests/boundary.test.ts server/tests/pocket.test.ts", + "test:miniflare": "vitest run server/tests/one-time.test.ts server/tests/boundary.test.ts server/tests/pocket.test.ts server/tests/relay-push.test.ts", "preview:deploy": "node scripts/preview.mjs deploy", "preview:cleanup": "node scripts/preview.mjs cleanup", "preview:smoke": "node scripts/preview-smoke.mjs" diff --git a/hosted/scripts/preview.mjs b/hosted/scripts/preview.mjs index 0395b310e..67cee0bcf 100644 --- a/hosted/scripts/preview.mjs +++ b/hosted/scripts/preview.mjs @@ -1,4 +1,4 @@ -import { createHmac } from "node:crypto"; +import { createECDH, createHmac } from "node:crypto"; import { writeFile, mkdir, appendFile, rm } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -184,7 +184,8 @@ const secretsFile = (worker) => /** * Each preview Worker's secrets, keyed as `WORKERS` is: its `previewSecret`, * the HMAC of `PREVIEW_AUTH_SECRET` with its preview Worker's name, so no two - * Workers or PRs share one and none is stored. + * Workers or PRs share one and none is stored; and, with `previewVapid`, a + * VAPID pair derived the same way (`previewVapidKeys`). */ export function previewSecrets(configs, env) { const secret = required(env, "PREVIEW_AUTH_SECRET"); @@ -193,17 +194,43 @@ export function previewSecrets(configs, env) { return Object.fromEntries( Object.entries(WORKERS) .filter(([, { previewSecret }]) => previewSecret) - .map(([worker, { previewSecret }]) => [ - worker, - { - [previewSecret]: createHmac("sha256", secret) - .update(configs[worker].name) - .digest("hex"), - }, - ]), + .map(([worker, { previewSecret, previewVapid }]) => { + const name = configs[worker].name; + return [ + worker, + { + [previewSecret]: createHmac("sha256", secret).update(name).digest("hex"), + ...(previewVapid && previewVapidKeys(secret, name)), + }, + ]; + }), ); } +/** + * A preview's VAPID pair: the P-256 scalar is the HMAC of the preview secret + * with `/vapid`, so it is stable across a PR's redeploys and its + * subscriptions survive them. A digest that is not a valid scalar (about one + * in 2^32) takes the next counter. + */ +export function previewVapidKeys(secret, name) { + for (let counter = 0; ; counter++) { + const scalar = createHmac("sha256", secret) + .update(`${name}/vapid${counter ? `/${counter}` : ""}`) + .digest(); + const ecdh = createECDH("prime256v1"); + try { + ecdh.setPrivateKey(scalar); + } catch { + continue; + } + return { + RELAY_VAPID_PUBLIC_KEY: ecdh.getPublicKey().toString("base64url"), + RELAY_VAPID_PRIVATE_KEY: scalar.toString("base64url"), + }; + } +} + /** Each Worker's origin in `configs`, keyed as `WORKERS` is. */ export const originsOf = (configs) => Object.fromEntries( diff --git a/hosted/scripts/preview.test.mjs b/hosted/scripts/preview.test.mjs index ab449829f..0f84f6e6a 100644 --- a/hosted/scripts/preview.test.mjs +++ b/hosted/scripts/preview.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { createHmac } from "node:crypto"; +import { createHmac, createPrivateKey, createPublicKey, sign, verify } from "node:crypto"; import { readFile } from "node:fs/promises"; import { previewName, @@ -504,10 +504,32 @@ test("each preview Worker with a secret gets its own, derived from the preview s const configs = previewConfigs(await readConfigs(), env, "c".repeat(32)); const secret = "p".repeat(32); const derived = (name) => createHmac("sha256", secret).update(name).digest("hex"); - assert.deepEqual(previewSecrets(configs, { ...env, PREVIEW_AUTH_SECRET: secret }), { - account: { AUTH_SECRET: derived("dormouse-hosted-pr-42") }, - relay: { RELAY_ENROLL_SECRET: derived("dormouse-relay-pr-42") }, - }); + const secrets = previewSecrets(configs, { ...env, PREVIEW_AUTH_SECRET: secret }); + const { RELAY_VAPID_PUBLIC_KEY, RELAY_VAPID_PRIVATE_KEY, ...relay } = secrets.relay; + assert.deepEqual( + { ...secrets, relay }, + { + account: { AUTH_SECRET: derived("dormouse-hosted-pr-42") }, + relay: { RELAY_ENROLL_SECRET: derived("dormouse-relay-pr-42") }, + }, + ); + // The relay's VAPID scalar is the HMAC of its name, and its point signs as one pair. + assert.equal( + RELAY_VAPID_PRIVATE_KEY, + createHmac("sha256", secret).update("dormouse-relay-pr-42/vapid").digest("base64url"), + ); + const point = Buffer.from(RELAY_VAPID_PUBLIC_KEY, "base64url"); + assert.equal(point.length, 65); + const jwk = { kty: "EC", crv: "P-256", x: point.subarray(1, 33).toString("base64url"), y: point.subarray(33).toString("base64url") }; + const signature = sign("sha256", Buffer.from("probe"), createPrivateKey({ key: { ...jwk, d: RELAY_VAPID_PRIVATE_KEY }, format: "jwk" })); + assert.ok(verify("sha256", Buffer.from("probe"), createPublicKey({ key: jwk, format: "jwk" }), signature)); + // Stable across redeploys of one PR, distinct across PRs and secrets. + assert.deepEqual(previewSecrets(configs, { ...env, PREVIEW_AUTH_SECRET: secret }), secrets); + const otherPr = previewConfigs(await readConfigs(), { ...env, PR_NUMBER: "43" }, "c".repeat(32)); + assert.notEqual( + previewSecrets(otherPr, { ...env, PREVIEW_AUTH_SECRET: secret }).relay.RELAY_VAPID_PRIVATE_KEY, + RELAY_VAPID_PRIVATE_KEY, + ); assert.throws(() => previewSecrets(configs, env), /Missing PREVIEW_AUTH_SECRET/); assert.throws( () => previewSecrets(configs, { ...env, PREVIEW_AUTH_SECRET: "short" }), diff --git a/hosted/scripts/production.test.mjs b/hosted/scripts/production.test.mjs index f8480a00b..de33f3771 100644 --- a/hosted/scripts/production.test.mjs +++ b/hosted/scripts/production.test.mjs @@ -247,6 +247,7 @@ test("the Durable Objects are the relay's, each rate limit its Worker's, and Dur assert.equal(configs.voice.ratelimits, undefined); assert.equal(configs.voice.migrations, undefined); }); +const relaySecrets = ["RELAY_ENROLL_SECRET", "RELAY_VAPID_PUBLIC_KEY", "RELAY_VAPID_PRIVATE_KEY"]; const accountSecrets = [ "AUTH_SECRET", "POSTMARK_SERVER_TOKEN", @@ -262,7 +263,7 @@ function provider({ disabled = true, secrets = { "dormouse-hosted": accountSecrets, - "dormouse-relay": ["RELAY_ENROLL_SECRET"], + "dormouse-relay": relaySecrets, "dormouse-voice": ["ELEVENLABS_API_KEY"], }, read = [], @@ -290,7 +291,7 @@ test("preflight rejects wrong databases, caching, reused roles, and incomplete s provider({ secrets: { "dormouse-hosted": accountSecrets.filter((name) => name !== missing), - "dormouse-relay": ["RELAY_ENROLL_SECRET"], + "dormouse-relay": relaySecrets, "dormouse-voice": ["ELEVENLABS_API_KEY"], }, }), @@ -305,28 +306,29 @@ test("preflight rejects wrong databases, caching, reused roles, and incomplete s provider({ secrets: { "dormouse-hosted": [...accountSecrets, "ELEVENLABS_API_KEY"], - "dormouse-relay": ["RELAY_ENROLL_SECRET"], + "dormouse-relay": relaySecrets, "dormouse-voice": [], }, }), ), { message: "Missing dormouse-voice secret: ELEVENLABS_API_KEY" }, ); - // The relay's enrollment secret, on the account Worker, does not count either. - await assert.rejects( - preflight( - env, - configs, - provider({ - secrets: { - "dormouse-hosted": [...accountSecrets, "RELAY_ENROLL_SECRET"], - "dormouse-relay": [], - "dormouse-voice": ["ELEVENLABS_API_KEY"], - }, - }), - ), - { message: "Missing dormouse-relay secret: RELAY_ENROLL_SECRET" }, - ); + // The relay's secrets, on the account Worker, do not count either. + for (const missing of relaySecrets) + await assert.rejects( + preflight( + env, + configs, + provider({ + secrets: { + "dormouse-hosted": [...accountSecrets, missing], + "dormouse-relay": relaySecrets.filter((name) => name !== missing), + "dormouse-voice": ["ELEVENLABS_API_KEY"], + }, + }), + ), + { message: `Missing dormouse-relay secret: ${missing}` }, + ); // Each case supplies every configured secret, so it fails on its own check. for (const [override, message] of [ [{ host: "ep-preview.neon.tech" }, "Migration and runtime databases must use the same host"], @@ -348,7 +350,7 @@ test("preflight rejects wrong databases, caching, reused roles, and incomplete s provider({ secrets: { "dormouse-hosted": ["AUTH_SECRET", "POSTMARK_SERVER_TOKEN", ...extra], - "dormouse-relay": ["RELAY_ENROLL_SECRET"], + "dormouse-relay": relaySecrets, "dormouse-voice": ["ELEVENLABS_API_KEY"], }, }); diff --git a/hosted/scripts/workers.mjs b/hosted/scripts/workers.mjs index 61ac8c20e..64702ef39 100644 --- a/hosted/scripts/workers.mjs +++ b/hosted/scripts/workers.mjs @@ -19,8 +19,10 @@ export const WORKERS = { config: "wrangler.relay.jsonc", // The production entry: its mapper passes nothing a preview lacks. previewMain: "server/relay-worker.ts", - secrets: () => ["RELAY_ENROLL_SECRET"], + secrets: () => ["RELAY_ENROLL_SECRET", "RELAY_VAPID_PUBLIC_KEY", "RELAY_VAPID_PRIVATE_KEY"], previewSecret: "RELAY_ENROLL_SECRET", + /** Its preview also gets a VAPID pair, so push works with no production credential. */ + previewVapid: true, }, voice: { config: "wrangler.voice.jsonc", diff --git a/hosted/server/bindings.ts b/hosted/server/bindings.ts index 2f37ac1d6..372dd8e3f 100644 --- a/hosted/server/bindings.ts +++ b/hosted/server/bindings.ts @@ -50,6 +50,9 @@ export interface RelayEnv extends WorkerEnv { ACCOUNT_ORIGIN?: string; /** The HMAC key a device code's user code is derived under. */ RELAY_ENROLL_SECRET: string; + /** The Web Push signing pair; push is off without a matching pair. */ + RELAY_VAPID_PUBLIC_KEY?: string; + RELAY_VAPID_PRIVATE_KEY?: string; } /** `voice.dormouse.sh`: managed-voice speech and its history sweep. */ @@ -87,9 +90,9 @@ export const accountPreviewBindings = (env: AccountEnv): AccountEnv => ({ /** * Hyperdrive for the Relay's own tables and no auth secret: the Relay reads a - * user row only for its entitlement, never a login. Its one secret, - * `RELAY_ENROLL_SECRET`, derives enrollment user codes. Production and preview - * alike. + * user row only for its entitlement, never a login. Its secrets are + * `RELAY_ENROLL_SECRET`, which derives enrollment user codes, and the VAPID + * pair push is signed with. Production and preview alike. */ export const relayBindings = (env: RelayEnv): RelayEnv => ({ ASSETS: env.ASSETS, @@ -106,6 +109,8 @@ export const relayBindings = (env: RelayEnv): RelayEnv => ({ RELAY_ENROLL_POLL_LIMIT: env.RELAY_ENROLL_POLL_LIMIT, ACCOUNT_ORIGIN: exactOrigin(env.ACCOUNT_ORIGIN) ?? undefined, RELAY_ENROLL_SECRET: env.RELAY_ENROLL_SECRET, + RELAY_VAPID_PUBLIC_KEY: env.RELAY_VAPID_PUBLIC_KEY, + RELAY_VAPID_PRIVATE_KEY: env.RELAY_VAPID_PRIVATE_KEY, }); /** Hyperdrive for the token lookup and the ElevenLabs key; no auth secret. */ diff --git a/hosted/server/dormouse-migrations/003_relay_push.sql b/hosted/server/dormouse-migrations/003_relay_push.sql new file mode 100644 index 000000000..f706a9cf7 --- /dev/null +++ b/hosted/server/dormouse-migrations/003_relay_push.sql @@ -0,0 +1,23 @@ +-- Up Migration +-- The Hosted Relay's Web Push subscriptions (docs/specs/hosted.md -> "Relay"; +-- the shared rules are docs/specs/relay.md -> "Web Push" and "State files"). +-- Keyed on the pair (burrowId, deliveryId); the account is the Burrow's +-- owner, so removing a Burrow, or its account, drops its subscriptions. Every +-- field is bounded as the self-host Relay bounds it. +CREATE TABLE dormouse_relay_push_subscriptions ( + "burrowId" text NOT NULL REFERENCES dormouse_relay_burrows ("burrowId") ON DELETE CASCADE, + "deliveryId" text NOT NULL CHECK ("deliveryId" ~ '^[A-Za-z0-9_-]{43}$'), + endpoint text NOT NULL CHECK (length(endpoint) BETWEEN 1 AND 1024), + p256dh text NOT NULL CHECK (length(p256dh) BETWEEN 1 AND 88), + auth text NOT NULL CHECK (length(auth) BETWEEN 1 AND 24), + -- The VAPID public key the row was registered under: a rotation reads as + -- stale rather than working. + "vapidPublicKey" text NOT NULL CHECK ("vapidPublicKey" ~ '^[A-Za-z0-9_-]{87}$'), + "subscribedAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("burrowId", "deliveryId") +); +CREATE INDEX dormouse_relay_push_subscriptions_delivery ON dormouse_relay_push_subscriptions ("deliveryId"); +CREATE INDEX dormouse_relay_push_subscriptions_endpoint ON dormouse_relay_push_subscriptions (endpoint); + +-- Down Migration +DROP TABLE dormouse_relay_push_subscriptions; diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index 0b94e2bb5..52e3c0eec 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -13,6 +13,7 @@ import { MALFORMED_BINDING_ERROR, MAX_ENROLLED_BURROWS, MAX_PENDING_REAUTH_NONCES_PER_SESSION, + MAX_PUSH_SEND_BODY_BYTES, MAX_REQUEST_BODY_BYTES, MAX_TOKENS_PER_BURROW, NOT_ENTITLED_ERROR, @@ -46,7 +47,6 @@ import type { BurrowsResponse, PasskeyAssertion, PresenceBinding, - PushConfigResponse, ReauthBeginResponse, ReauthFinishResponse, SetupBeginResponse, @@ -59,9 +59,11 @@ import type { RelayEnv } from "./bindings"; import { allowed } from "./one-time"; import { ENROLLMENT_TTL_MS } from "./policy-constants"; import { relayRoom } from "./relay-room-contract"; +import { relayPushRoutes } from "./relay-push"; import { OWNER_COLUMNS, database, + locked, ownerOf, requireBurrow, requireSession, @@ -172,12 +174,12 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { app.use(route, perAddress(limit, "too many enrollment attempts")); } - app.use( - "/api/*", - bodyLimit({ - maxSize: MAX_REQUEST_BODY_BYTES, - onError: (c) => c.json({ error: BODY_TOO_LARGE_ERROR }, 413), - }), + // One route is exempt, its legitimate body being larger: the push send. + const tooLarge = (c: Context) => c.json({ error: BODY_TOO_LARGE_ERROR }, 413); + const smallBodies = bodyLimit({ maxSize: MAX_REQUEST_BODY_BYTES, onError: tooLarge }); + const sendBodies = bodyLimit({ maxSize: MAX_PUSH_SEND_BODY_BYTES, onError: tooLarge }); + app.use("/api/*", (c, next) => + (c.req.path === API_ROUTES.pushSend ? sendBodies : smallBodies)(c, next), ); // --- Setup: a passkey joins the account that owns the minting Burrow ------ @@ -538,11 +540,7 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { }); }); - // Push is off until the Durable Object Relay delivers it. - app.get(API_ROUTES.pushConfig, (c) => { - const res: PushConfigResponse = { applicationServerKey: null }; - return c.json(res); - }); + relayPushRoutes(app); } /** The relay Cron Trigger: every table's expired rows, whoever grew them. */ @@ -615,22 +613,6 @@ async function consumeChallenge(db: Client, challenge: string, burrowId: string return row?.live === true; } -/** Runs `action` in a transaction holding `key`'s advisory lock, so a cap check and its insert cannot interleave. */ -async function locked(db: Client, key: string, action: () => Promise) { - await db.query("BEGIN"); - try { - await db.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [ - `dormouse-relay:${key}`, - ]); - const result = await action(); - await db.query("COMMIT"); - return result; - } catch (error) { - await db.query("ROLLBACK").catch(() => {}); - throw error; - } -} - /** * Inserts `row` into `spec`'s table for `owner`, under that owner's lock and * in one statement: its own expired rows pruned, its live rows trimmed to diff --git a/hosted/server/relay-auth.ts b/hosted/server/relay-auth.ts index d1fde1c5a..8113f1750 100644 --- a/hosted/server/relay-auth.ts +++ b/hosted/server/relay-auth.ts @@ -110,6 +110,22 @@ export function database( return withClient(c.env.HYPERDRIVE.connectionString, action); } +/** Runs `action` in a transaction holding `key`'s advisory lock, so a cap check and its insert cannot interleave. */ +export async function locked(db: Client, key: string, action: () => Promise) { + await db.query("BEGIN"); + try { + await db.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [ + `dormouse-relay:${key}`, + ]); + const result = await action(); + await db.query("COMMIT"); + return result; + } catch (error) { + await db.query("ROLLBACK").catch(() => {}); + throw error; + } +} + export const unauthorized = (c: Context) => c.json({ error: UNAUTHORIZED_ERROR }, 401); /** diff --git a/hosted/server/relay-push.ts b/hosted/server/relay-push.ts new file mode 100644 index 000000000..11bf28e20 --- /dev/null +++ b/hosted/server/relay-push.ts @@ -0,0 +1,489 @@ +// Rules: docs/specs/hosted.md -> "Relay" (push); shared semantics docs/specs/relay.md -> "Web Push". +import type { Hono } from "hono"; +import { + API_ROUTES, + MAX_PUSH_ENDPOINT_LENGTH, + MAX_PUSH_QUERY_DELIVERY_IDS, + MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT, + MAX_PUSH_SUBSCRIPTIONS_PER_BURROW, + PUSH_SEND_DEADLINE_MS, + PUSH_TTL_SECONDS, + defaultVapidSubject, + isPushDeliveryId, + isPushSubscriptionPayload, + isSealedPushRecipient, + readJson, + utf8Encode, + vapidSigner, + webPushRequest, +} from "remote-lib-common"; +import type { + PushConfigResponse, + PushDevicesResponse, + PushSendRequest, + PushSendResponse, + PushSubscribeRequest, + PushSubscribeResponse, + PushSubscriptionsQueryRequest, + PushSubscriptionsQueryResponse, + SealedPushPayload, + VapidSigner, + WebPushKeys, +} from "remote-lib-common"; +import type { RelayEnv } from "./bindings"; +import { locked, requireBurrow, requireSession, type Client } from "./relay-auth"; + +/** + * The Web Push services a subscription may name, by exact host or by a + * suffix its host ends with: Chrome's FCM, Mozilla's autopush, Apple's + * (`https://*.push.apple.com`, as Apple documents), and Windows' WNS. No other + * host is registered or fetched (rationale). + */ +export const PUSH_SERVICE_HOSTS: readonly string[] = [ + "fcm.googleapis.com", + "updates.push.services.mozilla.com", +]; +export const PUSH_SERVICE_HOST_SUFFIXES: readonly string[] = [ + ".push.apple.com", + ".notify.windows.com", +]; + +/** Bytes of a refusal's body read for the log, and the characters kept of it. */ +const MAX_REASON_BYTES = 1024; +const MAX_LOGGED_REASON = 200; + +/** + * The endpoint as the URL a push may be sent to, or null: `https:` on the + * default port, no credentials, at most `MAX_PUSH_ENDPOINT_LENGTH`, and a + * known push service's host. + */ +export function knownPushEndpoint(endpoint: string): URL | null { + if (endpoint.length > MAX_PUSH_ENDPOINT_LENGTH) return null; + let url: URL; + try { + url = new URL(endpoint); + } catch { + return null; + } + if (url.protocol !== "https:" || url.username || url.password || url.port) return null; + const host = url.hostname; + const known = + PUSH_SERVICE_HOSTS.includes(host) || + PUSH_SERVICE_HOST_SUFFIXES.some( + (suffix) => + host.endsWith(suffix) && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/.test( + host.slice(0, -suffix.length), + ), + ); + return known ? url : null; +} + +/** What a configured deployment signs with. */ +export interface PushConfig { + signer: VapidSigner; + subject: string; +} + +let cachedSigner: { pair: string; signer: Promise } | undefined; + +/** + * This deployment's push configuration, or null — push off, not half-working + * — without both VAPID secrets, with a pair that does not match, or without a + * subject (`APP_ORIGIN` not https, or loopback). + */ +export async function pushConfigOf(env: RelayEnv): Promise { + const subject = defaultVapidSubject(env.APP_ORIGIN); + const publicKey = env.RELAY_VAPID_PUBLIC_KEY; + const privateKey = env.RELAY_VAPID_PRIVATE_KEY; + if (!subject || !publicKey || !privateKey) return null; + const pair = `${publicKey}.${privateKey}`; + if (cachedSigner?.pair !== pair) + cachedSigner = { pair, signer: vapidSigner({ publicKey, privateKey }) }; + const signer = await cachedSigner.signer; + return signer && { signer, subject }; +} + +export type PushDeliveryResult = "delivered" | "expired" | "failed"; + +/** One stored subscription, as delivery needs it. */ +export interface PushTarget { + endpoint: string; + keys: WebPushKeys; +} + +/** + * One push to one subscription: a known endpoint only, never a redirect, + * 2xx delivered, 404/410 expired, and anything else — a refusal, a redirect, a + * throw — failed, logged with the endpoint's origin (the endpoint is a bearer + * capability) and at most a bounded reason. + */ +export async function deliverPush( + target: PushTarget, + payload: string, + push: PushConfig, + { fetch: send = fetch, signal }: { fetch?: typeof fetch; signal?: AbortSignal } = {}, +): Promise { + const url = knownPushEndpoint(target.endpoint); + if (!url) { + console.warn("push delivery refused: endpoint is not a known push service"); + return "failed"; + } + try { + const request = await webPushRequest( + { endpoint: url.href, keys: target.keys }, + utf8Encode(payload), + { signer: push.signer, subject: push.subject, ttlSeconds: PUSH_TTL_SECONDS, nowMs: Date.now() }, + ); + const response = await send(url.href, { + method: "POST", + headers: request.headers, + // A fresh buffer `encryptWebPush` allocated. + body: request.body as Uint8Array, + redirect: "manual", + signal, + }); + if (response.status >= 200 && response.status < 300) { + await response.body?.cancel(); + return "delivered"; + } + if (response.status === 404 || response.status === 410) { + await response.body?.cancel(); + return "expired"; + } + console.warn(`push delivery failed for ${url.origin}:`, response.status, await reasonOf(response)); + return "failed"; + } catch (error) { + console.warn(`push delivery failed for ${url.origin}:`, clamp(String((error as Error)?.message ?? error))); + return "failed"; + } +} + +/** + * Runs one delivery under a wall-clock deadline, answering `failed` and + * aborting it once the deadline passes; a throw is `failed` too, so one + * delivery never takes the fan-out down. + */ +export async function deliverWithinDeadline( + deliver: (signal: AbortSignal) => Promise, + deadlineMs: number, +): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve() + .then(() => deliver(controller.signal)) + .catch(() => "failed" as const), + new Promise((resolve) => { + timer = setTimeout(() => { + console.warn(`push delivery exceeded ${deadlineMs}ms`); + resolve("failed"); + controller.abort(); + }, deadlineMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +/** The push service's own explanation, read to {@link MAX_REASON_BYTES}, collapsed and clamped. */ +async function reasonOf(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) return ""; + const chunks: Uint8Array[] = []; + let read = 0; + try { + while (read < MAX_REASON_BYTES) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + read += value.length; + } + } finally { + await reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(Math.min(read, MAX_REASON_BYTES)); + let offset = 0; + for (const chunk of chunks) { + const take = Math.min(chunk.length, bytes.length - offset); + bytes.set(chunk.subarray(0, take), offset); + offset += take; + } + return clamp(new TextDecoder().decode(bytes)); +} + +function clamp(text: string): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + return collapsed.length > MAX_LOGGED_REASON ? `${collapsed.slice(0, MAX_LOGGED_REASON)}…` : collapsed; +} + +/** A `timestamptz` column as epoch milliseconds. */ +const SUBSCRIBED_AT_MS = `floor(extract(epoch from s."subscribedAt") * 1000)::float8`; + +/** + * The Hosted Relay's push routes, at the self-host Relay's paths, shapes, + * statuses and error strings. A session reaches only rows of its own + * account's Burrows, and only by a `deliveryId` it presents; a Burrow only its + * own. Sends are HTTPS from the Burrow to this Worker, never through a socket. + */ +export function relayPushRoutes(app: Hono<{ Bindings: RelayEnv }>) { + app.get(API_ROUTES.pushConfig, async (c) => { + const res: PushConfigResponse = { + applicationServerKey: (await pushConfigOf(c.env))?.signer.publicKey ?? null, + }; + return c.json(res); + }); + + app.post(API_ROUTES.pushSubscribe, requireSession, async (c) => { + const push = await pushConfigOf(c.env); + if (!push) return c.json({ error: "push is not configured" }, 503); + const body = await readJson(c); + if ( + !body || + typeof body.burrowId !== "string" || + !isPushDeliveryId(body.deliveryId) || + !isPushSubscriptionPayload(body.subscription) + ) + return c.json({ error: "malformed request" }, 400); + if (!knownPushEndpoint(body.subscription.endpoint)) + return c.json({ error: "endpoint must be a known push service" }, 400); + const stored = await upsertSubscription(c.var.db, c.var.session.userId, { + burrowId: body.burrowId, + deliveryId: body.deliveryId, + endpoint: body.subscription.endpoint, + keys: body.subscription.keys, + vapidPublicKey: push.signer.publicKey, + }); + if (!stored) return c.json({ error: "unknown burrow" }, 404); + return c.json(stored satisfies PushSubscribeResponse); + }); + + app.post(API_ROUTES.pushSubscriptionsQuery, requireSession, async (c) => { + const deliveryIds: unknown = (await readJson(c))?.deliveryIds; + if ( + !Array.isArray(deliveryIds) || + deliveryIds.length === 0 || + deliveryIds.length > MAX_PUSH_QUERY_DELIVERY_IDS || + deliveryIds.some((id) => !isPushDeliveryId(id)) + ) + return c.json( + { error: `deliveryIds must be 1..${MAX_PUSH_QUERY_DELIVERY_IDS} delivery ids` }, + 400, + ); + const push = await pushConfigOf(c.env); + // Only ids the caller presented, of its own account, under the current key. + const { rows } = push + ? await c.var.db.query<{ burrowId: string; deliveryId: string }>( + `SELECT s."burrowId", s."deliveryId" + FROM dormouse_relay_push_subscriptions s + JOIN dormouse_relay_burrows b ON b."burrowId" = s."burrowId" + WHERE b."userId" = $1 AND s."deliveryId" = ANY($2::text[]) AND s."vapidPublicKey" = $3 + ORDER BY s."subscribedAt", s."burrowId"`, + [c.var.session.userId, deliveryIds, push.signer.publicKey], + ) + : { rows: [] }; + const res: PushSubscriptionsQueryResponse = { registered: rows }; + return c.json(res); + }); + + // Always 204: answering otherwise would make this an oracle for whether a + // guessed id names a row. Only the session's own account's rows go. + app.delete(API_ROUTES.pushSubscriptionDelete, requireSession, async (c) => { + const deliveryId = c.req.param("deliveryId"); + if (isPushDeliveryId(deliveryId)) + await c.var.db.query( + `DELETE FROM dormouse_relay_push_subscriptions s USING dormouse_relay_burrows b + WHERE b."burrowId" = s."burrowId" AND b."userId" = $1 AND s."deliveryId" = $2`, + [c.var.session.userId, deliveryId], + ); + return c.body(null, 204); + }); + + app.get(API_ROUTES.pushDevices, requireBurrow, async (c) => { + const push = await pushConfigOf(c.env); + const { rows } = push + ? await c.var.db.query<{ deliveryId: string; subscribedAt: number }>( + `SELECT s."deliveryId", ${SUBSCRIBED_AT_MS} AS "subscribedAt" + FROM dormouse_relay_push_subscriptions s + WHERE s."burrowId" = $1 AND s."vapidPublicKey" = $2 + ORDER BY s."subscribedAt", s."deliveryId"`, + [c.var.burrow.burrowId, push.signer.publicKey], + ) + : { rows: [] }; + const res: PushDevicesResponse = { devices: rows }; + return c.json(res); + }); + + app.post(API_ROUTES.pushSend, requireBurrow, async (c) => { + const push = await pushConfigOf(c.env); + if (!push) return c.json({ error: "push is not configured" }, 503); + const recipients: unknown = (await readJson(c))?.recipients; + if ( + !Array.isArray(recipients) || + recipients.length === 0 || + recipients.length > MAX_PUSH_QUERY_DELIVERY_IDS || + !recipients.every(isSealedPushRecipient) + ) + return c.json( + { + error: + `recipients must be 1..${MAX_PUSH_QUERY_DELIVERY_IDS} ` + + "{ deliveryId, sealed } pairs", + }, + 400, + ); + // The Burrow is its token's, never the body's. + const { burrowId } = c.var.burrow; + const { db } = c.var; + const { rows } = await db.query<{ deliveryId: string; endpoint: string; p256dh: string; auth: string }>( + `SELECT s."deliveryId", s.endpoint, s.p256dh, s.auth + FROM dormouse_relay_push_subscriptions s + WHERE s."burrowId" = $1 AND s."vapidPublicKey" = $2 AND s."deliveryId" = ANY($3::text[])`, + [burrowId, push.signer.publicKey, recipients.map((recipient) => recipient.deliveryId)], + ); + const byDelivery = new Map(rows.map((row) => [row.deliveryId, row])); + // One fetch per subscription, so a send stays within the per-Burrow cap + // of subrequests: a repeated recipient is not sent twice (rationale). + const targets = recipients.flatMap((recipient) => { + const row = byDelivery.get(recipient.deliveryId); + byDelivery.delete(recipient.deliveryId); + return row ? [{ row, sealed: recipient.sealed }] : []; + }); + const results = await Promise.all( + targets.map(async ({ row, sealed }) => ({ + endpoint: row.endpoint, + result: await deliverWithinDeadline( + (signal) => + deliverPush( + { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } }, + // Field by field, never a spread of `sealed`: a spread would let + // a Burrow override its token's `burrowId` and smuggle readable + // text past a Relay that must forward neither. + JSON.stringify({ + burrowId, + v: sealed.v, + salt: sealed.salt, + ct: sealed.ct, + } satisfies SealedPushPayload), + push, + { signal }, + ), + PUSH_SEND_DEADLINE_MS, + ), + })), + ); + // A subscription its push service calls gone is forgotten, within the + // sending Burrow's account. + const expired = results.filter((r) => r.result === "expired").map((r) => r.endpoint); + if (expired.length > 0) + await db.query( + `DELETE FROM dormouse_relay_push_subscriptions s USING dormouse_relay_burrows b + WHERE b."burrowId" = s."burrowId" AND b."userId" = $1 AND s.endpoint = ANY($2::text[])`, + [c.var.burrow.userId, expired], + ); + const res: PushSendResponse = { + delivered: results.filter((r) => r.result === "delivered").length, + expired: expired.length, + unknown: recipients.length - targets.length, + failed: results.filter((r) => r.result === "failed").length, + }; + return c.json(res); + }); +} + +/** A subscription as the subscribe route stores it. */ +interface Subscription { + burrowId: string; + deliveryId: string; + endpoint: string; + keys: WebPushKeys; + vapidPublicKey: string; +} + +/** + * Stores `record` for `userId`'s Burrow, or answers null when the Burrow is + * not that account's. One transaction under the account's advisory lock: + * + * 1. Every address this delivery is moving off — read from the account's rows + * carrying its `deliveryId`, whichever Burrow — has its rows dropped, + * matched on the endpoint; then the row is upserted. + * 2. The Burrow's rows, then the account's, are trimmed to their caps, oldest + * `subscribedAt` first, never the row just written. + * + * Answers the row's `subscribedAt` and every Burrow of the account whose rows + * carry the presented endpoint under the current key: the state, not the + * delta. No other account's row is read or written. + */ +export function upsertSubscription( + db: Client, + userId: string, + record: Subscription, +): Promise { + return locked(db, `push:${userId}`, async () => { + const { rowCount } = await db.query( + `SELECT 1 FROM dormouse_relay_burrows WHERE "burrowId" = $1 AND "userId" = $2`, + [record.burrowId, userId], + ); + if (!rowCount) return null; + const { + rows: [{ subscribedAt }], + } = await db.query<{ subscribedAt: number }>( + `WITH account AS ( + SELECT "burrowId" FROM dormouse_relay_burrows WHERE "userId" = $1 + ), replaced AS ( + SELECT s.endpoint FROM dormouse_relay_push_subscriptions s + WHERE s."burrowId" IN (SELECT "burrowId" FROM account) + AND s."deliveryId" = $3 AND s.endpoint <> $4 + ), dropped AS ( + DELETE FROM dormouse_relay_push_subscriptions s + WHERE s."burrowId" IN (SELECT "burrowId" FROM account) + AND s.endpoint IN (SELECT endpoint FROM replaced) + AND NOT (s."burrowId" = $2 AND s."deliveryId" = $3) + ) + INSERT INTO dormouse_relay_push_subscriptions AS s + ("burrowId", "deliveryId", endpoint, p256dh, auth, "vapidPublicKey") + VALUES ($2, $3, $4, $5, $6, $7) + ON CONFLICT ("burrowId", "deliveryId") DO UPDATE SET + endpoint = EXCLUDED.endpoint, p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth, + "vapidPublicKey" = EXCLUDED."vapidPublicKey", "subscribedAt" = now() + RETURNING ${SUBSCRIBED_AT_MS} AS "subscribedAt"`, + [ + userId, + record.burrowId, + record.deliveryId, + record.endpoint, + record.keys.p256dh, + record.keys.auth, + record.vapidPublicKey, + ], + ); + // Never the row just written; each cap keeps its newest. + await db.query( + `DELETE FROM dormouse_relay_push_subscriptions WHERE ("burrowId", "deliveryId") IN ( + SELECT s."burrowId", s."deliveryId" FROM dormouse_relay_push_subscriptions s + WHERE s."burrowId" = $1 AND s."deliveryId" <> $2 + ORDER BY s."subscribedAt" DESC, s."deliveryId" DESC OFFSET $3 + )`, + [record.burrowId, record.deliveryId, MAX_PUSH_SUBSCRIPTIONS_PER_BURROW - 1], + ); + await db.query( + `DELETE FROM dormouse_relay_push_subscriptions WHERE ("burrowId", "deliveryId") IN ( + SELECT s."burrowId", s."deliveryId" FROM dormouse_relay_push_subscriptions s + JOIN dormouse_relay_burrows b ON b."burrowId" = s."burrowId" + WHERE b."userId" = $1 AND NOT (s."burrowId" = $2 AND s."deliveryId" = $3) + ORDER BY s."subscribedAt" DESC, s."burrowId" DESC, s."deliveryId" DESC OFFSET $4 + )`, + [userId, record.burrowId, record.deliveryId, MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT - 1], + ); + const { rows } = await db.query<{ burrowId: string }>( + `SELECT DISTINCT s."burrowId" FROM dormouse_relay_push_subscriptions s + JOIN dormouse_relay_burrows b ON b."burrowId" = s."burrowId" + WHERE b."userId" = $1 AND s.endpoint = $2 AND s."vapidPublicKey" = $3 + ORDER BY s."burrowId"`, + [userId, record.endpoint, record.vapidPublicKey], + ); + return { subscribedAt, burrowIds: rows.map((row) => row.burrowId) }; + }); +} diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index a2b3f2aee..4b7fa0629 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -5,9 +5,12 @@ import { Miniflare, Response as WorkerResponse } from "miniflare"; import { readFileSync } from "node:fs"; import { API_ROUTES, + MAX_PUSH_SEND_BODY_BYTES, + MAX_REQUEST_BODY_BYTES, ONE_TIME_PAGE_PATH, ONE_TIME_WS_ROUTES, WS_ROUTES, + pushSubscriptionDeletePath, } from "remote-lib-common"; import { accountBindings, @@ -36,6 +39,7 @@ import { alone, bundleWorker, miniflareOptions, + testVapidKeys, type Name, } from "./bundle"; @@ -57,6 +61,7 @@ const everything = { GITHUB_CLIENT_ID: "test-github-id", GITHUB_CLIENT_SECRET: "test-github-secret", RELAY_ENROLL_SECRET: "test-relay-enroll-secret", + ...testVapidKeys(), }; const IMMUTABLE = "public, max-age=31536000, immutable"; @@ -137,6 +142,8 @@ const SERVED: Record = { ["POST", API_ROUTES.burrowSetupToken], ["POST", API_ROUTES.burrowEnrollBegin], ["POST", API_ROUTES.burrowEnrollPoll], + ["GET", API_ROUTES.pushConfig], + ["POST", API_ROUTES.pushSend], ["GET", "/"], ], voice: [["POST", "/api/voice/speak"]], @@ -155,6 +162,11 @@ const RELAY_API: [string, string][] = [ ["POST", API_ROUTES.burrowSetupToken], ["POST", API_ROUTES.burrowEnroll], ["GET", API_ROUTES.pushConfig], + ["POST", API_ROUTES.pushSubscribe], + ["POST", API_ROUTES.pushSubscriptionsQuery], + ["DELETE", pushSubscriptionDeletePath("A".repeat(43))], + ["GET", API_ROUTES.pushDevices], + ["POST", API_ROUTES.pushSend], ]; test.for(NAMES)( @@ -194,9 +206,6 @@ const ABSENT: Record = { ["DELETE", "/api/voice/tokens/00000000-0000-4000-8000-000000000000"], ...ACCOUNT_RELAY, ["POST", "/api/voice/speak"], - ["POST", "/api/push/subscribe"], - ["POST", "/api/push/send"], - ["GET", "/api/push/devices"], // The self-host installers' probe; neither a Burrow nor Pocket asks Hosted for it. ["GET", "/api/hello"], // The non-page prefixes 404 whole. @@ -403,6 +412,8 @@ test("each bindings mapper passes only what its Worker uses", () => { "RELAY_ROOM", "RELAY_SETUP_LIMIT", "RELAY_SIGNIN_LIMIT", + "RELAY_VAPID_PRIVATE_KEY", + "RELAY_VAPID_PUBLIC_KEY", ].sort(), ); // `ACCOUNT_ORIGIN` reaches the routes exactly an origin, or not at all. @@ -425,6 +436,32 @@ test("each bindings mapper passes only what its Worker uses", () => { ); }); +test("the relay answers its VAPID key, and push routes gate on a bearer before the database", async () => { + const relay = ORIGINS.relay; + const config = await send("relay", relay + API_ROUTES.pushConfig); + expect(await config.json()).toEqual({ + applicationServerKey: everything.RELAY_VAPID_PUBLIC_KEY, + }); + for (const [method, path] of RELAY_API.filter(([, path]) => path.startsWith("/api/push/"))) + if (path !== API_ROUTES.pushConfig) + expect((await send("relay", relay + path, method)).status, `${method} ${path}`).toBe(401); + expect(outbound).toEqual([]); +}); + +test("only the push send outgrows the request body bound, and only to its derived bound", async () => { + const post = (path: string, bytes: number) => + workers.relay.dispatchFetch(ORIGINS.relay + path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "x".repeat(bytes), + }); + // Past the general bound, a send reaches its Burrow-token gate; anything else is 413. + expect((await post(API_ROUTES.pushSend, MAX_REQUEST_BODY_BYTES + 1)).status).toBe(401); + expect((await post(API_ROUTES.pushSubscribe, MAX_REQUEST_BODY_BYTES + 1)).status).toBe(413); + expect((await post(API_ROUTES.pushSend, MAX_PUSH_SEND_BODY_BYTES + 1)).status).toBe(413); + expect(MAX_PUSH_SEND_BODY_BYTES).toBeGreaterThan(MAX_REQUEST_BODY_BYTES); +}); + test("neither the relay nor the voice bundle carries Better Auth", async () => { for (const [entry, bundle] of [ [ENTRIES.relay, bundles.relay], diff --git a/hosted/server/tests/bundle.ts b/hosted/server/tests/bundle.ts index c6889ec24..8b7a5ea1e 100644 --- a/hosted/server/tests/bundle.ts +++ b/hosted/server/tests/bundle.ts @@ -1,5 +1,6 @@ import { build } from "esbuild"; import { convertV4MiniflareOptions } from "miniflare"; +import { createECDH, createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { builtinModules } from "node:module"; import { WORKERS, parseConfig } from "../../scripts/workers.mjs"; @@ -37,6 +38,16 @@ const each = (pick: (config: WranglerConfig) => T) => export const ORIGINS = each((config) => config.vars.APP_ORIGIN); /** The relay's `RELAY_ENROLL_SECRET` in every test; production's is a Worker secret. */ export const TEST_ENROLL_SECRET = "dormouse-hosted-test-enroll-secret"; +/** A VAPID pair for tests, as the relay's two secrets hold one; `seed` names another. */ +export function testVapidKeys(seed = "dormouse-hosted-test-vapid") { + const scalar = createHash("sha256").update(seed).digest(); + const ecdh = createECDH("prime256v1"); + ecdh.setPrivateKey(scalar); + return { + RELAY_VAPID_PUBLIC_KEY: ecdh.getPublicKey().toString("base64url"), + RELAY_VAPID_PRIVATE_KEY: scalar.toString("base64url"), + }; +} /** Each Worker's production entry, from its config. */ export const ENTRIES = each((config) => config.main); diff --git a/hosted/server/tests/relay-push.test.ts b/hosted/server/tests/relay-push.test.ts new file mode 100644 index 000000000..93d47895f --- /dev/null +++ b/hosted/server/tests/relay-push.test.ts @@ -0,0 +1,174 @@ +import { test, expect, vi } from "vitest"; +import { createECDH } from "node:crypto"; +import { MAX_PUSH_ENDPOINT_LENGTH, MAX_PUSH_SUBSCRIPTIONS_PER_BURROW } from "remote-lib-common"; +import type { RelayEnv } from "../bindings"; +import { + deliverPush, + deliverWithinDeadline, + knownPushEndpoint, + pushConfigOf, + type PushConfig, +} from "../relay-push"; +import { NAMES, ORIGINS, testVapidKeys, wrangler } from "./bundle"; + +// The Hosted Relay's push egress (`docs/specs/hosted.md` -> "Relay"): which +// endpoints it registers and fetches, how one delivery is classified, and the +// deadline. The routes run against Postgres in `relay.test.ts`. + +test("only a known push service's https endpoint, on the default port and without credentials, is admitted", () => { + for (const endpoint of [ + "https://fcm.googleapis.com/fcm/send/abc:def", + "https://fcm.googleapis.com:443/wp/abc", + "https://web.push.apple.com/QGuQyavXutnMH-5", + "https://api.push.apple.com/3/device/abc", + "https://updates.push.services.mozilla.com/wpush/v2/gAAAA", + "https://wns2-par02p.notify.windows.com/w/?token=BQYAAAB", + "https://FCM.googleapis.com/fcm/send/abc", + ]) + expect(knownPushEndpoint(endpoint), endpoint).not.toBeNull(); + for (const endpoint of [ + "http://fcm.googleapis.com/fcm/send/abc", + "https://fcm.googleapis.com:8443/fcm/send/abc", + "https://user:pass@fcm.googleapis.com/fcm/send/abc", + "https://fcm.googleapis.com.evil.test/fcm/send/abc", + "https://push.apple.com/abc", + "https://evilpush.apple.com/abc", + "https://web.push.apple.com.evil.test/abc", + "https://notify.windows.com/w/", + "https://push.example.com/sub/abc", + "https://127.0.0.1/abc", + "https://[::1]/abc", + "https://localhost/abc", + `https://fcm.googleapis.com/${"a".repeat(MAX_PUSH_ENDPOINT_LENGTH)}`, + "not a url", + ]) + expect(knownPushEndpoint(endpoint), endpoint).toBeNull(); +}); + +const env = (extra: Partial = {}) => + ({ APP_ORIGIN: ORIGINS.relay, ...testVapidKeys(), ...extra }) as RelayEnv; + +test("push is configured only by a matching pair and an https, non-loopback origin", async () => { + const keys = testVapidKeys(); + expect((await pushConfigOf(env()))?.signer.publicKey).toBe(keys.RELAY_VAPID_PUBLIC_KEY); + expect((await pushConfigOf(env()))?.subject).toBe(ORIGINS.relay); + const other = testVapidKeys("another"); + for (const [name, extra] of [ + ["no public key", { RELAY_VAPID_PUBLIC_KEY: undefined }], + ["no private key", { RELAY_VAPID_PRIVATE_KEY: undefined }], + ["a mismatched pair", { RELAY_VAPID_PRIVATE_KEY: other.RELAY_VAPID_PRIVATE_KEY }], + ["a malformed key", { RELAY_VAPID_PUBLIC_KEY: "not-a-key" }], + ["a loopback origin", { APP_ORIGIN: "http://127.0.0.1:8787" }], + ] as const) + expect(await pushConfigOf(env(extra)), name).toBeNull(); +}); + +/** A subscription a browser could hold. */ +function target(endpoint = "https://fcm.googleapis.com/fcm/send/abc") { + const ecdh = createECDH("prime256v1"); + ecdh.generateKeys(); + return { + endpoint, + keys: { p256dh: ecdh.getPublicKey().toString("base64url"), auth: "BTBZMqHH6r4Tts7J_aSIgg" }, + }; +} + +async function push(): Promise { + return (await pushConfigOf(env()))!; +} + +test("one delivery: 2xx delivered, 404 and 410 expired, a redirect or refusal or throw failed", async () => { + const config = await push(); + const seen: RequestInit[] = []; + const answering = (response: () => Response) => + (async (url: string, init: RequestInit) => { + expect(url).toBe("https://fcm.googleapis.com/fcm/send/abc"); + seen.push(init); + return response(); + }) as unknown as typeof fetch; + const outcome = (response: () => Response) => + deliverPush(target(), '{"v":1}', config, { fetch: answering(response) }); + expect(await outcome(() => new Response(null, { status: 201 }))).toBe("delivered"); + expect(await outcome(() => new Response(null, { status: 404 }))).toBe("expired"); + expect(await outcome(() => new Response(null, { status: 410 }))).toBe("expired"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + expect( + await outcome(() => new Response(null, { status: 302, headers: { location: "https://evil.test/" } })), + ).toBe("failed"); + expect(await outcome(() => new Response(` {"reason":\n"BadJwtToken"}${" x".repeat(5000)}`, { status: 403 }))).toBe( + "failed", + ); + // The reason is collapsed and clamped; the endpoint's path never reaches the log. + const logged = warn.mock.calls.at(-1)!.map(String).join(" "); + expect(logged).toContain('{"reason": "BadJwtToken"}'); + expect(logged.length).toBeLessThan(400); + expect(logged).not.toContain("/fcm/send/abc"); + expect( + await deliverPush(target(), "{}", config, { + fetch: (async () => { + throw new Error("connection reset"); + }) as unknown as typeof fetch, + }), + ).toBe("failed"); + } finally { + warn.mockRestore(); + } + // Every request: POST, never following a redirect, aes128gcm under VAPID. + for (const init of seen) { + expect(init.method).toBe("POST"); + expect(init.redirect).toBe("manual"); + const headers = init.headers as Record; + expect(headers["content-encoding"]).toBe("aes128gcm"); + expect(headers.ttl).toBe("300"); + expect(headers.authorization).toMatch(/^vapid t=[^,]+, k=/); + } +}); + +test("an endpoint outside the allowlist is never fetched, whatever the row says", async () => { + const fetched = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + expect( + await deliverPush(target("https://push.example.com/sub/abc"), "{}", await push(), { + fetch: fetched as unknown as typeof fetch, + }), + ).toBe("failed"); + } finally { + warn.mockRestore(); + } + expect(fetched).not.toHaveBeenCalled(); +}); + +test("a delivery past its deadline is failed and aborted; a throw is failed", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + let aborted = false; + const started = Date.now(); + const result = await deliverWithinDeadline( + (signal) => + new Promise(() => { + signal.addEventListener("abort", () => (aborted = true)); + }), + 50, + ); + expect(result).toBe("failed"); + expect(aborted).toBe(true); + expect(Date.now() - started).toBeLessThan(1000); + expect( + await deliverWithinDeadline(() => { + throw new Error("synchronous"); + }, 1000), + ).toBe("failed"); + expect(await deliverWithinDeadline(async () => "delivered", 1000)).toBe("delivered"); + } finally { + warn.mockRestore(); + } +}); + +test("a send fits Workers Free's 50 subrequests, and no Wrangler config carries a VAPID key", () => { + // One fetch per distinct subscription of the sending Burrow, and its database connection. + expect(MAX_PUSH_SUBSCRIPTIONS_PER_BURROW + 1).toBeLessThanOrEqual(50); + for (const name of NAMES) + expect(Object.keys((wrangler[name] as { vars: object }).vars).filter((key) => /VAPID/.test(key)), name).toEqual([]); +}); diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index be8c6ccd4..87490f571 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { randomUUID } from "node:crypto"; +import { createDecipheriv, createECDH, hkdfSync, randomBytes, randomUUID } from "node:crypto"; import { digest } from "@pgstencil/auth/security"; import { Miniflare, Response as WorkerResponse } from "miniflare"; import { createTestContext } from "pgstencil/testing"; @@ -8,12 +8,20 @@ import { API_ROUTES, MAX_ENROLLED_BURROWS, MAX_PENDING_REAUTH_NONCES_PER_SESSION, + MAX_PUSH_QUERY_DELIVERY_IDS, + MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT, + MAX_PUSH_SUBSCRIPTIONS_PER_BURROW, MAX_TOKENS_PER_BURROW, NOT_ENTITLED_ERROR, SETUP_TOKEN_INVALID_ERROR, UNAUTHORIZED_ERROR, enrollUserCode, fromBase64Url, + generateNoiseKeyPair, + openPush, + pushSubscriptionDeletePath, + sealPush, + utf8Encode, isEnrollUserCode, isRelayBearer, toBase64Url, @@ -36,7 +44,14 @@ import { MAX_SETUP_CHALLENGES_PER_BURROW, restoreSetupToken, } from "../relay-api"; -import { ENTRIES, ORIGINS, TEST_ENROLL_SECRET, bundleWorker, miniflareOptions } from "./bundle"; +import { + ENTRIES, + ORIGINS, + TEST_ENROLL_SECRET, + bundleWorker, + miniflareOptions, + testVapidKeys, +} from "./bundle"; import { limitOf, untilLimited } from "./rate-limit"; // The Hosted Relay's routes (`docs/specs/hosted.md` -> "Relay") in real @@ -50,15 +65,30 @@ const script = bundleWorker(ENTRIES.relay); type Authenticator = Awaited>; -async function fixture() { +/** One request the relay sent a push service. */ +interface Pushed { + url: string; + headers: Record; + body: Buffer; +} + +async function fixture({ bindings = {} }: { bindings?: Record } = {}) { const context = await createTestContext({ migrations }); + // The push services: every request is recorded, then answered by `answer`. + const pushed: Pushed[] = []; + let answer: (url: URL) => WorkerResponse | Promise = () => + new WorkerResponse(null, { status: 201 }); const relay = new Miniflare( miniflareOptions("relay", (await script).outputFiles[0].text, { - bindings: { - APP_ORIGIN: origin, - ACCOUNT_ORIGIN: ORIGINS.account, - RELAY_ENROLL_SECRET: TEST_ENROLL_SECRET, - }, + bindings: Object.fromEntries( + Object.entries({ + APP_ORIGIN: origin, + ACCOUNT_ORIGIN: ORIGINS.account, + RELAY_ENROLL_SECRET: TEST_ENROLL_SECRET, + ...testVapidKeys(), + ...bindings, + }).filter(([, value]) => value !== undefined), + ) as Record, hyperdrives: { HYPERDRIVE: context.database.url }, serviceBindings: { ASSETS: () => @@ -66,6 +96,14 @@ async function fixture() { headers: { "content-type": "text/html" }, }), }, + async outboundService(request) { + pushed.push({ + url: request.url, + headers: Object.fromEntries(request.headers), + body: Buffer.from(await request.arrayBuffer()), + }); + return answer(new URL(request.url)); + }, }), ); try { @@ -261,6 +299,10 @@ async function fixture() { return { sql, cron, + pushed, + answerPushes: (respond: typeof answer) => { + answer = respond; + }, entitle, url: context.database.url, call, @@ -925,3 +967,504 @@ test("begin refuses another origin", async ({ onTestFinished }) => { // A trailing slash is the same origin. expect((await f.call("POST", API_ROUTES.burrowEnrollBegin, { body: { origin: `${origin}/` } })).status).toBe(200); }); + +// --- Web Push: the self-host Relay's push routes over account-scoped rows --- + +const FCM = "https://fcm.googleapis.com/fcm/send/"; + +/** A live session for `userId`, as sign-in writes one. */ +async function sessionFor(f: Awaited>, userId: string) { + const token = randomSecret(); + await f.sql( + `INSERT INTO dormouse_relay_sessions ("tokenHash", "userId", "expiresAt") + VALUES ($1, $2, now() + interval '1 hour')`, + [digest(token), userId], + ); + return token; +} + +/** A subscription a browser holds: a P-256 keypair from Node and an auth secret. */ +function browserSubscription(endpoint = FCM + randomSecret()) { + const ecdh = createECDH("prime256v1"); + ecdh.generateKeys(); + return { + ecdh, + subscription: { + endpoint, + keys: { p256dh: ecdh.getPublicKey().toString("base64url"), auth: randomBytes(16).toString("base64url") }, + }, + }; +} + +/** RFC 8291 decryption on Node's `crypto`, independent of the sender under test. */ +function decryptPush(body: Buffer, { ecdh, subscription }: ReturnType) { + const salt = body.subarray(0, 16); + const senderPublic = body.subarray(21, 21 + body[20]); + const record = body.subarray(21 + body[20]); + const uaPublic = Buffer.from(subscription.keys.p256dh, "base64url"); + const ikm = Buffer.from( + hkdfSync( + "sha256", + ecdh.computeSecret(senderPublic), + Buffer.from(subscription.keys.auth, "base64url"), + Buffer.concat([Buffer.from("WebPush: info\0"), uaPublic, senderPublic]), + 32, + ), + ); + const derive = (info: string, length: number) => + Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(info), length)); + const decipher = createDecipheriv( + "aes-128-gcm", + derive("Content-Encoding: aes128gcm\0", 16), + derive("Content-Encoding: nonce\0", 12), + ); + decipher.setAuthTag(record.subarray(-16)); + const padded = Buffer.concat([decipher.update(record.subarray(0, -16)), decipher.final()]); + expect(padded.at(-1)).toBe(2); + return JSON.parse(padded.subarray(0, -1).toString("utf8")); +} + +/** A well-formed sealed envelope with no key behind it: the Relay checks shape alone. */ +const fakeSealed = () => ({ v: 1, salt: randomSecret(), ct: randomSecret() + randomSecret() }); +const to = (...deliveryIds: string[]) => ({ + recipients: deliveryIds.map((deliveryId) => ({ deliveryId, sealed: fakeSealed() })), +}); + +/** An entitled account with one Burrow and a session, and the push calls Pocket and the Burrow make. */ +async function pushFixture(options?: Parameters[0]) { + const f = await fixture(options); + const owner = await f.account(ADMIN_EMAIL); + const laptop = await f.burrow(owner); + const session = await sessionFor(f, owner); + const subscribe = ( + deliveryId: string, + subscription = browserSubscription().subscription, + { burrowId = laptop.burrowId, bearer = session } = {}, + ) => + f.call("POST", API_ROUTES.pushSubscribe, { + bearer, + body: { burrowId, deliveryId, subscription }, + }); + const query = (deliveryIds: string[], bearer = session) => + f.call("POST", API_ROUTES.pushSubscriptionsQuery, { bearer, body: { deliveryIds } }); + const devices = (bearer = laptop.token) => f.call("GET", API_ROUTES.pushDevices, { bearer }); + const send = (body: unknown, bearer = laptop.token) => + f.call("POST", API_ROUTES.pushSend, { bearer, body }); + const rows = () => + f.sql<{ burrowId: string; deliveryId: string; endpoint: string }>( + `SELECT "burrowId", "deliveryId", endpoint FROM dormouse_relay_push_subscriptions + ORDER BY "subscribedAt", "burrowId", "deliveryId"`, + ); + return { ...f, owner, laptop, sessionToken: session, subscribe, query, devices, send, rows }; +} + +test("push end to end: a Burrow's sealed envelope reaches the push service encrypted to the phone, and nothing else", async ({ + onTestFinished, +}) => { + const f = await pushFixture(); + onTestFinished(f.close); + const keys = testVapidKeys(); + expect((await f.call("GET", API_ROUTES.pushConfig)).json).toEqual({ + applicationServerKey: keys.RELAY_VAPID_PUBLIC_KEY, + }); + const phone = browserSubscription(); + const deliveryId = randomSecret(); + const subscribed = await f.subscribe(deliveryId, phone.subscription); + expect(subscribed.status).toBe(200); + expect(subscribed.json).toEqual({ subscribedAt: expect.any(Number), burrowIds: [f.laptop.burrowId] }); + expect((await f.devices()).json).toEqual({ + devices: [{ deliveryId, subscribedAt: subscribed.json!.subscribedAt }], + }); + expect((await f.query([deliveryId, randomSecret()])).json).toEqual({ + registered: [{ burrowId: f.laptop.burrowId, deliveryId }], + }); + + const burrowStatic = await generateNoiseKeyPair(); + const clientStatic = await generateNoiseKeyPair(); + const notification = utf8Encode(JSON.stringify({ title: "build finished", body: "zsh", tag: "pty-1" })); + const sealed = await sealPush({ + burrowStaticPrivateKey: burrowStatic.privateKey, + clientStaticPublicKey: clientStatic.publicKey, + plaintext: notification, + }); + // Extra fields ride along on the envelope; none reaches the phone, the + // `burrowId` least of all. + const sent = await f.send({ + recipients: [{ deliveryId, sealed: { ...sealed, burrowId: randomRoutingId(), title: "leak" } }], + }); + expect(sent.json).toEqual({ delivered: 1, expired: 0, unknown: 0, failed: 0 }); + expect(f.pushed).toHaveLength(1); + const [request] = f.pushed; + expect(request.url).toBe(phone.subscription.endpoint); + expect(request.headers["content-encoding"]).toBe("aes128gcm"); + expect(request.headers.ttl).toBe("300"); + expect(request.headers.urgency).toBe("high"); + + // The VAPID JWT: signed by the relay's key, for the push service's origin, from the relay's origin. + const [, jwt, k] = /^vapid t=([^,]+), k=(.+)$/.exec(request.headers.authorization)!; + expect(k).toBe(keys.RELAY_VAPID_PUBLIC_KEY); + const [header, claims, signature] = jwt.split("."); + const verifyKey = await crypto.subtle.importKey( + "raw", + Buffer.from(keys.RELAY_VAPID_PUBLIC_KEY, "base64url"), + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + expect( + await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + verifyKey, + Buffer.from(signature, "base64url"), + Buffer.from(`${header}.${claims}`), + ), + ).toBe(true); + const { aud, sub, exp } = JSON.parse(Buffer.from(claims, "base64url").toString("utf8")); + expect([aud, sub]).toEqual(["https://fcm.googleapis.com", origin]); + expect(exp * 1000 - Date.now()).toBeGreaterThan(0); + expect(exp * 1000 - Date.now()).toBeLessThanOrEqual(24 * 60 * 60 * 1000); + + // Exactly the four fields, the `burrowId` the token's, and the phone opens it. + const payload = decryptPush(request.body, phone); + expect(Object.keys(payload).sort()).toEqual(["burrowId", "ct", "salt", "v"]); + expect(payload.burrowId).toBe(f.laptop.burrowId); + expect( + await openPush({ + clientStaticPrivateKey: clientStatic.privateKey, + burrowStaticPublicKey: burrowStatic.publicKey, + sealed: payload, + }), + ).toEqual(notification); + + // Deleting is always 204, and the row is gone. + const remove = (id: string) => + f.call("DELETE", pushSubscriptionDeletePath(id), { bearer: f.sessionToken }); + expect((await remove(deliveryId)).status).toBe(204); + expect((await remove(deliveryId)).status).toBe(204); + expect((await remove("not-a-delivery-id")).status).toBe(204); + expect(await f.rows()).toEqual([]); +}); + +test("push rows are the account's: another account's session or Burrow subscribes, reads, deletes, and reaches none of them", async ({ + onTestFinished, +}) => { + const f = await pushFixture(); + onTestFinished(f.close); + const shared = browserSubscription().subscription; + const deliveryA = randomSecret(); + expect((await f.subscribe(deliveryA, shared)).status).toBe(200); + + const b = await f.account(); + const laptopB = await f.burrow(b); + const sessionB = await sessionFor(f, b); + // While A is entitled, B's session is the expired session's 401. + expect(await f.query([deliveryA], sessionB)).toMatchObject({ + status: 401, + json: { error: UNAUTHORIZED_ERROR }, + }); + await f.entitle(b); + // A's Burrow is unknown to B. + expect(await f.subscribe(randomSecret(), shared, { burrowId: f.laptop.burrowId, bearer: sessionB })).toMatchObject({ + status: 404, + json: { error: "unknown burrow" }, + }); + // Possession of A's id buys B nothing: no readback, no delete. + expect((await f.query([deliveryA], sessionB)).json).toEqual({ registered: [] }); + expect((await f.call("DELETE", pushSubscriptionDeletePath(deliveryA), { bearer: sessionB })).status).toBe(204); + // B's own row at A's address answers only B's Burrow. + const ownB = randomSecret(); + expect((await f.subscribe(ownB, shared, { burrowId: laptopB.burrowId, bearer: sessionB })).json).toMatchObject({ + burrowIds: [laptopB.burrowId], + }); + // B registering A's id at a new address moves nothing of A's: A's address is + // not one B's delivery is moving off, so B's row there stays too. + const moved = browserSubscription().subscription; + expect( + (await f.subscribe(deliveryA, moved, { burrowId: laptopB.burrowId, bearer: sessionB })).json, + ).toMatchObject({ burrowIds: [laptopB.burrowId] }); + expect((await f.query([ownB], sessionB)).json).toEqual({ + registered: [{ burrowId: laptopB.burrowId, deliveryId: ownB }], + }); + // B moving its own row off A's address drops B's rows there, never A's. + expect( + (await f.subscribe(ownB, browserSubscription().subscription, { burrowId: laptopB.burrowId, bearer: sessionB })) + .json, + ).toMatchObject({ burrowIds: [laptopB.burrowId] }); + // B's Burrow neither lists nor reaches A's subscriber. + const onlyA = randomSecret(); + await f.entitle(f.owner); + expect((await f.subscribe(onlyA)).status).toBe(200); + await f.entitle(b); + expect((await f.devices(laptopB.token)).json!.devices).toHaveLength(2); + expect((await f.send(to(onlyA), laptopB.token)).json).toEqual({ + delivered: 0, + expired: 0, + unknown: 1, + failed: 0, + }); + expect(f.pushed).toEqual([]); + // A's rows are all still there. + await f.entitle(f.owner); + expect((await f.query([deliveryA, onlyA])).json).toEqual({ + registered: [ + { burrowId: f.laptop.burrowId, deliveryId: deliveryA }, + { burrowId: f.laptop.burrowId, deliveryId: onlyA }, + ], + }); + // A de-entitled owner's Burrow sends nothing. + await f.entitle(b); + expect(await f.send(to(onlyA))).toMatchObject({ status: 403, json: { error: NOT_ENTITLED_ERROR } }); +}); + +test("subscribe upserts as the self-host Relay does: a moved endpoint takes its stale rows with it", async ({ + onTestFinished, +}) => { + const f = await pushFixture(); + onTestFinished(f.close); + const other = await f.burrow(f.owner); + const sub = (endpoint: string) => browserSubscription(FCM + endpoint).subscription; + const reset = () => f.sql(`DELETE FROM dormouse_relay_push_subscriptions`); + const endpoints = async () => (await f.rows()).map((row) => row.endpoint); + + // Re-subscribing replaces the row rather than accumulating one per rotation. + const deliveryId = randomSecret(); + await f.subscribe(deliveryId, sub("1")); + await f.subscribe(deliveryId, sub("2")); + expect(await endpoints()).toEqual([FCM + "2"]); + await reset(); + + // Rotating the endpoint drops every row still carrying the replaced one. + const [forLaptop, forOther] = [randomSecret(), randomSecret()]; + await f.subscribe(forLaptop, sub("original")); + await f.subscribe(forOther, sub("original"), { burrowId: other.burrowId }); + expect((await f.subscribe(forLaptop, sub("replacement"))).json!.burrowIds).toEqual([f.laptop.burrowId]); + expect(await f.rows()).toEqual([ + { burrowId: f.laptop.burrowId, deliveryId: forLaptop, endpoint: FCM + "replacement" }, + ]); + await reset(); + + // A moved delivery drops its own stale rows under every Burrow that holds it. + await f.subscribe(deliveryId, sub("old"), { burrowId: other.burrowId }); + expect((await f.subscribe(deliveryId, sub("new"))).json!.burrowIds).toEqual([f.laptop.burrowId]); + expect(await endpoints()).toEqual([FCM + "new"]); + expect((await f.query([deliveryId])).json!.registered).toEqual([{ burrowId: f.laptop.burrowId, deliveryId }]); + await reset(); + + // Subscribe answers every Burrow whose rows carry the presented endpoint, and only that endpoint's. + expect((await f.subscribe(forLaptop, sub("phone"))).json!.burrowIds).toEqual([f.laptop.burrowId]); + expect( + [...(await f.subscribe(forOther, sub("phone"), { burrowId: other.burrowId })).json!.burrowIds].sort(), + ).toEqual([f.laptop.burrowId, other.burrowId].sort()); + expect((await f.subscribe(randomSecret(), sub("other-phone"))).json!.burrowIds).toEqual([f.laptop.burrowId]); + await reset(); + + // A retried subscribe whose first response was lost still reports the truth. + await f.subscribe(forLaptop, sub("first")); + await f.subscribe(forOther, sub("first"), { burrowId: other.burrowId }); + const rotated = sub("rotated"); + expect((await f.subscribe(forLaptop, rotated)).json!.burrowIds).toEqual([f.laptop.burrowId]); + expect((await f.subscribe(forLaptop, rotated)).json!.burrowIds).toEqual([f.laptop.burrowId]); + await reset(); + + // A brand-new delivery id cannot know its scope's previous address: those rows survive. + await f.subscribe(forLaptop, sub("before")); + await f.subscribe(randomSecret(), sub("after")); + expect((await endpoints()).sort()).toEqual([FCM + "after", FCM + "before"]); +}); + +test("subscriptions are capped per Burrow and per account, evicting the oldest and never the new row or another account's", async ({ + onTestFinished, +}) => { + const f = await pushFixture(); + onTestFinished(f.close); + const vapid = testVapidKeys().RELAY_VAPID_PUBLIC_KEY; + /** `count` rows for `burrowId`, the oldest first, all older than any subscribe. */ + const seed = (burrowId: string, count: number, prefix: string) => + f.sql( + `INSERT INTO dormouse_relay_push_subscriptions + ("burrowId", "deliveryId", endpoint, p256dh, auth, "vapidPublicKey", "subscribedAt") + SELECT $1, lpad($3 || i::text, 43, 'A'), $4 || $3 || i::text, 'BPoint', 'Auth', $5, + now() - interval '1 day' + i * interval '1 second' + FROM generate_series(1, $2::int) AS i`, + [burrowId, count, prefix, FCM, vapid], + ); + const count = async (where: string, values: unknown[]) => + ( + await f.sql<{ n: number }>( + `SELECT count(*)::int AS n FROM dormouse_relay_push_subscriptions s + JOIN dormouse_relay_burrows b ON b."burrowId" = s."burrowId" WHERE ${where}`, + values, + ) + )[0].n; + // Another account already past both caps: nothing here touches it. + const stranger = await f.account(); + const strangers = await f.burrow(stranger); + await seed(strangers.burrowId, MAX_PUSH_SUBSCRIPTIONS_PER_BURROW + 8, "z"); + + await seed(f.laptop.burrowId, MAX_PUSH_SUBSCRIPTIONS_PER_BURROW, "a"); + const newest = randomSecret(); + expect((await f.subscribe(newest)).status).toBe(200); + expect(await count(`s."burrowId" = $1`, [f.laptop.burrowId])).toBe(MAX_PUSH_SUBSCRIPTIONS_PER_BURROW); + expect(await count(`s."deliveryId" = $1`, [newest])).toBe(1); + expect(await count(`s."deliveryId" = $1`, ["a1".padStart(43, "A")])).toBe(0); + expect(await count(`s."deliveryId" = $1`, ["a2".padStart(43, "A")])).toBe(1); + + // Fill the account to its cap across further Burrows, then subscribe on one more. + const perAccount = MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT / MAX_PUSH_SUBSCRIPTIONS_PER_BURROW; + for (let i = 1; i < perAccount; i++) await seed((await f.burrow(f.owner)).burrowId, MAX_PUSH_SUBSCRIPTIONS_PER_BURROW, `b${i}x`); + const last = await f.burrow(f.owner); + const latest = randomSecret(); + expect((await f.subscribe(latest, undefined, { burrowId: last.burrowId })).status).toBe(200); + expect(await count(`b."userId" = $1`, [f.owner])).toBe(MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT); + expect(await count(`s."deliveryId" = $1`, [latest])).toBe(1); + // The account's oldest row went: the first of the `b1x` Burrow, older than every `a` row. + expect(await count(`s."deliveryId" = $1`, ["b1x1".padStart(43, "A")])).toBe(0); + expect(await count(`b."userId" = $1`, [stranger])).toBe(MAX_PUSH_SUBSCRIPTIONS_PER_BURROW + 8); +}); + +test("removing a Burrow drops its subscriptions; views are VAPID-current; push is off, not half-working, without a matching pair", async ({ + onTestFinished, +}) => { + const f = await pushFixture(); + onTestFinished(f.close); + const [kept, stale] = [randomSecret(), randomSecret()]; + await f.subscribe(kept); + await f.subscribe(stale); + // A row registered under another key is stale: no readback, no device, no delivery. + await f.sql(`UPDATE dormouse_relay_push_subscriptions SET "vapidPublicKey" = $1 WHERE "deliveryId" = $2`, [ + testVapidKeys("rotated").RELAY_VAPID_PUBLIC_KEY, + stale, + ]); + expect((await f.query([kept, stale])).json!.registered).toEqual([{ burrowId: f.laptop.burrowId, deliveryId: kept }]); + expect((await f.devices()).json!.devices.map((d: { deliveryId: string }) => d.deliveryId)).toEqual([kept]); + expect((await f.send(to(stale))).json).toEqual({ delivered: 0, expired: 0, unknown: 1, failed: 0 }); + expect(f.pushed).toEqual([]); + expect(await f.rows()).toHaveLength(2); + // Removing the Burrow, as the account's Computers section does, drops its rows. + await f.sql(`DELETE FROM dormouse_relay_burrows WHERE "burrowId" = $1`, [f.laptop.burrowId]); + expect(await f.rows()).toEqual([]); + + for (const bindings of [ + { RELAY_VAPID_PUBLIC_KEY: undefined }, + { RELAY_VAPID_PRIVATE_KEY: testVapidKeys("mismatched").RELAY_VAPID_PRIVATE_KEY }, + ]) { + const off = await pushFixture({ bindings }); + try { + expect((await off.call("GET", API_ROUTES.pushConfig)).json).toEqual({ applicationServerKey: null }); + expect(await off.subscribe(randomSecret())).toMatchObject({ + status: 503, + json: { error: "push is not configured" }, + }); + expect(await off.send(to(randomSecret()))).toMatchObject({ status: 503 }); + expect((await off.query([randomSecret()])).json).toEqual({ registered: [] }); + expect((await off.devices()).json).toEqual({ devices: [] }); + } finally { + await off.close(); + } + } +}); + +test("send outcomes: 404 and 410 prune, a refusal, a redirect, or a throw is failed and kept, and siblings deliver", async ({ + onTestFinished, +}) => { + const f = await pushFixture(); + onTestFinished(f.close); + const outcomes = ["ok", "gone404", "gone410", "refused", "redirect", "throw"]; + const ids = Object.fromEntries(outcomes.map((name) => [name, randomSecret()])); + for (const name of outcomes) await f.subscribe(ids[name], browserSubscription(FCM + name).subscription); + f.answerPushes((url) => { + const name = url.pathname.split("/").at(-1); + if (name === "throw") throw new Error("connection reset"); + if (name === "redirect") + return new WorkerResponse(null, { status: 307, headers: { location: `${FCM}ok` } }); + const status = { ok: 201, gone404: 404, gone410: 410, refused: 500 }[name!]!; + return new WorkerResponse(status === 500 ? '{"reason":"Overloaded"}' : null, { status }); + }); + // A repeated recipient is not sent twice. + const body = to(...outcomes.map((name) => ids[name]), ids.ok); + expect((await f.send(body)).json).toEqual({ delivered: 1, expired: 2, unknown: 1, failed: 3 }); + // One request per subscription: the redirect was not followed. + expect(f.pushed.map((request) => request.url).sort()).toEqual(outcomes.map((name) => FCM + name).sort()); + expect((await f.rows()).map((row) => row.endpoint).sort()).toEqual( + ["ok", "refused", "redirect", "throw"].map((name) => FCM + name).sort(), + ); +}); + +test("a send waits at most its deadline for a hung push service, keeping the row", async ({ onTestFinished }) => { + const f = await pushFixture(); + onTestFinished(f.close); + const [hung, ok] = [randomSecret(), randomSecret()]; + await f.subscribe(hung, browserSubscription(FCM + "hung").subscription); + await f.subscribe(ok, browserSubscription(FCM + "ok").subscription); + f.answerPushes((url) => + url.pathname.endsWith("/hung") ? new Promise(() => {}) : new WorkerResponse(null, { status: 201 }), + ); + const started = Date.now(); + expect((await f.send(to(hung, ok))).json).toEqual({ delivered: 1, expired: 0, unknown: 0, failed: 1 }); + const elapsed = Date.now() - started; + expect(elapsed).toBeGreaterThanOrEqual(15_000 - 500); + expect(elapsed).toBeLessThan(30_000); + expect(await f.rows()).toHaveLength(2); +}); + +test("only a known push service's endpoint registers, and a row naming any other is never fetched", async ({ + onTestFinished, +}) => { + const f = await pushFixture(); + onTestFinished(f.close); + for (const endpoint of [ + "http://fcm.googleapis.com/fcm/send/abc", + "https://fcm.googleapis.com:8443/fcm/send/abc", + "https://user:pass@fcm.googleapis.com/fcm/send/abc", + "https://fcm.googleapis.com.evil.test/fcm/send/abc", + "https://push.example.com/sub/abc", + "https://100.64.0.1/sub/abc", + "https://localhost/sub/abc", + ]) + expect(await f.subscribe(randomSecret(), browserSubscription(endpoint).subscription), endpoint).toMatchObject({ + status: 400, + json: { error: "endpoint must be a known push service" }, + }); + for (const endpoint of [ + "https://web.push.apple.com/QGuQyavXutnMH-5", + "https://updates.push.services.mozilla.com/wpush/v2/gAAAA", + "https://wns2-par02p.notify.windows.com/w/?token=BQYAAAB", + ]) + expect((await f.subscribe(randomSecret(), browserSubscription(endpoint).subscription)).status, endpoint).toBe(200); + expect(await f.rows()).toHaveLength(3); + // A row the allowlist no longer admits, however it got there, is never fetched. + const legacy = randomSecret(); + await f.sql( + `INSERT INTO dormouse_relay_push_subscriptions ("burrowId", "deliveryId", endpoint, p256dh, auth, "vapidPublicKey") + VALUES ($1, $2, 'https://push.example.com/sub/abc', $3, 'BTBZMqHH6r4Tts7J_aSIgg', $4)`, + [f.laptop.burrowId, legacy, browserSubscription().subscription.keys.p256dh, testVapidKeys().RELAY_VAPID_PUBLIC_KEY], + ); + expect((await f.send(to(legacy))).json).toEqual({ delivered: 0, expired: 0, unknown: 0, failed: 1 }); + expect(f.pushed).toEqual([]); +}); + +test("send requires and bounds its recipients, and the push routes refuse an id no Burrow minted", async ({ + onTestFinished, +}) => { + const f = await pushFixture(); + onTestFinished(f.close); + const refused = { + status: 400, + json: { error: `recipients must be 1..${MAX_PUSH_QUERY_DELIVERY_IDS} { deliveryId, sealed } pairs` }, + }; + expect(await f.send({})).toMatchObject(refused); + expect(await f.send({ recipients: [] })).toMatchObject(refused); + expect( + await f.send(to(...Array.from({ length: MAX_PUSH_QUERY_DELIVERY_IDS + 1 }, () => randomSecret()))), + ).toMatchObject(refused); + expect(await f.send({ recipients: [{ deliveryId: randomSecret(), sealed: { v: 2, salt: "x", ct: "y" } }] })).toMatchObject( + refused, + ); + expect(await f.send(to("short"))).toMatchObject(refused); + expect(await f.query(["short"])).toMatchObject({ status: 400 }); + expect(await f.query([])).toMatchObject({ status: 400 }); + expect(await f.subscribe("short")).toMatchObject({ status: 400, json: { error: "malformed request" } }); + // A session is not a Burrow token, nor the reverse. + expect((await f.devices(f.sessionToken)).status).toBe(401); + expect((await f.query([randomSecret()], f.laptop.token)).status).toBe(401); +}); diff --git a/relay/src/app.ts b/relay/src/app.ts index 0a8c150a2..8cbf19288 100644 --- a/relay/src/app.ts +++ b/relay/src/app.ts @@ -24,10 +24,9 @@ import { WS_CLOSE_UNAUTHORIZED_REASON, WS_CLOSE_IDLE, WS_CLOSE_IDLE_REASON, - DELIVERY_ID_LENGTH, ChallengeIssuer, MAX_PUSH_QUERY_DELIVERY_IDS, - MAX_SEALED_PUSH_LENGTH, + MAX_PUSH_SEND_BODY_BYTES, SELFHOST_ACCOUNT_ID, SETUP_TOKEN_INVALID_ERROR, BAD_PASSWORD_ERROR, @@ -36,13 +35,14 @@ import { WS_ROUTES, PUSH_SEND_DEADLINE_MS, WS_TOKEN_PARAM, - isExactBase64Url, isOrigin, isPresenceBinding, normalizeOrigin, presenceChallenge, toBase64Url, - isSealedPushV1, + isPushDeliveryId, + isPushSubscriptionPayload, + isSealedPushRecipient, verifyPasskeyAssertion, TokenBucket, MAX_PENDING_REAUTH_NONCES_PER_SESSION, @@ -75,7 +75,6 @@ import type { PushSendResponse, PushSubscribeRequest, PushSubscribeResponse, - PushSubscriptionPayload, PushSubscriptionsQueryRequest, PushSubscriptionsQueryResponse, ReauthBeginRequest, @@ -83,7 +82,6 @@ import type { ReauthFinishRequest, ReauthFinishResponse, SealedPushPayload, - SealedPushRecipient, SetupBeginRequest, SetupBeginResponse, SetupFinishRequest, @@ -111,7 +109,7 @@ import { import type { StoredBurrow, StoredPushSubscription } from './state.js'; import { sendWithinDeadline } from './push.js'; import type { PushSender } from './push.js'; -import { MAX_PUSH_ENDPOINT_LENGTH, isPublicHttpsPushEndpoint } from './push-endpoint.js'; +import { isPublicHttpsPushEndpoint } from './push-endpoint.js'; import { isSetupPassword } from './setup-password.js'; /** Runtime configuration; see `index.ts` for how env maps onto this. */ @@ -228,18 +226,8 @@ export const BURROW_ENROLL_ATTEMPT_BURST = 8; /** Sustained Burrow-enrollment admission: one attempt per second. */ export const BURROW_ENROLL_ATTEMPT_REFILL_MS = 1_000; -/** - * The one route whose legitimate body outgrows {@link MAX_REQUEST_BODY_BYTES}: - * a fan-out of `MAX_PUSH_QUERY_DELIVERY_IDS` sealed envelopes, each already - * bounded by `MAX_SEALED_PUSH_LENGTH`. Derived from those two rather than - * written out, so tightening either tightens this with it; the per-recipient - * allowance covers the delivery id, the salt, and the JSON around them. - */ -const PUSH_SEND_RECIPIENT_OVERHEAD_BYTES = 256; -export const MAX_PUSH_SEND_BODY_BYTES = - MAX_PUSH_QUERY_DELIVERY_IDS * - (DELIVERY_ID_LENGTH + MAX_SEALED_PUSH_LENGTH + PUSH_SEND_RECIPIENT_OVERHEAD_BYTES) + - PUSH_SEND_RECIPIENT_OVERHEAD_BYTES; +// Shared with the Hosted Relay, which derives its send-body bound the same way. +export { MAX_PUSH_SEND_BODY_BYTES }; /** The one answer to an over-long body: 413, before any route has run. */ function tooLarge(c: Context): Response { @@ -979,8 +967,8 @@ export function createApp(config: AppConfig): CreatedApp { if ( !body || typeof body.burrowId !== 'string' || - !isDeliveryId(body.deliveryId) || - !isSubscriptionPayload(body.subscription) + !isPushDeliveryId(body.deliveryId) || + !isPushSubscriptionPayload(body.subscription) ) { return c.json({ error: 'malformed request' }, 400); } @@ -1027,7 +1015,7 @@ export function createApp(config: AppConfig): CreatedApp { deliveryIds.length > MAX_PUSH_QUERY_DELIVERY_IDS || // Every id is bounded here, as it is at subscribe: `readJson` caps // nothing, and a value no Burrow ever minted cannot match a row anyway. - deliveryIds.some((id) => !isDeliveryId(id)) + deliveryIds.some((id) => !isPushDeliveryId(id)) ) { return c.json( { error: `deliveryIds must be 1..${MAX_PUSH_QUERY_DELIVERY_IDS} delivery ids` }, @@ -1059,7 +1047,7 @@ export function createApp(config: AppConfig): CreatedApp { // have minted names no row, so refusing it early only avoids reading the // file for a value that cannot match. const deliveryId = c.req.param('deliveryId'); - if (isDeliveryId(deliveryId)) await pushStore.removeDelivery(deliveryId); + if (isPushDeliveryId(deliveryId)) await pushStore.removeDelivery(deliveryId); return c.body(null, 204); }); @@ -1395,59 +1383,3 @@ function pocketCacheControl(requestPath: string): string { function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } - -/** - * Base64url of exactly {@link DELIVERY_ID_LENGTH} characters — the Burrow mints - * 32 random bytes, so anything else is not an id any Burrow ever issued and must - * be refused before it becomes a row key. - */ -function isDeliveryId(value: unknown): value is string { - return isExactBase64Url(value, DELIVERY_ID_LENGTH); -} - -/** - * One `{ deliveryId, sealed }` pair on a send. Shape and bounds are the whole - * of what the Relay can check — it holds no key — and the envelope's bound is - * its only defense against forwarding megabytes at a phone. - */ -function isSealedPushRecipient(value: unknown): value is SealedPushRecipient { - if (!value || typeof value !== 'object') return false; - const v = value as SealedPushRecipient; - return isDeliveryId(v.deliveryId) && isSealedPushV1(v.sealed); -} - -/** - * Longest `keys.p256dh` / `keys.auth` this Relay will store. RFC 8291 fixes - * both: `p256dh` is an uncompressed P-256 point (65 bytes) and `auth` is the - * 16-byte auth secret, so the caps are their base64 encodings *with* padding — - * browsers emit unpadded base64url, and a padded serialization must not be the - * thing that breaks a real subscription. - * - * **Every stored field is bounded.** These two plus - * {@link MAX_PUSH_ENDPOINT_LENGTH} are the whole row, and a durable row of - * unknown size is re-read and re-parsed by every push route - * (`docs/specs/relay.md` -> State files). - */ -const MAX_PUSH_KEY_P256DH_LENGTH = 88; -const MAX_PUSH_KEY_AUTH_LENGTH = 24; - -/** - * True if `value` is a `PushSubscriptionPayload` with both encryption keys, - * each of a length RFC 8291 could actually have produced. Non-empty, because a - * blank key is a row `web-push` can never encrypt to. - */ -function isSubscriptionPayload(value: unknown): value is PushSubscriptionPayload { - if (!value || typeof value !== 'object') return false; - const v = value as PushSubscriptionPayload; - return ( - isBoundedNonEmptyString(v.endpoint, MAX_PUSH_ENDPOINT_LENGTH) && - !!v.keys && - typeof v.keys === 'object' && - isBoundedNonEmptyString(v.keys.p256dh, MAX_PUSH_KEY_P256DH_LENGTH) && - isBoundedNonEmptyString(v.keys.auth, MAX_PUSH_KEY_AUTH_LENGTH) - ); -} - -function isBoundedNonEmptyString(value: unknown, max: number): value is string { - return typeof value === 'string' && value.length > 0 && value.length <= max; -} diff --git a/relay/src/push-endpoint.ts b/relay/src/push-endpoint.ts index 085873465..87dd8bc10 100644 --- a/relay/src/push-endpoint.ts +++ b/relay/src/push-endpoint.ts @@ -14,6 +14,8 @@ import type { LookupAddress, LookupOptions } from 'node:dns'; import { Agent } from 'node:https'; import { BlockList, isIP, type LookupFunction } from 'node:net'; +import { MAX_PUSH_ENDPOINT_LENGTH } from 'remote-lib-common'; + // Separate lists matter: Node's BlockList maps an IPv4 address into // `::ffff:0:0/96` even when `check(..., 'ipv4')` is requested, so combining the // families would make the mapped-address deny range reject every IPv4 address. @@ -85,15 +87,8 @@ export function isPublicNetworkAddress(address: string): boolean { return false; } -/** - * Longest push endpoint this Relay will store. A real one is a provider URL a - * couple of hundred characters long (FCM, APNs and Mozilla autopush all sit - * well under this), so the cap is several times the headroom any of them needs - * — and it is what keeps a stored row a known size, since every push route - * re-reads and re-parses the whole file - * (`docs/specs/relay.md` -> State files). - */ -export const MAX_PUSH_ENDPOINT_LENGTH = 1024; +/** Shared with the Hosted Relay (`docs/specs/relay.md` -> State files). */ +export { MAX_PUSH_ENDPOINT_LENGTH }; /** * Cheap admission check. Hostnames are revalidated through DNS at connection diff --git a/relay/src/push.ts b/relay/src/push.ts index 6c43f0fd1..1528e8e09 100644 --- a/relay/src/push.ts +++ b/relay/src/push.ts @@ -22,7 +22,12 @@ import { createECDH, timingSafeEqual } from 'node:crypto'; import webpush from 'web-push'; -import { normalizeOrigin } from 'remote-lib-common'; +import { + PUSH_TTL_SECONDS, + defaultVapidSubject, + isLoopbackVapidSubject, + normalizeOrigin, +} from 'remote-lib-common'; import type { StoredPushSubscription } from './state.js'; import { @@ -53,58 +58,11 @@ export interface VapidKeys { } /** - * Hosts a push service will not accept in a VAPID subject. Apple answers - * `403 {"reason":"BadJwtToken"}` for a loopback subject — verified against - * `web.push.apple.com` for both `mailto:admin@localhost` and - * `https://localhost:3000`, while `mailto:admin@example.com` and an ordinary - * https origin were accepted. Apple does not check that the contact is - * *reachable*, only that it is not loopback. + * The VAPID subject this Relay signs with when `DORMOUSE_VAPID_SUBJECT` is + * unset: its https origin, or `null` when there is none push can use. Shared + * with the Hosted Relay (`remote-lib-common/src/remote/web-push.ts`). */ -const LOOPBACK_SUBJECT_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); - -/** The host a subject names: the domain half for `mailto:`, the hostname otherwise. */ -function subjectHost(subject: URL): string { - if (subject.protocol === 'mailto:') { - const at = subject.pathname.lastIndexOf('@'); - return at === -1 ? '' : subject.pathname.slice(at + 1).toLowerCase(); - } - return subject.hostname.toLowerCase(); -} - -function isLoopbackSubjectHost(host: string): boolean { - if (!host) return false; - if (LOOPBACK_SUBJECT_HOSTS.has(host)) return true; - // RFC 6761 reserves the whole `.localhost` TLD for loopback. - if (host.endsWith('.localhost')) return true; - return /^127\./.test(host); -} - -/** - * The `mailto:`/`https:` operator contact to sign VAPID JWTs with (RFC 8292) - * when `DORMOUSE_VAPID_SUBJECT` is unset, or `null` when this deployment has no - * usable one and push must stay off. - * - * The Relay's own origin is the right zero-config answer: it is a real contact - * for whoever runs this Relay, and every deployment that can serve Pocket at - * all already has a valid https origin, because WebAuthn requires one. A - * loopback dev server has no such contact — and could not reach a phone anyway, - * since the phone cannot route to it. Returning `null` there disables push - * rather than inventing a placeholder contact that a push service may reject, - * which is the failure this default exists to prevent: the previous default - * (`mailto:admin@localhost`) let the Relay boot clean, answer 200 on send, and - * silently deliver nothing to any iPhone. - */ -export function defaultVapidSubject(origin: string): string | null { - let parsed: URL; - try { - parsed = new URL(origin); - } catch { - return null; - } - if (parsed.protocol !== 'https:') return null; - if (isLoopbackSubjectHost(parsed.hostname.toLowerCase())) return null; - return parsed.origin; -} +export { defaultVapidSubject }; /** Generate a VAPID keypair in the exact encoding the sender expects. */ export function generateVapidKeys(): VapidKeys { @@ -158,7 +116,7 @@ export function assertVapidSubject(subject: string): void { if (parsed.protocol !== 'mailto:' && parsed.protocol !== 'https:') { throw new Error('VAPID subject must be a valid mailto: or https: URL.'); } - if (isLoopbackSubjectHost(subjectHost(parsed))) { + if (isLoopbackVapidSubject(subject)) { throw new Error( 'VAPID subject must not name a loopback host — Apple rejects such a JWT with ' + 'BadJwtToken, so every push to an iPhone would fail. Use a routable contact, ' + @@ -179,13 +137,11 @@ function decodeVapidKey(value: string, name: 'public' | 'private', length: numbe } /** - * Real delivery through `web-push`. TTL is deliberately short: an alarm that - * arrives an hour late is noise, not information, so a push service holding one - * for an offline phone should drop it rather than deliver it stale. The + * Real delivery through `web-push`, at the shared `PUSH_TTL_SECONDS`. The * request timeout is a separate bound on socket inactivity while talking to * the push service. */ -export const PUSH_TTL_SECONDS = 300; +export { PUSH_TTL_SECONDS }; export const PUSH_REQUEST_TIMEOUT_MS = 10_000; // The third bound of the trio, `PUSH_SEND_DEADLINE_MS`, lives in diff --git a/relay/src/state.ts b/relay/src/state.ts index 698e219dc..60be4dd1f 100644 --- a/relay/src/state.ts +++ b/relay/src/state.ts @@ -7,6 +7,8 @@ import { join } from 'node:path'; import { E2E_ID_BYTE_LENGTH, MAX_ENROLLED_BURROWS, + MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT, + MAX_PUSH_SUBSCRIPTIONS_PER_BURROW, RELAY_BEARER_BYTE_LENGTH, SELFHOST_ACCOUNT_ID, isE2eId, @@ -438,7 +440,7 @@ export class BurrowStore extends JsonFileStore { * lookup costs a `stat` (plus a `readFile` + `JSON.parse` whenever the file * changed) + two SHA-256 per row — so a probe * that cannot possibly be a token this Relay minted must not buy any of it. - * The same reasoning `isDeliveryId` applies at the push routes. + * The same reasoning `isPushDeliveryId` applies at the push routes. */ async findByToken(burrowToken: string): Promise { // The one shape a `burrowToken` has, required at every lookup the way @@ -670,9 +672,9 @@ export class PushSubscriptionStore extends JsonFileStore { * * **Not scoped to an account**, and correct only because selfhost has exactly * one (`SELFHOST_ACCOUNT_ID`, which `docs/specs/security-remote.md` -> "Trust boundary" pins). A delivery id is - * unguessable, so possession is the authorization — but multi-tenant would - * still have to key the delete on the calling account, since a leaked id - * would otherwise reach across tenants (`docs/specs/relay.md` `## Future`). + * unguessable, so possession is the authorization — but the multi-tenant + * Hosted Relay keys the delete on the calling account, since a leaked id + * would otherwise reach across tenants (`docs/specs/hosted.md` -> "Relay"). */ removeDelivery(deliveryId: string): Promise { return this.mutate(async () => { @@ -705,20 +707,12 @@ export class PushSubscriptionStore extends JsonFileStore { } /** - * How many subscription rows one Burrow, and the whole file, may hold. - * - * `POST /api/push/subscribe` needs a session token and a `deliveryId` the - * caller picks for itself — the Relay cannot check one against a Burrow's ACL, - * by design — so without a cap one signed-in caller appends a durable row per - * request, and every push route thereafter re-reads and re-parses the file. - * Every sibling transient store is capped (`MAX_PENDING_REAUTH_NONCES_PER_SESSION`, - * `MAX_TOKENS_PER_BURROW`); this is the durable one, so it matters more. - * - * Far above any real use: the per-Burrow cap is phones paired with one laptop, - * the total is that across every laptop an account enrolled. + * The subscription caps (`MAX_PUSH_SUBSCRIPTIONS_PER_BURROW` and + * `MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT` in `remote-lib-common`). This Relay has + * one account, so the per-account cap bounds the whole file. */ -export const MAX_PUSH_SUBSCRIPTIONS_PER_BURROW = 32; -export const MAX_PUSH_SUBSCRIPTIONS_TOTAL = 256; +export { MAX_PUSH_SUBSCRIPTIONS_PER_BURROW }; +export const MAX_PUSH_SUBSCRIPTIONS_TOTAL = MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT; /** * Drop the oldest rows until both caps hold, never `keep` — the row this diff --git a/remote-lib-common/src/index.ts b/remote-lib-common/src/index.ts index 5c253fa9e..ccaeb7d22 100644 --- a/remote-lib-common/src/index.ts +++ b/remote-lib-common/src/index.ts @@ -20,6 +20,7 @@ export * from './remote/origin.js'; export * from './remote/enroll-code.js'; export * from './remote/relay-common.js'; export * from './remote/relay-routing.js'; +export * from './remote/web-push.js'; export * from './security/webcrypto.js'; export * from './security/bytes.js'; export * from './security/ecdsa.js'; diff --git a/remote-lib-common/src/remote/relay-common.ts b/remote-lib-common/src/remote/relay-common.ts index 95b066836..c7b625174 100644 --- a/remote-lib-common/src/remote/relay-common.ts +++ b/remote-lib-common/src/remote/relay-common.ts @@ -8,9 +8,11 @@ * (`docs/specs/relay.md`, `docs/specs/hosted.md` -> "Relay"). */ +import { DELIVERY_ID_LENGTH } from '../security/acl.js'; import { fromBase64Url, isBoundedBase64Url, + isExactBase64Url, toBase64Url, utf8Decode, } from '../security/bytes.js'; @@ -22,6 +24,7 @@ import { type PasskeyAssertion, } from '../security/passkey.js'; import { boundedPushText } from '../security/push.js'; +import { MAX_SEALED_PUSH_LENGTH, isSealedPushV1 } from '../security/push-seal.js'; import { getWebCrypto } from '../security/webcrypto.js'; import { E2E_ID_LENGTH, @@ -37,6 +40,9 @@ import { UNKNOWN_CHALLENGE_ERROR, UNKNOWN_CREDENTIAL_ERROR, assertionRejectedError, + MAX_PUSH_QUERY_DELIVERY_IDS, + type PushSubscriptionPayload, + type SealedPushRecipient, } from './wire.js'; /** The bearer shape lives in the wire contract; re-exported for the Relays. */ @@ -152,6 +158,107 @@ export const MAX_PASSKEY_LABEL_LENGTH = 64; */ export const MAX_REQUEST_BODY_BYTES = 64 * 1024; +// --------------------------------------------------------------------------- +// Web Push bounds (`docs/specs/relay.md` -> "Web Push" and "State files"). + +/** + * How many push subscriptions one Burrow, and one account, may hold. + * + * `POST /api/push/subscribe` needs a session token and a `deliveryId` the + * caller picks for itself — the Relay cannot check one against a Burrow's ACL, + * by design — so without a cap one signed-in caller appends a durable row per + * request, and every push route thereafter reads it. Every sibling transient + * store is capped (`MAX_PENDING_REAUTH_NONCES_PER_SESSION`, + * `MAX_TOKENS_PER_BURROW`); this is the durable one, so it matters more. + * + * Far above any real use: the per-Burrow cap is phones paired with one laptop, + * the per-account cap that across every laptop an account enrolled. A + * self-host Relay has one account, so its per-account cap is its total. + */ +export const MAX_PUSH_SUBSCRIPTIONS_PER_BURROW = 32; +export const MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT = 256; + +/** + * Longest push endpoint a Relay will store. A real one is a provider URL a + * couple of hundred characters long (FCM, APNs and Mozilla autopush all sit + * well under this), so the cap is several times the headroom any of them needs + * — and it is what keeps a stored row a known size. + */ +export const MAX_PUSH_ENDPOINT_LENGTH = 1024; + +/** + * Longest `keys.p256dh` / `keys.auth` a Relay will store. RFC 8291 fixes + * both: `p256dh` is an uncompressed P-256 point (65 bytes) and `auth` is the + * 16-byte auth secret, so the caps are their base64 encodings *with* padding — + * browsers emit unpadded base64url, and a padded serialization must not be the + * thing that breaks a real subscription. + */ +export const MAX_PUSH_KEY_P256DH_LENGTH = 88; +export const MAX_PUSH_KEY_AUTH_LENGTH = 24; + +/** + * The push service's TTL on every delivery. Short on purpose: an alarm that + * arrives an hour late is noise, not information, so a push service holding + * one for an offline phone should drop it rather than deliver it stale. + */ +export const PUSH_TTL_SECONDS = 300; + +/** + * The one route whose legitimate body outgrows {@link MAX_REQUEST_BODY_BYTES}: + * `/api/push/send`, a fan-out of `MAX_PUSH_QUERY_DELIVERY_IDS` sealed + * envelopes, each already bounded by `MAX_SEALED_PUSH_LENGTH`. Derived from + * those two rather than written out, so tightening either tightens this with + * it; the per-recipient allowance covers the delivery id, the salt, and the + * JSON around them. + */ +const PUSH_SEND_RECIPIENT_OVERHEAD_BYTES = 256; +export const MAX_PUSH_SEND_BODY_BYTES = + MAX_PUSH_QUERY_DELIVERY_IDS * + (DELIVERY_ID_LENGTH + MAX_SEALED_PUSH_LENGTH + PUSH_SEND_RECIPIENT_OVERHEAD_BYTES) + + PUSH_SEND_RECIPIENT_OVERHEAD_BYTES; + +/** + * Base64url of exactly {@link DELIVERY_ID_LENGTH} characters — the Burrow mints + * 32 random bytes, so anything else is not an id any Burrow ever issued and must + * be refused before it becomes a row key. + */ +export function isPushDeliveryId(value: unknown): value is string { + return isExactBase64Url(value, DELIVERY_ID_LENGTH); +} + +/** + * True if `value` is a `PushSubscriptionPayload` with both encryption keys, + * each of a length RFC 8291 could actually have produced. Non-empty, because a + * blank key is a row no sender can ever encrypt to. **Every stored field is + * bounded**: these three are the whole row. + */ +export function isPushSubscriptionPayload(value: unknown): value is PushSubscriptionPayload { + if (!value || typeof value !== 'object') return false; + const v = value as PushSubscriptionPayload; + return ( + isBoundedNonEmptyString(v.endpoint, MAX_PUSH_ENDPOINT_LENGTH) && + !!v.keys && + typeof v.keys === 'object' && + isBoundedNonEmptyString(v.keys.p256dh, MAX_PUSH_KEY_P256DH_LENGTH) && + isBoundedNonEmptyString(v.keys.auth, MAX_PUSH_KEY_AUTH_LENGTH) + ); +} + +/** + * One `{ deliveryId, sealed }` pair on a send. Shape and bounds are the whole + * of what a Relay can check — it holds no key — and the envelope's bound is + * its only defense against forwarding megabytes at a phone. + */ +export function isSealedPushRecipient(value: unknown): value is SealedPushRecipient { + if (!value || typeof value !== 'object') return false; + const v = value as SealedPushRecipient; + return isPushDeliveryId(v.deliveryId) && isSealedPushV1(v.sealed); +} + +function isBoundedNonEmptyString(value: unknown, max: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= max; +} + /** * A registration's label as a Relay stores it: reduced rather than refused, so * a long device name still registers, through the same `boundedPushText` the diff --git a/remote-lib-common/src/remote/web-push.ts b/remote-lib-common/src/remote/web-push.ts new file mode 100644 index 000000000..70308364d --- /dev/null +++ b/remote-lib-common/src/remote/web-push.ts @@ -0,0 +1,413 @@ +/** + * A Web Push request built on WebCrypto alone, for a Relay that has no Node + * `crypto` (`docs/specs/hosted.md` -> "Relay"): RFC 8291 `aes128gcm` message + * encryption and an RFC 8292 VAPID `ES256` JWT. The self-host Relay sends + * through `web-push` instead (`relay/src/push.ts`); both carry the same sealed + * envelope, which this module treats as opaque bytes. + * + * Pinned to the RFC 8291 Appendix A vector byte for byte + * (`remote-lib-common/test/web-push.test.mjs`): a subtly wrong HKDF `info` + * produces a push the phone silently fails to decrypt, with no error anywhere + * the Relay can see. + */ + +import { + concatBytes, + fromBase64Url, + isExactBase64Url, + toBase64Url, + utf8Encode, + writeUint32BE, +} from '../security/bytes.js'; +import { + type CryptoKeyLike, + type CryptoKeyPairLike, + type WebCryptoLike, + getWebCrypto, +} from '../security/webcrypto.js'; + +/** A subscription's encryption keys as the browser serializes them. */ +export interface WebPushKeys { + /** The user agent's uncompressed P-256 public key. */ + readonly p256dh: string; + /** The 16-byte authentication secret. */ + readonly auth: string; +} + +/** A VAPID keypair: the uncompressed P-256 point and the scalar, unpadded base64url. */ +export interface VapidKeys { + readonly publicKey: string; + readonly privateKey: string; +} + +/** The one record's size the header declares (RFC 8188); one record carries the whole message. */ +export const WEB_PUSH_RECORD_SIZE = 4096; + +const SALT_LENGTH = 16; +const P256_POINT_LENGTH = 65; +const P256_SCALAR_LENGTH = 32; +const AUTH_SECRET_LENGTH = 16; +const GCM_TAG_LENGTH = 16; +/** `salt || rs || idlen || keyid`, the keyid being the sender's public key. */ +const HEADER_LENGTH = SALT_LENGTH + 4 + 1 + P256_POINT_LENGTH; +/** RFC 8188's delimiter for the last (here, only) record, before no padding. */ +const LAST_RECORD_DELIMITER = new Uint8Array([2]); + +/** + * Longest plaintext one message carries: the whole body, header included, + * within one 4096-octet record, which every push service accepts (RFC 8291 + * section 4). + */ +export const MAX_WEB_PUSH_PLAINTEXT_LENGTH = + WEB_PUSH_RECORD_SIZE - HEADER_LENGTH - LAST_RECORD_DELIMITER.length - GCM_TAG_LENGTH; + +/** A VAPID JWT's lifetime; RFC 8292 caps `exp` at 24 hours ahead. */ +export const VAPID_JWT_LIFETIME_S = 12 * 60 * 60; + +const nul = new Uint8Array([0]); +const KEY_INFO_PREFIX = concatBytes(utf8Encode('WebPush: info'), nul); +const CEK_INFO = concatBytes(utf8Encode('Content-Encoding: aes128gcm'), nul); +const NONCE_INFO = concatBytes(utf8Encode('Content-Encoding: nonce'), nul); + +const ECDH = { name: 'ECDH', namedCurve: 'P-256' }; +const ECDSA = { name: 'ECDSA', namedCurve: 'P-256' }; +const ES256 = { name: 'ECDSA', hash: 'SHA-256' }; + +/** A P-256 private key as JWK, the one import format WebCrypto offers for a bare scalar. */ +interface P256PrivateJwk { + readonly kty: 'EC'; + readonly crv: 'P-256'; + readonly d: string; + readonly x: string; + readonly y: string; +} + +/** The WebCrypto calls this module makes beyond `SubtleCryptoLike`. */ +interface WebPushSubtle { + importKey( + format: 'raw', + keyData: Uint8Array, + algorithm: object, + extractable: boolean, + usages: readonly string[], + ): Promise; + importKey( + format: 'jwk', + keyData: P256PrivateJwk, + algorithm: object, + extractable: boolean, + usages: readonly string[], + ): Promise; + generateKey( + algorithm: object, + extractable: boolean, + usages: readonly string[], + ): Promise; + exportKey(format: 'raw', key: CryptoKeyLike): Promise; + deriveBits(algorithm: object, baseKey: CryptoKeyLike, length: number): Promise; + encrypt(algorithm: object, key: CryptoKeyLike, data: Uint8Array): Promise; + sign(algorithm: object, key: CryptoKeyLike, data: Uint8Array): Promise; + verify( + algorithm: object, + key: CryptoKeyLike, + signature: Uint8Array, + data: Uint8Array, + ): Promise; +} + +const subtleOf = (crypto: WebCryptoLike) => crypto.subtle as unknown as WebPushSubtle; + +/** A raw P-256 keypair, the scalar and its uncompressed point. */ +export interface RawP256KeyPair { + readonly privateKey: Uint8Array; + readonly publicKey: Uint8Array; +} + +export interface WebPushEncryptOptions { + readonly crypto?: WebCryptoLike; + /** + * The test hook in this module: supply the salt and the sender's ephemeral + * keypair instead of generating them, so a published vector can be + * replayed. Production callers never pass either. + */ + readonly salt?: Uint8Array; + readonly senderKeyPair?: RawP256KeyPair; +} + +/** + * Encrypts `plaintext` to one subscription as an RFC 8291 `aes128gcm` body: + * ECDH with a fresh sender key, HKDF over the subscription's auth secret and + * both public keys, then one AES-128-GCM record under a fresh salt. Throws on + * malformed subscription keys or a plaintext past + * {@link MAX_WEB_PUSH_PLAINTEXT_LENGTH}. + */ +export async function encryptWebPush( + plaintext: Uint8Array, + keys: WebPushKeys, + options: WebPushEncryptOptions = {}, +): Promise { + const crypto = options.crypto ?? getWebCrypto(); + const subtle = subtleOf(crypto); + const uaPublic = decodePushKey(keys.p256dh, P256_POINT_LENGTH, 'p256dh'); + if (uaPublic[0] !== 4) throw new Error('p256dh is not an uncompressed P-256 point'); + const authSecret = decodePushKey(keys.auth, AUTH_SECRET_LENGTH, 'auth'); + if (plaintext.length > MAX_WEB_PUSH_PLAINTEXT_LENGTH) { + throw new Error(`push payload exceeds ${MAX_WEB_PUSH_PLAINTEXT_LENGTH} bytes`); + } + const salt = options.salt ?? crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); + if (salt.length !== SALT_LENGTH) throw new Error('salt must be 16 bytes'); + + let senderPrivate: CryptoKeyLike; + let senderPublic: Uint8Array; + if (options.senderKeyPair) { + senderPrivate = await importP256Private(subtle, options.senderKeyPair, ECDH, ['deriveBits']); + senderPublic = options.senderKeyPair.publicKey; + } else { + const pair = await subtle.generateKey(ECDH, false, ['deriveBits']); + senderPrivate = pair.privateKey; + senderPublic = new Uint8Array(await subtle.exportKey('raw', pair.publicKey)); + } + + const uaKey = await subtle.importKey('raw', uaPublic, ECDH, false, []); + const ecdhSecret = new Uint8Array( + await subtle.deriveBits({ ...ECDH, public: uaKey }, senderPrivate, 256), + ); + const keyInfo = concatBytes(KEY_INFO_PREFIX, uaPublic, senderPublic); + const ikm = await hkdf(subtle, authSecret, ecdhSecret, keyInfo, 32); + const cek = await hkdf(subtle, salt, ikm, CEK_INFO, 16); + const nonce = await hkdf(subtle, salt, ikm, NONCE_INFO, 12); + + const aesKey = await subtle.importKey('raw', cek, { name: 'AES-GCM' }, false, ['encrypt']); + const record = new Uint8Array( + await subtle.encrypt( + { name: 'AES-GCM', iv: nonce, tagLength: GCM_TAG_LENGTH * 8 }, + aesKey, + concatBytes(plaintext, LAST_RECORD_DELIMITER), + ), + ); + const header = new Uint8Array(HEADER_LENGTH); + header.set(salt, 0); + writeUint32BE(header, SALT_LENGTH, WEB_PUSH_RECORD_SIZE); + header[SALT_LENGTH + 4] = P256_POINT_LENGTH; + header.set(senderPublic, SALT_LENGTH + 5); + return concatBytes(header, record); +} + +/** Signs VAPID JWTs with one validated keypair. */ +export interface VapidSigner { + /** The public key, as `GET /api/push/config` and the `k=` parameter carry it. */ + readonly publicKey: string; + /** + * The `Authorization` header for a push to `endpoint`: `vapid t=, + * k=`, the JWT's `aud` the endpoint's origin, `sub` the subject, + * and `exp` {@link VAPID_JWT_LIFETIME_S} after `nowMs`. + */ + authorization(endpoint: string, subject: string, nowMs: number): Promise; +} + +/** + * A signer for `keys`, or `null` when they are malformed or are not one + * keypair: the private scalar must sign what the public point verifies, so + * a mismatched pair turns push off rather than signing JWTs no push service + * accepts. + */ +export async function vapidSigner( + keys: VapidKeys, + crypto: WebCryptoLike = getWebCrypto(), +): Promise { + if ( + !isExactBase64Url(keys.publicKey, 87) || + !isExactBase64Url(keys.privateKey, 43) + ) { + return null; + } + const publicKey = fromBase64Url(keys.publicKey); + const privateScalar = fromBase64Url(keys.privateKey); + if (publicKey.length !== P256_POINT_LENGTH || publicKey[0] !== 4) return null; + if (privateScalar.length !== P256_SCALAR_LENGTH) return null; + const subtle = subtleOf(crypto); + let signingKey: CryptoKeyLike; + try { + signingKey = await importP256Private(subtle, { privateKey: privateScalar, publicKey }, ECDSA, [ + 'sign', + ]); + const verifyKey = await subtle.importKey('raw', publicKey, ECDSA, false, ['verify']); + // Node refuses a mismatched point at import; the probe holds the rule in a + // runtime that imports one anyway. + const probe = utf8Encode('dormouse/vapid/pair'); + const signature = new Uint8Array(await subtle.sign(ES256, signingKey, probe)); + if (!(await subtle.verify(ES256, verifyKey, signature, probe))) return null; + } catch { + return null; + } + return { + publicKey: keys.publicKey, + async authorization(endpoint, subject, nowMs) { + const header = base64UrlJson({ typ: 'JWT', alg: 'ES256' }); + const claims = base64UrlJson({ + aud: new URL(endpoint).origin, + exp: Math.floor(nowMs / 1000) + VAPID_JWT_LIFETIME_S, + sub: subject, + }); + const signingInput = `${header}.${claims}`; + // WebCrypto's ECDSA signature is already JWS's `r || s`. + const signature = new Uint8Array( + await subtle.sign(ES256, signingKey, utf8Encode(signingInput)), + ); + return `vapid t=${signingInput}.${toBase64Url(signature)}, k=${keys.publicKey}`; + }, + }; +} + +/** What a push service is sent for one delivery, before `fetch`. */ +export interface WebPushRequest { + readonly headers: Readonly>; + readonly body: Uint8Array; +} + +/** + * One push's headers and encrypted body: `aes128gcm`, the VAPID + * authorization, `TTL`, and high urgency, an alarm being worth waking for. + */ +export async function webPushRequest( + target: { readonly endpoint: string; readonly keys: WebPushKeys }, + payload: Uint8Array, + { + signer, + subject, + ttlSeconds, + nowMs, + ...encrypt + }: WebPushEncryptOptions & { + readonly signer: VapidSigner; + readonly subject: string; + readonly ttlSeconds: number; + readonly nowMs: number; + }, +): Promise { + const body = await encryptWebPush(payload, target.keys, encrypt); + return { + headers: { + authorization: await signer.authorization(target.endpoint, subject, nowMs), + 'content-encoding': 'aes128gcm', + 'content-type': 'application/octet-stream', + ttl: String(ttlSeconds), + urgency: 'high', + }, + body, + }; +} + +/** + * Hosts a push service will not accept in a VAPID subject. Apple answers + * `403 {"reason":"BadJwtToken"}` for a loopback subject — verified against + * `web.push.apple.com` for both `mailto:admin@localhost` and + * `https://localhost:3000`, while `mailto:admin@example.com` and an ordinary + * https origin were accepted. Apple does not check that the contact is + * *reachable*, only that it is not loopback. + */ +const LOOPBACK_SUBJECT_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * Whether `subject` names a loopback host: the domain half of a `mailto:`, + * the hostname otherwise. An unparsable subject names none. + */ +export function isLoopbackVapidSubject(subject: string): boolean { + let parsed: InstanceType; + try { + parsed = new URL(subject); + } catch { + return false; + } + let host: string; + if (parsed.protocol === 'mailto:') { + const at = parsed.pathname.lastIndexOf('@'); + host = at === -1 ? '' : parsed.pathname.slice(at + 1).toLowerCase(); + } else { + host = parsed.hostname.toLowerCase(); + } + if (!host) return false; + if (LOOPBACK_SUBJECT_HOSTS.has(host)) return true; + // RFC 6761 reserves the whole `.localhost` TLD for loopback. + if (host.endsWith('.localhost')) return true; + return /^127\./.test(host); +} + +/** + * The `https:` operator contact to sign VAPID JWTs with (RFC 8292) by + * default — the Relay's own origin — or `null` when this deployment has no + * usable one and push must stay off. + * + * Every deployment that can serve Pocket at all already has a valid https + * origin, because WebAuthn requires one. A loopback dev server has no such + * contact — and could not reach a phone anyway. Returning `null` there + * disables push rather than inventing a placeholder contact that a push + * service may reject, which is the failure this default exists to prevent: a + * Relay that answers 200 on send and silently delivers nothing to any iPhone. + */ +export function defaultVapidSubject(origin: string): string | null { + let parsed: InstanceType; + try { + parsed = new URL(origin); + } catch { + return null; + } + if (parsed.protocol !== 'https:') return null; + if (isLoopbackVapidSubject(parsed.origin)) return null; + return parsed.origin; +} + +/** + * A subscription key as browsers serialize it: base64url, or base64 with + * padding, decoding to exactly `length` bytes. + */ +function decodePushKey(value: string, length: number, name: string): Uint8Array { + let decoded: Uint8Array; + try { + decoded = fromBase64Url(value.replace(/\+/g, '-').replace(/\//g, '_')); + } catch { + throw new Error(`${name} is not base64url`); + } + if (decoded.length !== length) throw new Error(`${name} must decode to ${length} bytes`); + return decoded; +} + +/** A P-256 scalar and its point as a WebCrypto private key for `algorithm`. */ +function importP256Private( + subtle: WebPushSubtle, + { privateKey, publicKey }: RawP256KeyPair, + algorithm: object, + usages: readonly string[], +): Promise { + return subtle.importKey( + 'jwk', + { + kty: 'EC', + crv: 'P-256', + d: toBase64Url(privateKey), + x: toBase64Url(publicKey.subarray(1, 33)), + y: toBase64Url(publicKey.subarray(33, 65)), + }, + algorithm, + false, + usages, + ); +} + +/** RFC 5869 HKDF-SHA-256: extract under `salt`, expand `info` to `length` bytes. */ +async function hkdf( + subtle: WebPushSubtle, + salt: Uint8Array, + ikm: Uint8Array, + info: Uint8Array, + length: number, +): Promise { + const key = await subtle.importKey('raw', ikm, { name: 'HKDF' }, false, ['deriveBits']); + return new Uint8Array( + await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, key, length * 8), + ); +} + +function base64UrlJson(value: object): string { + return toBase64Url(utf8Encode(JSON.stringify(value))); +} diff --git a/remote-lib-common/test/vectors/README.md b/remote-lib-common/test/vectors/README.md index ff830d446..a7bb801c9 100644 --- a/remote-lib-common/test/vectors/README.md +++ b/remote-lib-common/test/vectors/README.md @@ -1,4 +1,6 @@ -# Vendored Noise test vectors +# Vendored test vectors + +## Noise `noise-ik-25519-chachapoly-sha256.json` is the single `Noise_IK_25519_ChaChaPoly_SHA256` entry lifted verbatim from the `vectors` @@ -20,3 +22,13 @@ responder. `handshake_hash` is the value `Split` reports. The RFC 7748 section 6.1 X25519 vector and the RFC 8439 section 2.8.2 ChaCha20-Poly1305 vector are inline fixtures in `../noise.test.mjs`. + +## RFC 8291 Web Push vector + +`rfc8291-appendix-a.json` holds the example of RFC 8291 section 5 and the +intermediate values of its Appendix A, copied from the RFC text with the line +wrapping removed; every field is base64url, as the RFC prints it. `body` is the +section 5 message, which is `header` followed by `ciphertext`. It is what proves +`src/remote/web-push.ts` byte-for-byte conformant, so nothing here may be +regenerated from our own sender. RFCs are published under the IETF Trust's +Legal Provisions, which permit reproducing code components such as these. diff --git a/remote-lib-common/test/vectors/rfc8291-appendix-a.json b/remote-lib-common/test/vectors/rfc8291-appendix-a.json new file mode 100644 index 000000000..c1979c60d --- /dev/null +++ b/remote-lib-common/test/vectors/rfc8291-appendix-a.json @@ -0,0 +1,16 @@ +{ + "source": "RFC 8291 section 5 and Appendix A", + "plaintext": "V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24", + "as_public": "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8", + "as_private": "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw", + "ua_public": "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", + "ua_private": "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94", + "salt": "DGv6ra1nlYgDCS1FRnbzlw", + "auth_secret": "BTBZMqHH6r4Tts7J_aSIgg", + "ecdh_secret": "kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs", + "cek": "oIhVW04MRdy2XN9CiKLxTg", + "nonce": "4h_95klXJ5E_qnoN", + "header": "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8", + "ciphertext": "8pfeW0KbunFT06SuDKoJH9Ql87S1QUrdirN6GcG7sFz1y1sqLgVi1VhjVkHsUoEsbI_0LpXMuGvnzQ", + "body": "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN" +} diff --git a/remote-lib-common/test/web-push.test.mjs b/remote-lib-common/test/web-push.test.mjs new file mode 100644 index 000000000..5d95a3b96 --- /dev/null +++ b/remote-lib-common/test/web-push.test.mjs @@ -0,0 +1,234 @@ +/** + * The WebCrypto Web Push sender (`src/remote/web-push.ts`; docs/specs/hosted.md + * -> "Relay"). + * + * Every expected value comes from an independent source: the vendored RFC 8291 + * Appendix A vector, Node's own `crypto` (ECDH, HKDF, AES-128-GCM) decrypting + * what the sender produced, and WebCrypto verifying the VAPID signature against + * a key Node generated — never from the implementation under test. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createDecipheriv, createECDH, generateKeyPairSync, hkdfSync } from 'node:crypto'; +import { readFileSync } from 'node:fs'; + +import { + MAX_WEB_PUSH_PLAINTEXT_LENGTH, + VAPID_JWT_LIFETIME_S, + WEB_PUSH_RECORD_SIZE, + defaultVapidSubject, + encryptWebPush, + generateNoiseKeyPair, + isLoopbackVapidSubject, + openPush, + sealPush, + toBase64Url, + utf8Encode, + vapidSigner, + webPushRequest, +} from '../dist/index.js'; + +const vector = JSON.parse( + readFileSync(new URL('./vectors/rfc8291-appendix-a.json', import.meta.url), 'utf8'), +); +const b64u = (text) => new Uint8Array(Buffer.from(text, 'base64url')); + +test('encryption reproduces the RFC 8291 Appendix A message byte for byte', async () => { + const body = await encryptWebPush( + b64u(vector.plaintext), + { p256dh: vector.ua_public, auth: vector.auth_secret }, + { + salt: b64u(vector.salt), + senderKeyPair: { privateKey: b64u(vector.as_private), publicKey: b64u(vector.as_public) }, + }, + ); + assert.equal(toBase64Url(body), vector.body); +}); + +test('padded and standard-alphabet subscription keys encrypt the same message', async () => { + // Browsers emit unpadded base64url; a padded or `+`/`/` serialization is the + // same key, and the registration bounds admit it. + const standard = (text) => Buffer.from(text, 'base64url').toString('base64'); + const body = await encryptWebPush( + b64u(vector.plaintext), + { p256dh: standard(vector.ua_public), auth: standard(vector.auth_secret) }, + { + salt: b64u(vector.salt), + senderKeyPair: { privateKey: b64u(vector.as_private), publicKey: b64u(vector.as_public) }, + }, + ); + assert.equal(toBase64Url(body), vector.body); +}); + +/** A subscription as a browser holds it: a P-256 keypair and an auth secret, from Node. */ +function browserSubscription() { + const ecdh = createECDH('prime256v1'); + ecdh.generateKeys(); + const auth = new Uint8Array(16); + crypto.getRandomValues(auth); + return { + ecdh, + keys: { p256dh: ecdh.getPublicKey().toString('base64url'), auth: toBase64Url(auth) }, + }; +} + +/** + * RFC 8291 decryption written against Node's `crypto`, not the code under + * test: the header's salt and sender key, ECDH, two HKDF steps, AES-128-GCM, + * and the last-record delimiter. + */ +function decrypt(body, { ecdh, keys }) { + const bytes = Buffer.from(body); + const salt = bytes.subarray(0, 16); + const recordSize = bytes.readUInt32BE(16); + const idLength = bytes[20]; + const senderPublic = bytes.subarray(21, 21 + idLength); + const record = bytes.subarray(21 + idLength); + assert.equal(recordSize, 4096); + assert.equal(idLength, 65); + assert.ok(record.length <= recordSize); + const uaPublic = Buffer.from(keys.p256dh, 'base64url'); + const ecdhSecret = ecdh.computeSecret(senderPublic); + const keyInfo = Buffer.concat([Buffer.from('WebPush: info\0'), uaPublic, senderPublic]); + const ikm = Buffer.from(hkdfSync('sha256', ecdhSecret, Buffer.from(keys.auth, 'base64url'), keyInfo, 32)); + const cek = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: aes128gcm\0'), 16)); + const nonce = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: nonce\0'), 12)); + const decipher = createDecipheriv('aes-128-gcm', cek, nonce); + decipher.setAuthTag(record.subarray(record.length - 16)); + const padded = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]); + assert.equal(padded.at(-1), 2, 'one record, ending in the last-record delimiter'); + return padded.subarray(0, -1); +} + +test('a sealed envelope survives the round trip through an independent decryptor', async () => { + const burrow = await generateNoiseKeyPair(); + const client = await generateNoiseKeyPair(); + const notification = utf8Encode(JSON.stringify({ title: 'build finished', body: 'zsh', tag: 'pty-1' })); + const sealed = await sealPush({ + burrowStaticPrivateKey: burrow.privateKey, + clientStaticPublicKey: client.publicKey, + plaintext: notification, + }); + const payload = utf8Encode(JSON.stringify({ burrowId: 'b'.repeat(22), ...sealed })); + + const subscription = browserSubscription(); + const first = await encryptWebPush(payload, subscription.keys); + const second = await encryptWebPush(payload, subscription.keys); + // A fresh salt and sender key per message. + assert.notDeepEqual(first.subarray(0, 86), second.subarray(0, 86)); + + const recovered = JSON.parse(decrypt(first, subscription).toString('utf8')); + assert.equal(recovered.burrowId, 'b'.repeat(22)); + const opened = await openPush({ + clientStaticPrivateKey: client.privateKey, + burrowStaticPublicKey: burrow.publicKey, + sealed: { v: recovered.v, salt: recovered.salt, ct: recovered.ct }, + }); + assert.deepEqual(opened, notification); +}); + +test('a payload past one record, or a malformed subscription key, is refused', async () => { + const { keys } = browserSubscription(); + assert.equal(MAX_WEB_PUSH_PLAINTEXT_LENGTH, WEB_PUSH_RECORD_SIZE - 86 - 1 - 16); + await encryptWebPush(new Uint8Array(MAX_WEB_PUSH_PLAINTEXT_LENGTH), keys); + await assert.rejects(encryptWebPush(new Uint8Array(MAX_WEB_PUSH_PLAINTEXT_LENGTH + 1), keys)); + const point = Buffer.from(keys.p256dh, 'base64url'); + for (const bad of [ + { ...keys, p256dh: point.subarray(1).toString('base64url') }, + { ...keys, p256dh: Buffer.concat([Buffer.from([3]), point.subarray(1)]).toString('base64url') }, + { ...keys, p256dh: 'not base64!' }, + { ...keys, auth: toBase64Url(new Uint8Array(15)) }, + ]) + await assert.rejects(encryptWebPush(new Uint8Array(8), bad), JSON.stringify(bad)); +}); + +/** A VAPID keypair from Node, in the encoding the Worker secrets hold. */ +function nodeVapidKeys() { + const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const jwk = privateKey.export({ format: 'jwk' }); + const point = Buffer.concat([Buffer.from([4]), Buffer.from(jwk.x, 'base64url'), Buffer.from(jwk.y, 'base64url')]); + return { publicKey: point.toString('base64url'), privateKey: jwk.d }; +} + +test('the VAPID JWT verifies under the public key and carries exactly aud, exp, and sub', async () => { + const keys = nodeVapidKeys(); + const signer = await vapidSigner(keys); + assert.ok(signer); + assert.equal(signer.publicKey, keys.publicKey); + const now = Date.UTC(2026, 9, 1, 12, 0, 0); + const endpoint = 'https://fcm.googleapis.com/fcm/send/abc:def?x=1'; + const authorization = await signer.authorization(endpoint, 'https://relay.example.test', now); + + const match = /^vapid t=([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+), k=([A-Za-z0-9_-]+)$/.exec( + authorization, + ); + assert.ok(match, authorization); + const [, header, claims, signature, k] = match; + assert.equal(k, keys.publicKey); + assert.deepEqual(JSON.parse(Buffer.from(header, 'base64url')), { typ: 'JWT', alg: 'ES256' }); + assert.deepEqual(JSON.parse(Buffer.from(claims, 'base64url')), { + aud: 'https://fcm.googleapis.com', + exp: now / 1000 + VAPID_JWT_LIFETIME_S, + sub: 'https://relay.example.test', + }); + assert.ok(VAPID_JWT_LIFETIME_S <= 24 * 60 * 60); + + const verifyKey = await crypto.subtle.importKey( + 'raw', + b64u(keys.publicKey), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'], + ); + const verify = (data) => + crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, verifyKey, b64u(signature), utf8Encode(data)); + assert.equal(await verify(`${header}.${claims}`), true); + assert.equal(await verify(`${header}.${claims}x`), false); +}); + +test('a malformed or mismatched VAPID pair yields no signer', async () => { + const keys = nodeVapidKeys(); + const other = nodeVapidKeys(); + assert.equal(await vapidSigner({ publicKey: keys.publicKey, privateKey: other.privateKey }), null); + assert.equal(await vapidSigner({ publicKey: `${keys.publicKey}=`, privateKey: keys.privateKey }), null); + assert.equal(await vapidSigner({ publicKey: keys.publicKey, privateKey: '' }), null); + assert.equal(await vapidSigner({ publicKey: '', privateKey: keys.privateKey }), null); + assert.equal( + await vapidSigner({ publicKey: keys.publicKey, privateKey: toBase64Url(new Uint8Array(32)) }), + null, + ); + assert.ok(await vapidSigner(keys)); +}); + +test('a push request carries the encrypted body, the VAPID authorization, TTL, and urgency', async () => { + const signer = await vapidSigner(nodeVapidKeys()); + const subscription = browserSubscription(); + const endpoint = 'https://web.push.apple.com/QGuQyavXutnMH-5'; + const { headers, body } = await webPushRequest( + { endpoint, keys: subscription.keys }, + utf8Encode('{"v":1}'), + { signer, subject: 'https://relay.example.test', ttlSeconds: 300, nowMs: Date.now() }, + ); + assert.deepEqual(Object.keys(headers).sort(), [ + 'authorization', + 'content-encoding', + 'content-type', + 'ttl', + 'urgency', + ]); + assert.match(headers.authorization, /^vapid t=[^,]+, k=/); + assert.equal(headers['content-encoding'], 'aes128gcm'); + assert.equal(headers.ttl, '300'); + assert.equal(headers.urgency, 'high'); + assert.equal(decrypt(body, subscription).toString('utf8'), '{"v":1}'); +}); + +test('the default VAPID subject is an https origin that names no loopback host', () => { + assert.equal(defaultVapidSubject('https://relay.dormouse.sh'), 'https://relay.dormouse.sh'); + assert.equal(defaultVapidSubject('https://relay.dormouse.sh/'), 'https://relay.dormouse.sh'); + for (const origin of ['http://localhost:8787', 'https://localhost', 'https://127.0.0.1', 'https://a.localhost', 'nope']) + assert.equal(defaultVapidSubject(origin), null, origin); + assert.equal(isLoopbackVapidSubject('mailto:admin@localhost'), true); + assert.equal(isLoopbackVapidSubject('mailto:admin@example.com'), false); +}); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 753b7f880..12d357f40 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,7 +12,7 @@ "docs/specs/dor-tools-builtin.md": 1050, "docs/specs/dor-tools-lib.md": 400, "docs/specs/glossary.md": 3000, - "docs/specs/hosted.md": 4250, + "docs/specs/hosted.md": 4600, "docs/specs/layout.md": 11500, "docs/specs/mobile-terminal-ui.md": 2300, "docs/specs/mouse-and-clipboard.md": 3750, @@ -24,7 +24,7 @@ "docs/specs/remote-security-model.md": 5450, "docs/specs/security-audit.md": 2100, "docs/specs/security-ci.md": 2950, - "docs/specs/security-hosted.md": 2150, + "docs/specs/security-hosted.md": 2350, "docs/specs/security-local.md": 3850, "docs/specs/security-remote.md": 7050, "docs/specs/security-supply-chain.md": 1250, From 284d7a8abd055b339ecae1d82564c2e7be83f7c5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 1 Oct 2026 01:33:44 -0700 Subject: [PATCH 2/4] Harden the Hosted push sender and its routes Review fixes: subscription keys checked by what they decode to, no database connection held across the push fan-out, a refusal body read to at most 1 KiB, lock-ordered timestamps, subscribe serialized against Burrow removal, one VAPID signature per origin, the VAPID smoke, and the e2e lint admitting AES-GCM in the Web Push sender alone. Co-Authored-By: Claude Opus 5.5 --- .github/audit/hosted.md | 2 +- docs/specs/hosted.md | 14 +- docs/specs/hosted.rationale.md | 2 +- docs/specs/relay.md | 12 +- docs/specs/security-hosted.md | 2 +- docs/specs/security-remote.md | 7 +- hosted/README.md | 7 +- hosted/scripts/preview-smoke.mjs | 24 ++- hosted/scripts/preview.mjs | 27 +--- hosted/scripts/preview.test.mjs | 20 ++- hosted/scripts/production.test.mjs | 11 +- hosted/scripts/vapid.mjs | 23 +++ hosted/server/relay-api.ts | 15 +- hosted/server/relay-auth.ts | 75 ++++++--- hosted/server/relay-push.ts | 152 ++++++++++++------- hosted/server/tests/bundle.ts | 16 +- hosted/server/tests/relay-push.test.ts | 57 ++++++- hosted/server/tests/relay.test.ts | 135 ++++++++++++++++ relay/test/push.test.mjs | 32 +++- remote-lib-common/src/remote/relay-common.ts | 13 +- remote-lib-common/src/remote/web-push.ts | 60 +++++--- remote-lib-common/test/relay-common.test.mjs | 31 ++++ remote-lib-common/test/web-push.test.mjs | 12 +- scripts/e2e-lint-selftest.mjs | 6 + scripts/e2e-lint.mjs | 20 ++- scripts/spec-word-budgets.json | 4 +- 26 files changed, 585 insertions(+), 194 deletions(-) create mode 100644 hosted/scripts/vapid.mjs diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 0732db336..c3d8b932e 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -134,7 +134,7 @@ Be adversarial, and go past the `FAIL IF` list. Ask specifically: delete another account's rows, even holding its `deliveryId`, and an upsert's endpoint rotation, its caps, and the 404/410 prune must stay inside the account. Every fetch must go to an endpoint `knownPushEndpoint` admits, - follow no redirect, and read only a bounded reason. Check the sender against + follow no redirect, and keep at most 1 KiB of a refusal's body. Check the sender against RFC 8291 and RFC 8292 yourself: the test's expected bytes must come from the RFC, not the code, and a JWT's `aud` must be the endpoint's origin. Look for an endpoint string that parses to an allowlisted host in one place and diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 0cf086491..e01c9d1fa 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -122,14 +122,14 @@ A session-gated route answers a session of an account no longer entitled with th **Push.** The push routes keep `docs/specs/relay.md` -> "Web Push" and its "State files" upsert rules; a send is HTTPS from the Burrow to the relay Worker, independent of terminal transport. - **Must keep subscriptions in Postgres** (`hosted/server/dormouse-migrations/003_relay_push.sql`), keyed `(burrowId, deliveryId)`, every field bounded as self-host bounds it, deleted with their Burrow. **Must read the addresses a delivery moves off, drop rows, and prune 404/410 among the account's rows only.** -- **Must cap subscriptions at `MAX_PUSH_SUBSCRIPTIONS_PER_BURROW` and `MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT`** in place of self-host's file total, evicting the oldest `subscribedAt`, never the row just written, in the upsert's transaction under the account's advisory lock (rationale). -- **Must register and fetch only a known Web Push service's endpoint** (`knownPushEndpoint`) in place of the self-host DNS guard: `https:` on the default port, no credentials, at most `MAX_PUSH_ENDPOINT_LENGTH`, and host `fcm.googleapis.com` or `updates.push.services.mozilla.com`, or one under `.push.apple.com` or `.notify.windows.com` (rationale). **Never follow a redirect**: a 3xx is `failed`. A refusal's log reads at most 1 KiB of its body. -- **Must send through `webPushRequest`** (`remote-lib-common/src/remote/web-push.ts`), WebCrypto with no `web-push`: one RFC 8291 `aes128gcm` record and an RFC 8292 `ES256` VAPID JWT, `aud` the endpoint's origin, `exp` `VAPID_JWT_LIFETIME_S` (12 hours) ahead, `sub` the relay's `APP_ORIGIN`. The route bounds each delivery by `PUSH_SEND_DEADLINE_MS`, aborting its fetch. +- **Must cap subscriptions at `MAX_PUSH_SUBSCRIPTIONS_PER_BURROW` and `MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT`** in place of self-host's file total, evicting the oldest `subscribedAt`, never the row just written, in the upsert's transaction under the account's advisory lock (rationale). **Must stamp `subscribedAt` and every capped expiry with `clock_timestamp()`**, never `now()`, so a write that waited on its lock sorts after the one it followed. **Must share-lock the Burrow row**, so a subscribe racing its removal answers 404. +- **Must register and fetch only a known Web Push service's endpoint** (`knownPushEndpoint`) in place of the self-host DNS guard: `https:` on the default port, no credentials, at most `MAX_PUSH_ENDPOINT_LENGTH`, and host `fcm.googleapis.com` or `updates.push.services.mozilla.com`, or one under `.push.apple.com` or `.notify.windows.com` (rationale). **Never follow a redirect**: a 3xx is `failed`. A refusal's log keeps at most 1 KiB of its body, copying no chunk past it, and cancels the rest. +- **Must send through `webPushRequest`** (`remote-lib-common/src/remote/web-push.ts`), WebCrypto with no `web-push`: one RFC 8291 `aes128gcm` record and an RFC 8292 `ES256` VAPID JWT, `aud` the endpoint's origin, `exp` `VAPID_JWT_LIFETIME_S` (12 hours) ahead, `sub` the relay's `APP_ORIGIN`, signed once per origin per send (`vapidAuthorizations`). The route bounds each delivery by `PUSH_SEND_DEADLINE_MS`, aborting its fetch. **Never hold a Postgres connection across a push service's fetch**: read the targets (`readAsBurrow`), release, fan out, then reconnect only to prune. - **Push is disabled, not half-working**: without both `RELAY_VAPID_PUBLIC_KEY` and `RELAY_VAPID_PRIVATE_KEY`, with a private key that does not sign for its public point, or without a subject (`defaultVapidSubject`: an https, non-loopback `APP_ORIGIN`), the config route answers `null` and subscribe and send 503. The pair is a relay Worker secret; previews derive theirs ("PR previews"), and the dev loop has none. **Pocket at the root.** `build` stages `lib/dist-pocket` at the root of the relay's assets and the one-time page beside it, checking both shells. Pocket is served per `docs/specs/pocket-app.md` -> "Serving the built bundle", except that a path naming no file (no extension, outside `/diagnostics`) gets the shell in one asset fetch. -Source of truth: `relayApiRoutes` / `sweepExpired` in `hosted/server/relay-api.ts`; `sessionByToken` / `burrowByToken` / `requireSession` / `requireBurrow` / `locked` in `hosted/server/relay-auth.ts`; `hosted/server/dormouse-migrations/002_relay.sql`; `relayPushRoutes` / `upsertSubscription` / `knownPushEndpoint` / `deliverPush` / `pushConfigOf` in `hosted/server/relay-push.ts`; `hosted/server/dormouse-migrations/003_relay_push.sql`; `vapidSigner` / `encryptWebPush` in `remote-lib-common/src/remote/web-push.ts`; `triggers` and `ratelimits` in `hosted/wrangler.relay.jsonc`; `pocketRoutes` in `hosted/server/pocket.ts`; `relayRules` / `relayPathKind` in `hosted/server/headers.ts`; `stageRelay` in `hosted/scripts/stage-relay.mjs`; `checkRegistration` / `verifySigninAssertion` and the push bounds in `remote-lib-common/src/remote/relay-common.ts`. Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, `hosted/scripts/stage-relay.test.mjs`, `remote-lib-common/test/relay-common.test.mjs`, and `remote-lib-common/test/web-push.test.mjs` (the RFC 8291 Appendix A vector). +Source of truth: `relayApiRoutes` / `sweepExpired` in `hosted/server/relay-api.ts`; `sessionByToken` / `burrowByToken` / `requireSession` / `requireBurrow` / `readAsBurrow` / `locked` in `hosted/server/relay-auth.ts`; `hosted/server/dormouse-migrations/002_relay.sql`; `relayPushRoutes` / `upsertSubscription` / `knownPushEndpoint` / `deliverPush` / `reasonOf` / `vapidAuthorizations` / `pushConfigOf` in `hosted/server/relay-push.ts`; `hosted/server/dormouse-migrations/003_relay_push.sql`; `vapidSigner` / `encryptWebPush` in `remote-lib-common/src/remote/web-push.ts`; `triggers` and `ratelimits` in `hosted/wrangler.relay.jsonc`; `pocketRoutes` in `hosted/server/pocket.ts`; `relayRules` / `relayPathKind` in `hosted/server/headers.ts`; `stageRelay` in `hosted/scripts/stage-relay.mjs`; `checkRegistration` / `verifySigninAssertion` and the push bounds in `remote-lib-common/src/remote/relay-common.ts`. Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, `hosted/scripts/stage-relay.test.mjs`, `remote-lib-common/test/relay-common.test.mjs`, and `remote-lib-common/test/web-push.test.mjs` (the RFC 8291 Appendix A vector). ## Relay sockets @@ -208,15 +208,15 @@ Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workf ## Production releases -**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's pinned name and origin and lone custom domain, and the relay's `ACCOUNT_ORIGIN` to the account's origin; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`, the relay's `RELAY_ENROLL_SECRET` and its VAPID pair). Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy relay, voice, then account, stopping at a failure; the relay must pass its revision check and `oneTimeSmoke` before the next deploy (rationale). Production has no public candidate URL. +**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's pinned name and origin and lone custom domain, and the relay's `ACCOUNT_ORIGIN` to the account's origin; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`, the relay's `RELAY_ENROLL_SECRET` and its VAPID pair) — names only, as Cloudflare exposes no value. Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy relay, voice, then account, stopping at a failure; the relay must pass its revision check, push config, and `oneTimeSmoke` before the next deploy (rationale). Production has no public candidate URL. -**Must only append Durable Object migrations**: a deployed tag is never edited or removed, and Cloudflare refuses a rollback across one, so each is a rollback floor. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay has its own `v1` (`OneTimeRoom`) and `v2` (`RelayRoom`). A deploy restarts every room, dropping links still waiting or mid-handshake, and every relay socket, which its end reconnects; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and runs `oneTimeSmoke` on the relay once the relay's revision check passes, whatever the account's outcome; a failed smoke reports every failed part. +**Must only append Durable Object migrations**: a deployed tag is never edited or removed, and Cloudflare refuses a rollback across one, so each is a rollback floor. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay has its own `v1` (`OneTimeRoom`) and `v2` (`RelayRoom`). A deploy restarts every room, dropping links still waiting or mid-handshake, and every relay socket, which its end reconnects; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and, once the relay's passes, requires its `/api/push/config` to answer a key (`pushConfigSmoke`: both VAPID secrets set, as one pair) and runs `oneTimeSmoke` on it, whatever the account's outcome; a failed smoke reports every failed part. **Must bound retries.** Health GETs require the selected revision, sharing six five-second retries for transport failures or healthy stale revisions. Retry rate-limited OAuth once; never replay POSTs after transport failures. Production repeats only the relay and voice smokes, up to six times 10 s apart (rationale). **Must record an immutable annotated hosted/YYYY-MM-DD tag only after live verification.** Tags identify the deployed commit and verification run/attempt; retries are idempotent and redeployments get new tags. Dating and repeat-deployment suffixes: `recordDeployment`. Code rollback never reverses migrations. -Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / `verifyPackages` / `preflight` / `deployProduction` / `productionSmoke` in `hosted/scripts/production.mjs`; `WORKERS` / `deployWorkers` in `hosted/scripts/workers.mjs`; `hosted/scripts/production-backup.mjs`; `smokeRequest` / `healthSmoke` / `relaySmoke` / `smokeAll` in `hosted/scripts/preview-smoke.mjs`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs`; `recordDeployment` in `hosted/scripts/production-tag.mjs`. Pinned by `hosted/scripts/production.test.mjs`, `hosted/scripts/smoke-request.test.mjs`, and `hosted/scripts/production-tag.test.mjs`. +Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / `verifyPackages` / `preflight` / `productionSmoke` in `hosted/scripts/production.mjs`; `WORKERS` / `deployWorkers` in `hosted/scripts/workers.mjs`; `hosted/scripts/production-backup.mjs`; `smokeRequest` / `healthSmoke` / `pushConfigSmoke` / `smokeAll` in `hosted/scripts/preview-smoke.mjs`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs`; `recordDeployment` in `hosted/scripts/production-tag.mjs`. Pinned by `hosted/scripts/production.test.mjs`, `hosted/scripts/smoke-request.test.mjs`, and `hosted/scripts/production-tag.test.mjs`. ## Future diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 31a9a7ef3..c2ce005e2 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -44,7 +44,7 @@ Push (2026-10-01): - Per-account cap (`MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT`, 256) in place of self-host's total: a self-host Relay is one account, so its total already was a per-account bound, and the same 256 keeps the two Relays' ceilings equal. A global cap across accounts would let one account's subscribe loop evict every other account's phones; keyed by the account, a caller only ever evicts its own. 256 is eight laptops' worth at the per-Burrow cap, far above the phones a person pairs, and the per-Burrow cap still stops one Burrow from holding them all. - Endpoint allowlist instead of the DNS guard: a Worker's `fetch` resolves and connects inside Cloudflare's network, so the Relay can neither see nor pin the address a hostname resolves to, which is the whole of the self-host guard. Cloudflare's egress cannot reach a customer tailnet either way, so the risk left is the Worker as a blind POST relay at an arbitrary public host, carrying a VAPID JWT for that host. Every browser Pocket runs in subscribes at one of four services: Chrome and Android at FCM (`fcm.googleapis.com`), Firefox at autopush (`updates.push.services.mozilla.com`), Safari at APNs, which Apple documents as `https://*.push.apple.com`, and Edge on Windows at WNS (`*.notify.windows.com`). A browser that adds a service needs a line here before it can register, which is the intended failure. A redirect is failed rather than followed, so a push service's answer cannot steer the request off the allowlist. -- A repeated recipient is sent once: Workers Free allows 50 subrequests per invocation, and with each `deliveryId` sent at most once a send makes at most `MAX_PUSH_SUBSCRIPTIONS_PER_BURROW` (32) fetches, whatever `recipients` holds, plus its database connection. The Burrow names each ACL record once, so only a malformed send repeats one. +- A repeated recipient is sent once: Workers Free allows 50 subrequests per invocation, and with each `deliveryId` sent at most once a send makes at most `MAX_PUSH_SUBSCRIPTIONS_PER_BURROW` (32) fetches, whatever `recipients` holds, plus its two database connections (the read and the prune). The Burrow names each ACL record once, so only a malformed send repeats one. - Preview VAPID pairs derive from the preview secret and the Worker's name, as its other secrets do, so a PR's subscriptions survive redeploys and no production key reaches a preview. ## Relay sockets diff --git a/docs/specs/relay.md b/docs/specs/relay.md index e18a09621..1577e2cd2 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -273,17 +273,19 @@ stale row rather than leave one per rotation: or Client delete. * **Every stored field is bounded and the row count is capped** — the one *durable* store a session token can grow (rationale). `endpoint` at - `MAX_PUSH_ENDPOINT_LENGTH` (1024) on admission; both `keys` at the base64 - lengths RFC 8291 fixes — `p256dh` an uncompressed P-256 point, `auth` the - 16-byte secret — each at its *padded* encoding, so a browser that pads still - registers. An upsert then caps the committed set at + `MAX_PUSH_ENDPOINT_LENGTH` (1024) on admission; both `keys` bounded at the + base64 lengths RFC 8291 fixes, each at its *padded* encoding so a browser + that pads still registers, and refused unless they decode to them — + `p256dh` an uncompressed P-256 point (`0x04`-led), `auth` the 16-byte + secret (`isWebPushKeys`). An upsert then caps the committed set at `MAX_PUSH_SUBSCRIPTIONS_PER_BURROW` (32) and `MAX_PUSH_SUBSCRIPTIONS_TOTAL` (256), **evicting the oldest `subscribedAt` first and never the row it just wrote**. Eviction covers every Burrow, so a hand-edited file over the cap converges on the next write. Source of truth: `relay/src/state.ts`; the caps and field bounds in -`remote-lib-common/src/remote/relay-common.ts`. +`remote-lib-common/src/remote/relay-common.ts`; `isWebPushKeys` in +`remote-lib-common/src/remote/web-push.ts`. ## WebAuthn without a WebAuthn library diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 02c309b0b..0434bfba1 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -55,7 +55,7 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. - **FAIL IF** an approval admits a request without a login, with an `Origin` other than the account's own exactly, from a login older than `LOGIN_FRESH_AGE_MS` or one whose creation time it cannot read, for an account but the entitled admin, or past the per-account attempt limit (`RELAY_APPROVE_LIMIT`, counted before the body is read). - **FAIL IF** a table a caller can grow has no cap keyed by whoever grows it (approvals: the per-account attempt limit), or a capped write deletes another key's rows; `signin/*` or `setup/begin`/`finish` reaches the database before its per-address limit; or a production `namespace_id` reaches `PREVIEW_RATELIMIT_OFFSET`. - **FAIL IF** the push send reads, logs, or forwards notification text, or forwards anything but `{ burrowId, v, salt, ct }` copied field by field with the `burrowId` from the Burrow token; a session-gated push route registers against, reports, or deletes a row outside the session's account, or reports a `deliveryId` the caller did not present; or a push upsert, cap, or prune touches another account's rows. Inspect `relayPushRoutes` / `upsertSubscription`. -- **FAIL IF** a push endpoint is registered or fetched that `knownPushEndpoint` does not admit (`https:`, default port, no credentials, a listed push service's host), or a delivery follows a redirect or reads more than a bounded reason of a response body; inspect `deliverPush`. +- **FAIL IF** a push endpoint is registered or fetched that `knownPushEndpoint` does not admit (`https:`, default port, no credentials, a listed push service's host), or a delivery follows a redirect or keeps more than 1 KiB of a response body; inspect `deliverPush` and `reasonOf`. - **FAIL IF** `RELAY_VAPID_PRIVATE_KEY` is anything but a relay Worker secret, a Wrangler `vars` entry or a preview config included, or push answers a key while the private key does not sign for it; inspect `pushConfigOf`, `vapidSigner` in `remote-lib-common/src/remote/web-push.ts`, and `hosted/scripts/preview.mjs`. - **FAIL IF** the WebCrypto sender stops reproducing the RFC 8291 Appendix A message byte for byte in `remote-lib-common/test/web-push.test.mjs`, or that test takes an expected value from the code under test. - **FAIL IF** the relay Worker reads a cookie, asks auth, or reads a user column but the entitlement's; inspect the relay bundle's imports. diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index ff694985b..e0dd299a4 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -102,9 +102,10 @@ per-Burrow browser storage follows `docs/specs/remote-security-model.md` -> `lib/src/remote/client/pocket-encrypted-storage.test.ts`. - **FAIL IF** AES-GCM appears in production source under `remote-lib-common/src/`, `lib/src/`, or `relay/src/` outside the local at-rest - wrapper `lib/src/remote/client/pocket-private-key.ts`. The wire cipher is - unchanged; `scripts/e2e-lint.mjs` and `scripts/e2e-lint-selftest.mjs` pin - this exception. + wrapper `lib/src/remote/client/pocket-private-key.ts` and the Web Push sender + `remote-lib-common/src/remote/web-push.ts`, whose `aes128gcm` record RFC 8291 + fixes. The wire cipher is unchanged; `scripts/e2e-lint.mjs` and + `scripts/e2e-lint-selftest.mjs` pin these exceptions. - **FAIL IF** `relay/src/state.ts` stops creating `$DORMOUSE_STATE_DIR` mode `0o700`, or stops writing every file through `writeAtomic` at mode `0o600`. The "every file" clause is a negative search over `relay/src/`: no `writeFile`, `appendFile`, or `createWriteStream` may target the state directory outside `writeAtomic`. A cheap default, not a cross-platform guarantee; the installer's directory permissions below protect the installed Relay's state (rationale). - **FAIL IF** `FileBurrowStateStore` (`lib/src/host/remote/burrow-state-store.ts`) stops creating its directory `0o700` and writing `0o600` on non-Windows platforms, or if `VsCodeBurrowStateStore` stops keeping the **enrollment** in `SecretStorage`. The ACL's home in `globalState` is deliberate and is not a finding; the enrollment's is what carries `burrowToken`. diff --git a/hosted/README.md b/hosted/README.md index ecae3eacf..d5c927b3a 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -356,8 +356,11 @@ rm vapid.json ``` The public half is public (`GET /api/push/config` serves it); the private half -signs every push. Push stays off, not half-working, until both are set and -match. Rotating the pair makes every phone's subscription stale until Pocket +signs every push. Both are required for a production deploy: preflight refuses +a relay Worker missing either. Cloudflare exposes a secret's name, never its +value, so preflight cannot tell whether the two match; the relay answers push +off for a pair that does not, and the release's live verification fails +unless `/api/push/config` answers the key. Rotating the pair makes every phone's subscription stale until Pocket re-registers it, so rotate only on compromise. Previews derive their own pair and never need this one. diff --git a/hosted/scripts/preview-smoke.mjs b/hosted/scripts/preview-smoke.mjs index edd8e80b8..949f34b2f 100644 --- a/hosted/scripts/preview-smoke.mjs +++ b/hosted/scripts/preview-smoke.mjs @@ -73,6 +73,22 @@ export async function healthSmoke(origin, sha, fetcher = fetch) { assert.deepEqual(await health.json(), { ok: true, revision: sha }); } +/** + * The relay answers a VAPID key from `/api/push/config`. Cloudflare exposes a + * Worker secret's name and never its value, so preflight sees only that both + * halves exist; a pair that does not match turns push off, and fails here. + */ +export async function pushConfigSmoke(origin, fetcher = fetch) { + const response = await smokeRequest(fetcher, origin + "/api/push/config", {}); + assert.equal(response.status, 200, `${origin} must answer its push config`); + const { applicationServerKey } = await response.json(); + assert.match( + applicationServerKey ?? "", + /^B[A-Za-z0-9_-]{86}$/, + `${origin} must answer a VAPID key: both relay secrets set, as one pair`, + ); +} + export async function smoke( origin, sha, @@ -276,9 +292,10 @@ async function retrying(limit, what, check, { retryMs, wait }) { } /** - * The relay's smoke: its revision, then its one-time rendezvous, each retried - * on its own up to `attempts`, `retryMs` apart, so a passed revision never - * runs again and the rendezvous never runs on a relay that did not pass. + * The relay's smoke: its revision, then its push config and its one-time + * rendezvous, each retried on its own up to `attempts`, `retryMs` apart, so a + * passed revision never runs again and neither later check runs on a relay that + * did not pass. */ export async function relaySmoke( origin, @@ -293,6 +310,7 @@ export async function relaySmoke( ) { const retry = { retryMs, wait }; await retrying(attempts, origin, () => healthSmoke(origin, sha, fetcher), retry); + await retrying(attempts, `${origin} push`, () => pushConfigSmoke(origin, fetcher), retry); await retrying(attempts, `${origin} one-time`, () => oneTime(origin), retry); } diff --git a/hosted/scripts/preview.mjs b/hosted/scripts/preview.mjs index 67cee0bcf..77b6ed0f0 100644 --- a/hosted/scripts/preview.mjs +++ b/hosted/scripts/preview.mjs @@ -1,7 +1,8 @@ -import { createECDH, createHmac } from "node:crypto"; +import { createHmac } from "node:crypto"; import { writeFile, mkdir, appendFile, rm } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { vapidKeysFrom } from "./vapid.mjs"; import { WORKERS, deployWorkers, fromStage, readConfigs } from "./workers.mjs"; export function required(env, name) { @@ -210,26 +211,14 @@ export function previewSecrets(configs, env) { /** * A preview's VAPID pair: the P-256 scalar is the HMAC of the preview secret * with `/vapid`, so it is stable across a PR's redeploys and its - * subscriptions survive them. A digest that is not a valid scalar (about one - * in 2^32) takes the next counter. + * subscriptions survive them. */ -export function previewVapidKeys(secret, name) { - for (let counter = 0; ; counter++) { - const scalar = createHmac("sha256", secret) +export const previewVapidKeys = (secret, name) => + vapidKeysFrom((counter) => + createHmac("sha256", secret) .update(`${name}/vapid${counter ? `/${counter}` : ""}`) - .digest(); - const ecdh = createECDH("prime256v1"); - try { - ecdh.setPrivateKey(scalar); - } catch { - continue; - } - return { - RELAY_VAPID_PUBLIC_KEY: ecdh.getPublicKey().toString("base64url"), - RELAY_VAPID_PRIVATE_KEY: scalar.toString("base64url"), - }; - } -} + .digest(), + ); /** Each Worker's origin in `configs`, keyed as `WORKERS` is. */ export const originsOf = (configs) => diff --git a/hosted/scripts/preview.test.mjs b/hosted/scripts/preview.test.mjs index 0f84f6e6a..754844519 100644 --- a/hosted/scripts/preview.test.mjs +++ b/hosted/scripts/preview.test.mjs @@ -386,13 +386,15 @@ test("deployment smoke rejects malformed health before making any auth requests" assert.equal(requests, 1); }); -test("the smoke runs its parts concurrently, retries each alone, and checks the rendezvous after the relay's revision alone", async () => { +test("the smoke runs its parts concurrently, retries each alone, and checks the push config and rendezvous after the relay's revision alone", async () => { const origins = { account: "https://account.example.test", relay: "https://relay.example.test", voice: "https://voice.example.test", }; const events = []; + // The relay's VAPID key, or null while push is off. + let pushKey = `B${"A".repeat(86)}`; // How many more health checks each origin fails before it is healthy. const unhealthy = {}; // Every request the production smoke makes, answered as a healthy account would. @@ -406,6 +408,11 @@ test("the smoke runs its parts concurrently, retries each alone, and checks the } return Response.json({ ok: true, revision: env.BUILD_SHA }); } + if (pathname === "/api/push/config") { + assert.equal(origin, origins.relay); + events.push("push"); + return Response.json({ applicationServerKey: pushKey }); + } assert.equal(origin, origins.account); if (pathname === "/api/ready") return new Response(null, { status: 200 }); if (pathname === "/api/auth/csrf") @@ -444,8 +451,19 @@ test("the smoke runs its parts concurrently, retries each alone, and checks the assert.equal(count(`${origins.voice} health`), 1); assert.equal(count(`${origins.relay} health`), 2); assert.equal(count("one-time"), 1); + assert.equal(count("push"), 1); + assert.ok(events.indexOf("push") > events.lastIndexOf(`${origins.relay} health`)); assert.equal(events.at(-1), "one-time"); + // A relay with push off — a VAPID secret missing, or a pair that does not + // match — fails the smoke: preflight can read only the secrets' names. + events.length = 0; + pushKey = null; + await assert.rejects(smokeAll(origins, env.BUILD_SHA, { fetcher, oneTime }), { + message: `${origins.relay} must answer a VAPID key: both relay secrets set, as one pair`, + }); + pushKey = `B${"A".repeat(86)}`; + // Per-part attempts: the account runs once while the relay and voice retry. events.length = 0; waits.length = 0; diff --git a/hosted/scripts/production.test.mjs b/hosted/scripts/production.test.mjs index de33f3771..78e7e5c2b 100644 --- a/hosted/scripts/production.test.mjs +++ b/hosted/scripts/production.test.mjs @@ -128,6 +128,10 @@ test("production smokes the relay before anything after it deploys, and a relay // The relay's health fails this many times before it passes. let unhealthy = 5; const fetcher = async (url) => { + if (url === "https://relay.dormouse.sh/api/push/config") { + events.push("relay push"); + return Response.json({ applicationServerKey: "B" + "A".repeat(86) }); + } assert.equal(url, "https://relay.dormouse.sh/api/health"); events.push("relay health"); return unhealthy-- > 0 @@ -150,6 +154,7 @@ test("production smokes the relay before anything after it deploys, and a relay assert.deepEqual(events, [ "deploy dormouse-relay", ...Array(6).fill("relay health"), + "relay push", "relay one-time", "deploy dormouse-voice", "deploy dormouse-hosted", @@ -165,7 +170,7 @@ test("production smokes the relay before anything after it deploys, and a relay ); // Six attempts, then nothing else deploys: the account's `v2` never runs. assert.equal(rendezvous, 6); - assert.deepEqual(events, ["deploy dormouse-relay", "relay health"]); + assert.deepEqual(events, ["deploy dormouse-relay", "relay health", "relay push"]); }); test("live verification retries the relay and voice while their domains come up, and runs the account's POSTs once", async () => { const health = {}; @@ -177,6 +182,10 @@ test("live verification retries the relay and voice while their domains come up, }; const fetcher = async (url) => { const { origin, pathname } = new URL(url); + if (pathname === "/api/push/config") { + assert.equal(origin, "https://relay.dormouse.sh"); + return Response.json({ applicationServerKey: `B${"A".repeat(86)}` }); + } assert.equal(pathname, "/api/health"); health[origin] = (health[origin] ?? 0) + 1; return health[origin] > failing[origin] diff --git a/hosted/scripts/vapid.mjs b/hosted/scripts/vapid.mjs new file mode 100644 index 000000000..e01609db5 --- /dev/null +++ b/hosted/scripts/vapid.mjs @@ -0,0 +1,23 @@ +import { createECDH } from "node:crypto"; + +/** + * A VAPID pair as the relay's two secrets hold it, from the first scalar + * `scalarFor(0)`, `scalarFor(1)`, … that is a valid P-256 private key (a + * digest is not, about one time in 2^32): the uncompressed point and the + * scalar, unpadded base64url. + */ +export function vapidKeysFrom(scalarFor) { + for (let counter = 0; ; counter++) { + const scalar = scalarFor(counter); + const ecdh = createECDH("prime256v1"); + try { + ecdh.setPrivateKey(scalar); + } catch { + continue; + } + return { + RELAY_VAPID_PUBLIC_KEY: ecdh.getPublicKey().toString("base64url"), + RELAY_VAPID_PRIVATE_KEY: scalar.toString("base64url"), + }; + } +} diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index 52e3c0eec..6ca0c22b8 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -88,7 +88,7 @@ export const ENROLLMENT_POLL_INTERVAL_S = 5; const DEVICE_CODE_EXPIRY_BYTES = 4; /** A table a caller grows, capped per `owner` value. */ -interface Capped { +export interface Capped { table: string; pk: string; owner: string; @@ -107,7 +107,7 @@ const SETUP_CHALLENGES: Capped = { owner: '"burrowId"', cap: MAX_SETUP_CHALLENGES_PER_BURROW, }; -const SESSIONS: Capped = { +export const SESSIONS: Capped = { table: "dormouse_relay_sessions", pk: '"tokenHash"', owner: '"userId"', @@ -134,8 +134,13 @@ const EXPIRING_TABLES = [ const epochMs = (column: string) => `floor(extract(epoch from ${column}) * 1000)::float8`; -/** `now()` plus `$n` milliseconds. */ -const after = (param: string) => `now() + (${param}::float8 * interval '1 millisecond')`; +/** + * `clock_timestamp()` plus `$n` milliseconds. Not `now()`, the transaction's + * start: a capped insert takes its lock inside the transaction, so a write + * that waited would expire before the one it followed and be trimmed first. + */ +const after = (param: string) => + `clock_timestamp() + (${param}::float8 * interval '1 millisecond')`; /** 429 past `limit` per address, before anything reaches the database. */ const perAddress = @@ -621,7 +626,7 @@ async function consumeChallenge(db: Client, challenge: string, burrowId: string * row already expired is not inserted. Answers its expiry in epoch * milliseconds, or undefined when nothing was inserted. */ -async function admit( +export async function admit( db: Client, { table, pk, owner, cap }: Capped, ownerValue: string, diff --git a/hosted/server/relay-auth.ts b/hosted/server/relay-auth.ts index 8113f1750..2102c25fd 100644 --- a/hosted/server/relay-auth.ts +++ b/hosted/server/relay-auth.ts @@ -102,11 +102,11 @@ export type RelayHonoEnv = { Variables: { db: Client } & Var; }; -/** One connection per request, released before the response. */ -export function database( +/** One connection, released once `action` settles. */ +export function database( c: { env: Pick }, - action: (db: Client) => Promise, -): Promise { + action: (db: Client) => Promise, +): Promise { return withClient(c.env.HYPERDRIVE.connectionString, action); } @@ -128,42 +128,69 @@ export async function locked(db: Client, key: string, action: () => Promise c.json({ error: UNAUTHORIZED_ERROR }, 401); +/** How a credential gate finds its bearer's row and decides whether to admit it. */ +interface Gate { + lookup(db: Client, token: string): Promise; + admit(c: Context, found: Found): Response | null; +} + /** - * A credential gate as Hono middleware: a bearer of the minted shape (refused - * before any database read), resolved by `lookup` on the request's - * connection, and admitted by `admit` or answered with its response. + * Runs `action` on a connection once `gate` admits the request's bearer: a + * bearer of the minted shape (refused before any database read), resolved on + * that connection, and admitted or answered with the gate's response. */ +async function gated( + c: Context, + { lookup, admit }: Gate, + action: (db: Client, found: Found) => Promise, +): Promise { + const token = parseBearer(c.req.header("authorization")); + if (!isRelayBearer(token)) return unauthorized(c); + return database(c, async (db) => { + const found = await lookup(db, token); + if (!found) return unauthorized(c); + return admit(c, found) ?? action(db, found); + }); +} + +/** A credential gate as Hono middleware, the request's connection held through the route. */ function bearerGate( name: Name, - lookup: (db: Client, token: string) => Promise, - admit: (c: Context, found: Found) => Response | null, + gate: Gate, ): MiddlewareHandler>> { - return async (c, next) => { - const token = parseBearer(c.req.header("authorization")); - if (!isRelayBearer(token)) return unauthorized(c); - return database(c, async (db) => { - const found = await lookup(db, token); - if (!found) return unauthorized(c); - const refused = admit(c, found); - if (refused) return refused; + return (c, next) => + gated(c, gate, async (db, found) => { const vars = c as unknown as Context<{ Variables: Record }>; vars.set("db", db); vars.set(name, found); await next(); return c.res; }); - }; } /** * Session-gated routes: a de-entitled account's session is the same 401 as an * expired one, so Pocket returns to sign-in. */ -export const requireSession = bearerGate("session", sessionByToken, (c, session) => - session.entitled ? null : unauthorized(c), -); +export const requireSession = bearerGate("session", { + lookup: sessionByToken, + admit: (c, session) => (session.entitled ? null : unauthorized(c)), +}); /** Burrow-gated routes: an owner not entitled is a 403, rechecked per request. */ -export const requireBurrow = bearerGate("burrow", burrowByToken, (c, burrow) => - burrow.entitled ? null : c.json({ error: NOT_ENTITLED_ERROR }, 403), -); +const BURROW_GATE: Gate = { + lookup: burrowByToken, + admit: (c, burrow) => (burrow.entitled ? null : c.json({ error: NOT_ENTITLED_ERROR }, 403)), +}; +export const requireBurrow = bearerGate("burrow", BURROW_GATE); + +/** + * The Burrow gate for a route whose work outlasts its reads: `read` runs on + * the gate's connection, which is released before this answers, so what the + * route does next never holds it. Answers what `read` does, or the gate's + * refusal. + */ +export const readAsBurrow = ( + c: Context<{ Bindings: RelayEnv }>, + read: (db: Client, burrow: RelayBurrow) => Promise, +): Promise => gated(c, BURROW_GATE, read); diff --git a/hosted/server/relay-push.ts b/hosted/server/relay-push.ts index 11bf28e20..024003f00 100644 --- a/hosted/server/relay-push.ts +++ b/hosted/server/relay-push.ts @@ -8,6 +8,7 @@ import { MAX_PUSH_SUBSCRIPTIONS_PER_BURROW, PUSH_SEND_DEADLINE_MS, PUSH_TTL_SECONDS, + concatBytes, defaultVapidSubject, isPushDeliveryId, isPushSubscriptionPayload, @@ -31,7 +32,7 @@ import type { WebPushKeys, } from "remote-lib-common"; import type { RelayEnv } from "./bindings"; -import { locked, requireBurrow, requireSession, type Client } from "./relay-auth"; +import { database, locked, readAsBurrow, requireBurrow, requireSession, type Client } from "./relay-auth"; /** * The Web Push services a subscription may name, by exact host or by a @@ -48,8 +49,8 @@ export const PUSH_SERVICE_HOST_SUFFIXES: readonly string[] = [ ".notify.windows.com", ]; -/** Bytes of a refusal's body read for the log, and the characters kept of it. */ -const MAX_REASON_BYTES = 1024; +/** Bytes of a refusal's body kept for the log, and the characters logged of them. */ +export const MAX_REASON_BYTES = 1024; const MAX_LOGGED_REASON = 200; /** @@ -106,6 +107,26 @@ export async function pushConfigOf(env: RelayEnv): Promise { export type PushDeliveryResult = "delivered" | "expired" | "failed"; +/** A delivery's VAPID `Authorization` for a push service's endpoint. */ +export type Authorize = (endpoint: URL) => Promise; + +/** + * One send's VAPID authorizations: a JWT's `aud` is the push service's + * origin, so each origin's is signed once, on first use, and serves every + * endpoint there. + */ +export function vapidAuthorizations(push: PushConfig, nowMs = Date.now()): Authorize { + const byOrigin = new Map>(); + return (endpoint) => { + let authorization = byOrigin.get(endpoint.origin); + if (!authorization) { + authorization = push.signer.authorization(endpoint.href, push.subject, nowMs); + byOrigin.set(endpoint.origin, authorization); + } + return authorization; + }; +} + /** One stored subscription, as delivery needs it. */ export interface PushTarget { endpoint: string; @@ -121,7 +142,7 @@ export interface PushTarget { export async function deliverPush( target: PushTarget, payload: string, - push: PushConfig, + authorize: Authorize, { fetch: send = fetch, signal }: { fetch?: typeof fetch; signal?: AbortSignal } = {}, ): Promise { const url = knownPushEndpoint(target.endpoint); @@ -130,11 +151,10 @@ export async function deliverPush( return "failed"; } try { - const request = await webPushRequest( - { endpoint: url.href, keys: target.keys }, - utf8Encode(payload), - { signer: push.signer, subject: push.subject, ttlSeconds: PUSH_TTL_SECONDS, nowMs: Date.now() }, - ); + const request = await webPushRequest(target.keys, utf8Encode(payload), { + authorization: await authorize(url), + ttlSeconds: PUSH_TTL_SECONDS, + }); const response = await send(url.href, { method: "POST", headers: request.headers, @@ -188,30 +208,28 @@ export async function deliverWithinDeadline( } } -/** The push service's own explanation, read to {@link MAX_REASON_BYTES}, collapsed and clamped. */ -async function reasonOf(response: Response): Promise { +/** + * The push service's own explanation, collapsed and clamped: at most + * {@link MAX_REASON_BYTES} of its body kept, each chunk copied down to what + * still fits so a large one is never retained, and the rest cancelled. + */ +export async function reasonOf(response: Response): Promise { const reader = response.body?.getReader(); if (!reader) return ""; - const chunks: Uint8Array[] = []; - let read = 0; + const kept: Uint8Array[] = []; + let room = MAX_REASON_BYTES; try { - while (read < MAX_REASON_BYTES) { + while (room > 0) { const { done, value } = await reader.read(); if (done) break; - chunks.push(value); - read += value.length; + const take = value.slice(0, room); + kept.push(take); + room -= take.length; } } finally { await reader.cancel().catch(() => {}); } - const bytes = new Uint8Array(Math.min(read, MAX_REASON_BYTES)); - let offset = 0; - for (const chunk of chunks) { - const take = Math.min(chunk.length, bytes.length - offset); - bytes.set(chunk.subarray(0, take), offset); - offset += take; - } - return clamp(new TextDecoder().decode(bytes)); + return clamp(new TextDecoder().decode(concatBytes(...kept))); } function clamp(text: string): string { @@ -316,33 +334,41 @@ export function relayPushRoutes(app: Hono<{ Bindings: RelayEnv }>) { return c.json(res); }); - app.post(API_ROUTES.pushSend, requireBurrow, async (c) => { - const push = await pushConfigOf(c.env); - if (!push) return c.json({ error: "push is not configured" }, 503); - const recipients: unknown = (await readJson(c))?.recipients; - if ( - !Array.isArray(recipients) || - recipients.length === 0 || - recipients.length > MAX_PUSH_QUERY_DELIVERY_IDS || - !recipients.every(isSealedPushRecipient) - ) - return c.json( - { - error: - `recipients must be 1..${MAX_PUSH_QUERY_DELIVERY_IDS} ` + - "{ deliveryId, sealed } pairs", - }, - 400, + // Never holds a Postgres connection across a push service's fetch: the + // targets are read on the gate's connection, released before the fan-out, + // and one is reopened only to prune. + app.post(API_ROUTES.pushSend, async (c) => { + const read = await readAsBurrow(c, async (db, burrow) => { + const push = await pushConfigOf(c.env); + if (!push) return c.json({ error: "push is not configured" }, 503); + const recipients: unknown = (await readJson(c))?.recipients; + if ( + !Array.isArray(recipients) || + recipients.length === 0 || + recipients.length > MAX_PUSH_QUERY_DELIVERY_IDS || + !recipients.every(isSealedPushRecipient) + ) + return c.json( + { + error: + `recipients must be 1..${MAX_PUSH_QUERY_DELIVERY_IDS} ` + + "{ deliveryId, sealed } pairs", + }, + 400, + ); + const { rows } = await db.query<{ deliveryId: string; endpoint: string; p256dh: string; auth: string }>( + `SELECT s."deliveryId", s.endpoint, s.p256dh, s.auth + FROM dormouse_relay_push_subscriptions s + WHERE s."burrowId" = $1 AND s."vapidPublicKey" = $2 AND s."deliveryId" = ANY($3::text[])`, + [burrow.burrowId, push.signer.publicKey, recipients.map((recipient) => recipient.deliveryId)], ); + return { burrow, push, recipients, rows }; + }); + if (read instanceof Response) return read; + const { burrow, push, recipients, rows } = read; // The Burrow is its token's, never the body's. - const { burrowId } = c.var.burrow; - const { db } = c.var; - const { rows } = await db.query<{ deliveryId: string; endpoint: string; p256dh: string; auth: string }>( - `SELECT s."deliveryId", s.endpoint, s.p256dh, s.auth - FROM dormouse_relay_push_subscriptions s - WHERE s."burrowId" = $1 AND s."vapidPublicKey" = $2 AND s."deliveryId" = ANY($3::text[])`, - [burrowId, push.signer.publicKey, recipients.map((recipient) => recipient.deliveryId)], - ); + const { burrowId } = burrow; + const authorize = vapidAuthorizations(push); const byDelivery = new Map(rows.map((row) => [row.deliveryId, row])); // One fetch per subscription, so a send stays within the per-Burrow cap // of subrequests: a repeated recipient is not sent twice (rationale). @@ -367,7 +393,7 @@ export function relayPushRoutes(app: Hono<{ Bindings: RelayEnv }>) { salt: sealed.salt, ct: sealed.ct, } satisfies SealedPushPayload), - push, + authorize, { signal }, ), PUSH_SEND_DEADLINE_MS, @@ -378,10 +404,12 @@ export function relayPushRoutes(app: Hono<{ Bindings: RelayEnv }>) { // sending Burrow's account. const expired = results.filter((r) => r.result === "expired").map((r) => r.endpoint); if (expired.length > 0) - await db.query( - `DELETE FROM dormouse_relay_push_subscriptions s USING dormouse_relay_burrows b - WHERE b."burrowId" = s."burrowId" AND b."userId" = $1 AND s.endpoint = ANY($2::text[])`, - [c.var.burrow.userId, expired], + await database(c, (db) => + db.query( + `DELETE FROM dormouse_relay_push_subscriptions s USING dormouse_relay_burrows b + WHERE b."burrowId" = s."burrowId" AND b."userId" = $1 AND s.endpoint = ANY($2::text[])`, + [burrow.userId, expired], + ), ); const res: PushSendResponse = { delivered: results.filter((r) => r.result === "delivered").length, @@ -404,7 +432,10 @@ interface Subscription { /** * Stores `record` for `userId`'s Burrow, or answers null when the Burrow is - * not that account's. One transaction under the account's advisory lock: + * not that account's — or is being removed: the Burrow row is share-locked, + * so a concurrent removal either waits for this write and cascades it, or + * commits first and the Burrow is unknown. One transaction under the + * account's advisory lock: * * 1. Every address this delivery is moving off — read from the account's rows * carrying its `deliveryId`, whichever Burrow — has its rows dropped, @@ -423,10 +454,13 @@ export function upsertSubscription( ): Promise { return locked(db, `push:${userId}`, async () => { const { rowCount } = await db.query( - `SELECT 1 FROM dormouse_relay_burrows WHERE "burrowId" = $1 AND "userId" = $2`, + `SELECT 1 FROM dormouse_relay_burrows WHERE "burrowId" = $1 AND "userId" = $2 FOR KEY SHARE`, [record.burrowId, userId], ); if (!rowCount) return null; + // `clock_timestamp()`, not `now()`: `now()` is the transaction's start, + // before the lock, so a write that waited would sort older than the one it + // followed, and the caps would evict it first. const { rows: [{ subscribedAt }], } = await db.query<{ subscribedAt: number }>( @@ -443,11 +477,11 @@ export function upsertSubscription( AND NOT (s."burrowId" = $2 AND s."deliveryId" = $3) ) INSERT INTO dormouse_relay_push_subscriptions AS s - ("burrowId", "deliveryId", endpoint, p256dh, auth, "vapidPublicKey") - VALUES ($2, $3, $4, $5, $6, $7) + ("burrowId", "deliveryId", endpoint, p256dh, auth, "vapidPublicKey", "subscribedAt") + VALUES ($2, $3, $4, $5, $6, $7, clock_timestamp()) ON CONFLICT ("burrowId", "deliveryId") DO UPDATE SET endpoint = EXCLUDED.endpoint, p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth, - "vapidPublicKey" = EXCLUDED."vapidPublicKey", "subscribedAt" = now() + "vapidPublicKey" = EXCLUDED."vapidPublicKey", "subscribedAt" = EXCLUDED."subscribedAt" RETURNING ${SUBSCRIBED_AT_MS} AS "subscribedAt"`, [ userId, diff --git a/hosted/server/tests/bundle.ts b/hosted/server/tests/bundle.ts index 8b7a5ea1e..d3b96a29f 100644 --- a/hosted/server/tests/bundle.ts +++ b/hosted/server/tests/bundle.ts @@ -1,8 +1,9 @@ import { build } from "esbuild"; import { convertV4MiniflareOptions } from "miniflare"; -import { createECDH, createHash } from "node:crypto"; +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { builtinModules } from "node:module"; +import { vapidKeysFrom } from "../../scripts/vapid.mjs"; import { WORKERS, parseConfig } from "../../scripts/workers.mjs"; interface WranglerConfig { @@ -39,15 +40,10 @@ export const ORIGINS = each((config) => config.vars.APP_ORIGIN); /** The relay's `RELAY_ENROLL_SECRET` in every test; production's is a Worker secret. */ export const TEST_ENROLL_SECRET = "dormouse-hosted-test-enroll-secret"; /** A VAPID pair for tests, as the relay's two secrets hold one; `seed` names another. */ -export function testVapidKeys(seed = "dormouse-hosted-test-vapid") { - const scalar = createHash("sha256").update(seed).digest(); - const ecdh = createECDH("prime256v1"); - ecdh.setPrivateKey(scalar); - return { - RELAY_VAPID_PUBLIC_KEY: ecdh.getPublicKey().toString("base64url"), - RELAY_VAPID_PRIVATE_KEY: scalar.toString("base64url"), - }; -} +export const testVapidKeys = (seed = "dormouse-hosted-test-vapid") => + vapidKeysFrom((counter: number) => + createHash("sha256").update(counter ? `${seed}/${counter}` : seed).digest(), + ); /** Each Worker's production entry, from its config. */ export const ENTRIES = each((config) => config.main); diff --git a/hosted/server/tests/relay-push.test.ts b/hosted/server/tests/relay-push.test.ts index 93d47895f..86370e26d 100644 --- a/hosted/server/tests/relay-push.test.ts +++ b/hosted/server/tests/relay-push.test.ts @@ -5,8 +5,11 @@ import type { RelayEnv } from "../bindings"; import { deliverPush, deliverWithinDeadline, + MAX_REASON_BYTES, knownPushEndpoint, pushConfigOf, + reasonOf, + vapidAuthorizations, type PushConfig, } from "../relay-push"; import { NAMES, ORIGINS, testVapidKeys, wrangler } from "./bundle"; @@ -87,7 +90,7 @@ test("one delivery: 2xx delivered, 404 and 410 expired, a redirect or refusal or return response(); }) as unknown as typeof fetch; const outcome = (response: () => Response) => - deliverPush(target(), '{"v":1}', config, { fetch: answering(response) }); + deliverPush(target(), '{"v":1}', vapidAuthorizations(config), { fetch: answering(response) }); expect(await outcome(() => new Response(null, { status: 201 }))).toBe("delivered"); expect(await outcome(() => new Response(null, { status: 404 }))).toBe("expired"); expect(await outcome(() => new Response(null, { status: 410 }))).toBe("expired"); @@ -105,7 +108,7 @@ test("one delivery: 2xx delivered, 404 and 410 expired, a redirect or refusal or expect(logged.length).toBeLessThan(400); expect(logged).not.toContain("/fcm/send/abc"); expect( - await deliverPush(target(), "{}", config, { + await deliverPush(target(), "{}", vapidAuthorizations(config), { fetch: (async () => { throw new Error("connection reset"); }) as unknown as typeof fetch, @@ -130,7 +133,7 @@ test("an endpoint outside the allowlist is never fetched, whatever the row says" const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { expect( - await deliverPush(target("https://push.example.com/sub/abc"), "{}", await push(), { + await deliverPush(target("https://push.example.com/sub/abc"), "{}", vapidAuthorizations(await push()), { fetch: fetched as unknown as typeof fetch, }), ).toBe("failed"); @@ -166,9 +169,53 @@ test("a delivery past its deadline is failed and aborted; a throw is failed", as } }); +test("a refusal's reason keeps at most its bound of the body, however large a chunk, and cancels the rest", async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream({ + // One chunk far past the bound: blank up to it, a marker after. + pull(controller) { + pulls++; + controller.enqueue(new TextEncoder().encode(`${" ".repeat(MAX_REASON_BYTES)}beyond${"x".repeat(64 * 1024)}`)); + }, + cancel() { + cancelled = true; + }, + }); + expect(await reasonOf(new Response(body, { status: 500 }))).toBe(""); + expect(pulls).toBe(1); + expect(cancelled).toBe(true); + // Small chunks are kept up to the bound across reads. + const chunks = ['{"reason":', '"Overloaded"}']; + const small = new ReadableStream({ + pull(controller) { + const next = chunks.shift(); + if (next) controller.enqueue(new TextEncoder().encode(next)); + else controller.close(); + }, + }); + expect(await reasonOf(new Response(small, { status: 500 }))).toBe('{"reason":"Overloaded"}'); +}); + +test("a send's VAPID JWT is signed once per push-service origin", async () => { + const config = await push(); + const sign = vi.spyOn(config.signer, "authorization"); + const authorize = vapidAuthorizations(config); + const fcm = await authorize(new URL("https://fcm.googleapis.com/fcm/send/a")); + expect(await authorize(new URL("https://fcm.googleapis.com/fcm/send/b"))).toBe(fcm); + const apple = await authorize(new URL("https://web.push.apple.com/c")); + expect(apple).not.toBe(fcm); + expect(sign).toHaveBeenCalledTimes(2); + // The JWT's `aud` is the origin, so one serves every endpoint there. + const audience = (authorization: string) => + JSON.parse(Buffer.from(/t=[^.]+\.([^.]+)\./.exec(authorization)![1], "base64url").toString()).aud; + expect([audience(fcm), audience(apple)]).toEqual(["https://fcm.googleapis.com", "https://web.push.apple.com"]); +}); + test("a send fits Workers Free's 50 subrequests, and no Wrangler config carries a VAPID key", () => { - // One fetch per distinct subscription of the sending Burrow, and its database connection. - expect(MAX_PUSH_SUBSCRIPTIONS_PER_BURROW + 1).toBeLessThanOrEqual(50); + // One fetch per distinct subscription of the sending Burrow, and its two + // database connections: the read, and the prune. + expect(MAX_PUSH_SUBSCRIPTIONS_PER_BURROW + 2).toBeLessThanOrEqual(50); for (const name of NAMES) expect(Object.keys((wrangler[name] as { vars: object }).vars).filter((key) => /VAPID/.test(key)), name).toEqual([]); }); diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index 87490f571..0f8fab2a8 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -42,8 +42,12 @@ import { MAX_PASSKEYS_PER_ACCOUNT, MAX_SESSIONS_PER_ACCOUNT, MAX_SETUP_CHALLENGES_PER_BURROW, + SESSIONS, + admit, restoreSetupToken, } from "../relay-api"; +import type { Client } from "../relay-auth"; +import { upsertSubscription } from "../relay-push"; import { ENTRIES, ORIGINS, @@ -1390,6 +1394,137 @@ test("send outcomes: 404 and 410 prune, a refusal, a redirect, or a throw is fai ); }); +/** Polls `ready` every 25 ms until it holds, for at most `ms`; answers whether it did. */ +async function eventually(ready: () => Promise, ms = 5_000) { + const deadline = Date.now() + ms; + for (;;) { + if (await ready()) return true; + if (Date.now() > deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +/** + * Backends on the fixture's database that have run a query, other than the + * one counting them (Miniflare's Hyperdrive keeps one open that never has), + * optionally only those waiting on a lock. + */ +async function backends(f: Awaited>, waitingOnLock = false) { + const [{ n }] = await f.sql<{ n: number }>( + `SELECT count(*)::int AS n FROM pg_stat_activity + WHERE datname = current_database() AND pid <> pg_backend_pid() AND query <> '' + AND (NOT $1 OR wait_event_type = 'Lock')`, + [waitingOnLock], + ); + return n; +} + +test("a send holds no Postgres connection while a push service answers, and reopens one only to prune", async ({ + onTestFinished, +}) => { + const f = await pushFixture(); + onTestFinished(f.close); + const gone = randomSecret(); + await f.subscribe(gone, browserSubscription(FCM + "gone").subscription); + let released = false; + f.answerPushes(async () => { + // A closed connection's backend can outlive the close by a moment. + released = await eventually(async () => (await backends(f)) === 0, 2_000); + return new WorkerResponse(null, { status: 410 }); + }); + expect((await f.send(to(gone))).json).toEqual({ delivered: 0, expired: 1, unknown: 0, failed: 0 }); + expect(released).toBe(true); + expect(await f.rows()).toEqual([]); +}); + +test("a send signs one VAPID JWT per push-service origin", async ({ onTestFinished }) => { + const f = await pushFixture(); + onTestFinished(f.close); + const mozilla = "https://updates.push.services.mozilla.com/wpush/v2/"; + const ids = [randomSecret(), randomSecret(), randomSecret()]; + for (const [i, endpoint] of [FCM + "a", FCM + "b", mozilla + "c"].entries()) + await f.subscribe(ids[i], browserSubscription(endpoint).subscription); + expect((await f.send(to(...ids))).json).toMatchObject({ delivered: 3 }); + const byOrigin = new Map>(); + for (const { url, headers } of f.pushed) { + const origin = new URL(url).origin; + byOrigin.set(origin, (byOrigin.get(origin) ?? new Set()).add(headers.authorization)); + } + // ECDSA signatures are randomized, so a second signing would differ. + expect([...byOrigin].map(([origin, tokens]) => [origin, tokens.size])).toEqual([ + ["https://fcm.googleapis.com", 1], + ["https://updates.push.services.mozilla.com", 1], + ].sort()); +}); + +test("a subscribe racing its Burrow's removal answers unknown burrow, never a database error", async ({ + onTestFinished, +}) => { + const f = await pushFixture(); + onTestFinished(f.close); + await withClient(f.url, async (remover) => { + // The removal has deleted the row and not yet committed. + await remover.query("BEGIN"); + await remover.query(`DELETE FROM dormouse_relay_burrows WHERE "burrowId" = $1`, [f.laptop.burrowId]); + const subscribed = f.subscribe(randomSecret()); + expect(await eventually(async () => (await backends(f, true)) === 1)).toBe(true); + await remover.query("COMMIT"); + expect(await subscribed).toMatchObject({ status: 404, json: { error: "unknown burrow" } }); + }); + expect(await f.rows()).toEqual([]); +}); + +test("a capped write that waited on its lock is stamped after the write it followed", async ({ onTestFinished }) => { + const f = await pushFixture(); + onTestFinished(f.close); + /** + * Runs `write` on a connection that pauses just after `BEGIN` while `other` + * runs to completion: a transaction that began first and takes the lock + * second. Answers both results. + */ + async function interleaved(write: (db: Client) => Promise) { + return withClient(f.url, async (db) => { + let began!: () => void; + let go!: () => void; + const begun = new Promise((resolve) => (began = resolve)); + const gate = new Promise((resolve) => (go = resolve)); + const paused: Client = { + async query(text: string, values?: unknown[]) { + const result = await (db as Client).query(text, values); + if (text === "BEGIN") { + began(); + await gate; + } + return result; + }, + }; + const waited = write(paused); + await begun; + await new Promise((resolve) => setTimeout(resolve, 50)); + const first = await withClient(f.url, write); + go(); + return { first, waited: await waited }; + }); + } + const vapidPublicKey = testVapidKeys().RELAY_VAPID_PUBLIC_KEY; + const subscriptions = await interleaved((db) => { + const deliveryId = randomSecret(); + const { keys } = browserSubscription().subscription; + return upsertSubscription(db, f.owner, { + burrowId: f.laptop.burrowId, + deliveryId, + endpoint: FCM + deliveryId, + keys, + vapidPublicKey, + }); + }); + expect(subscriptions.waited!.subscribedAt).toBeGreaterThan(subscriptions.first!.subscribedAt); + const sessions = await interleaved((db) => + admit(db, SESSIONS, f.owner, { tokenHash: digest(randomSecret()), userId: f.owner }, 60_000), + ); + expect(sessions.waited!).toBeGreaterThan(sessions.first!); +}); + test("a send waits at most its deadline for a hung push service, keeping the row", async ({ onTestFinished }) => { const f = await pushFixture(); onTestFinished(f.close); diff --git a/relay/test/push.test.mjs b/relay/test/push.test.mjs index 92a02c725..989285ff2 100644 --- a/relay/test/push.test.mjs +++ b/relay/test/push.test.mjs @@ -12,6 +12,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { createECDH, randomBytes } from 'node:crypto'; import { readFile, rm, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -125,8 +126,20 @@ test('default VAPID subject is the https origin, and absent for one push cannot } }); +/** Keys a browser could hold: an uncompressed P-256 point and a 16-byte secret. */ +function browserKeys() { + const ecdh = createECDH('prime256v1'); + ecdh.generateKeys(); + return { p256dh: ecdh.getPublicKey(), auth: randomBytes(16) }; +} + +const KEYS = browserKeys(); + function subscription(endpoint = 'https://push.example.com/sub/abc') { - return { endpoint, keys: { p256dh: 'BFakeP256dhKey', auth: 'FakeAuthSecret' } }; + return { + endpoint, + keys: { p256dh: KEYS.p256dh.toString('base64url'), auth: KEYS.auth.toString('base64url') }, + }; } /** A delivery id in the shape a Burrow mints: base64url of 32 random bytes. */ @@ -328,16 +341,19 @@ test('an over-long endpoint is refused before it becomes a durable row', async ( await assert.rejects(storedRows(stateDir), 'nothing was written'); }); -test('over-long encryption keys are refused; RFC 8291 fixes both lengths', async () => { +test('encryption keys RFC 8291 could not have produced are refused', async () => { const { app, stateDir, burrow, sessionToken } = await pushApp(); const long = 'A'.repeat(4096); + const { p256dh, auth } = subscription().keys; for (const keys of [ - { p256dh: long, auth: 'FakeAuthSecret' }, - { p256dh: 'BFakeP256dhKey', auth: long }, - // Empty is not a key `web-push` could ever encrypt to. - { p256dh: '', auth: 'FakeAuthSecret' }, - { p256dh: 'BFakeP256dhKey', auth: '' }, + { p256dh: long, auth }, + { p256dh, auth: long }, + // Empty, or the wrong length, is not a key `web-push` could ever encrypt to. + { p256dh: '', auth }, + { p256dh, auth: '' }, + { p256dh: 'BFakeP256dhKey', auth }, + { p256dh, auth: 'FakeAuthSecret' }, ]) { const res = await subscribe(app, { sessionToken, @@ -359,7 +375,7 @@ test('a padded base64 p256dh still registers — browsers serialize both ways', // 65 and 16 raw bytes as PADDED base64: the longest either can really be. sub: { endpoint: 'https://push.example.com/sub/abc', - keys: { p256dh: `${'B'.repeat(87)}=`, auth: `${'C'.repeat(23)}=` }, + keys: { p256dh: KEYS.p256dh.toString('base64'), auth: KEYS.auth.toString('base64') }, }, }); assert.equal(res.status, 200); diff --git a/remote-lib-common/src/remote/relay-common.ts b/remote-lib-common/src/remote/relay-common.ts index c7b625174..f2f1b672d 100644 --- a/remote-lib-common/src/remote/relay-common.ts +++ b/remote-lib-common/src/remote/relay-common.ts @@ -26,6 +26,7 @@ import { import { boundedPushText } from '../security/push.js'; import { MAX_SEALED_PUSH_LENGTH, isSealedPushV1 } from '../security/push-seal.js'; import { getWebCrypto } from '../security/webcrypto.js'; +import { isWebPushKeys } from './web-push.js'; import { E2E_ID_LENGTH, MAX_CLIENT_ID_LENGTH, @@ -227,10 +228,11 @@ export function isPushDeliveryId(value: unknown): value is string { } /** - * True if `value` is a `PushSubscriptionPayload` with both encryption keys, - * each of a length RFC 8291 could actually have produced. Non-empty, because a - * blank key is a row no sender can ever encrypt to. **Every stored field is - * bounded**: these three are the whole row. + * True if `value` is a `PushSubscriptionPayload` whose keys a sender can + * encrypt to: each within its bound, `p256dh` decoding to an uncompressed + * P-256 point and `auth` to 16 bytes, padded or not (`isWebPushKeys`), so a + * row no push could ever reach is refused at registration. **Every stored + * field is bounded**: these three are the whole row. */ export function isPushSubscriptionPayload(value: unknown): value is PushSubscriptionPayload { if (!value || typeof value !== 'object') return false; @@ -240,7 +242,8 @@ export function isPushSubscriptionPayload(value: unknown): value is PushSubscrip !!v.keys && typeof v.keys === 'object' && isBoundedNonEmptyString(v.keys.p256dh, MAX_PUSH_KEY_P256DH_LENGTH) && - isBoundedNonEmptyString(v.keys.auth, MAX_PUSH_KEY_AUTH_LENGTH) + isBoundedNonEmptyString(v.keys.auth, MAX_PUSH_KEY_AUTH_LENGTH) && + isWebPushKeys(v.keys) ); } diff --git a/remote-lib-common/src/remote/web-push.ts b/remote-lib-common/src/remote/web-push.ts index 70308364d..fe3774376 100644 --- a/remote-lib-common/src/remote/web-push.ts +++ b/remote-lib-common/src/remote/web-push.ts @@ -148,9 +148,10 @@ export async function encryptWebPush( ): Promise { const crypto = options.crypto ?? getWebCrypto(); const subtle = subtleOf(crypto); - const uaPublic = decodePushKey(keys.p256dh, P256_POINT_LENGTH, 'p256dh'); - if (uaPublic[0] !== 4) throw new Error('p256dh is not an uncompressed P-256 point'); - const authSecret = decodePushKey(keys.auth, AUTH_SECRET_LENGTH, 'auth'); + const uaPublic = decodeP256dh(keys.p256dh); + if (!uaPublic) throw new Error('p256dh is not an uncompressed P-256 point'); + const authSecret = decodeAuthSecret(keys.auth); + if (!authSecret) throw new Error(`auth must decode to ${AUTH_SECRET_LENGTH} bytes`); if (plaintext.length > MAX_WEB_PUSH_PLAINTEXT_LENGTH) { throw new Error(`push payload exceeds ${MAX_WEB_PUSH_PLAINTEXT_LENGTH} bytes`); } @@ -267,28 +268,26 @@ export interface WebPushRequest { /** * One push's headers and encrypted body: `aes128gcm`, the VAPID - * authorization, `TTL`, and high urgency, an alarm being worth waking for. + * `authorization` ({@link VapidSigner.authorization} for the endpoint's + * origin, which one JWT serves for every endpoint there), `TTL`, and high + * urgency, an alarm being worth waking for. */ export async function webPushRequest( - target: { readonly endpoint: string; readonly keys: WebPushKeys }, + keys: WebPushKeys, payload: Uint8Array, { - signer, - subject, + authorization, ttlSeconds, - nowMs, ...encrypt }: WebPushEncryptOptions & { - readonly signer: VapidSigner; - readonly subject: string; + readonly authorization: string; readonly ttlSeconds: number; - readonly nowMs: number; }, ): Promise { - const body = await encryptWebPush(payload, target.keys, encrypt); + const body = await encryptWebPush(payload, keys, encrypt); return { headers: { - authorization: await signer.authorization(target.endpoint, subject, nowMs), + authorization, 'content-encoding': 'aes128gcm', 'content-type': 'application/octet-stream', ttl: String(ttlSeconds), @@ -358,18 +357,37 @@ export function defaultVapidSubject(origin: string): string | null { } /** - * A subscription key as browsers serialize it: base64url, or base64 with - * padding, decoding to exactly `length` bytes. + * True if `keys` are subscription keys {@link encryptWebPush} can encrypt to: + * `p256dh` an uncompressed P-256 point, `auth` the 16-byte secret. Both Relays + * refuse any other at registration (`isPushSubscriptionPayload`). + */ +export function isWebPushKeys(keys: { readonly p256dh: unknown; readonly auth: unknown }): boolean { + return decodeP256dh(keys.p256dh) !== null && decodeAuthSecret(keys.auth) !== null; +} + +/** A `p256dh` as its 65-byte uncompressed point (leading `0x04`), or null. */ +function decodeP256dh(value: unknown): Uint8Array | null { + const point = decodePushKey(value, P256_POINT_LENGTH); + return point && point[0] === 4 ? point : null; +} + +/** An `auth` as its 16 bytes, or null. */ +function decodeAuthSecret(value: unknown): Uint8Array | null { + return decodePushKey(value, AUTH_SECRET_LENGTH); +} + +/** + * A subscription key as browsers serialize it — base64url or base64, padded + * or not — decoding to exactly `length` bytes, or null. */ -function decodePushKey(value: string, length: number, name: string): Uint8Array { - let decoded: Uint8Array; +function decodePushKey(value: unknown, length: number): Uint8Array | null { + if (typeof value !== 'string') return null; try { - decoded = fromBase64Url(value.replace(/\+/g, '-').replace(/\//g, '_')); + const decoded = fromBase64Url(value.replace(/\+/g, '-').replace(/\//g, '_')); + return decoded.length === length ? decoded : null; } catch { - throw new Error(`${name} is not base64url`); + return null; } - if (decoded.length !== length) throw new Error(`${name} must decode to ${length} bytes`); - return decoded; } /** A P-256 scalar and its point as a WebCrypto private key for `algorithm`. */ diff --git a/remote-lib-common/test/relay-common.test.mjs b/remote-lib-common/test/relay-common.test.mjs index 927b7c75a..bd8a92ab2 100644 --- a/remote-lib-common/test/relay-common.test.mjs +++ b/remote-lib-common/test/relay-common.test.mjs @@ -4,6 +4,7 @@ */ import test from 'node:test'; import assert from 'node:assert/strict'; +import { createECDH, randomBytes } from 'node:crypto'; import { MAX_PASSKEY_LABEL_LENGTH, @@ -15,6 +16,7 @@ import { readJson, verifySigninAssertion, importableSpkiP256, + isPushSubscriptionPayload, normalizeChallenge, pocketContentSecurityPolicy, reducePasskeyLabel, @@ -27,6 +29,35 @@ const ORIGIN = 'https://relay.example'; const RP_ID = 'relay.example'; const encode = (value) => toBase64Url(utf8Encode(JSON.stringify(value))); +test('isPushSubscriptionPayload admits only keys a sender can encrypt to, padded or not', () => { + const ecdh = createECDH('prime256v1'); + ecdh.generateKeys(); + const point = ecdh.getPublicKey(); + const auth = randomBytes(16); + const padded = (bytes) => bytes.toString('base64'); + const payload = (keys) => ({ endpoint: 'https://fcm.googleapis.com/fcm/send/abc', keys }); + for (const keys of [ + { p256dh: point.toString('base64url'), auth: auth.toString('base64url') }, + { p256dh: padded(point), auth: padded(auth) }, + ]) + assert.equal(isPushSubscriptionPayload(payload(keys)), true, JSON.stringify(keys)); + const ok = { p256dh: point.toString('base64url'), auth: auth.toString('base64url') }; + for (const keys of [ + // A compressed point, a point without its prefix, and 65 bytes not led by 0x04. + { ...ok, p256dh: ecdh.getPublicKey(undefined, 'compressed').toString('base64url') }, + { ...ok, p256dh: point.subarray(1).toString('base64url') }, + { ...ok, p256dh: Buffer.concat([Buffer.from([2]), point.subarray(1)]).toString('base64url') }, + { ...ok, p256dh: 'BFakeP256dhKey' }, + { ...ok, auth: randomBytes(15).toString('base64url') }, + { ...ok, auth: randomBytes(17).toString('base64url') }, + { ...ok, auth: 'FakeAuthSecret' }, + { ...ok, auth: `${auth.toString('base64url').slice(0, -1)}!` }, + { ...ok, p256dh: '' }, + { ...ok, auth: undefined }, + ]) + assert.equal(isPushSubscriptionPayload(payload(keys)), false, JSON.stringify(keys)); +}); + test('decodeClientData answers an object or null', () => { assert.deepEqual(decodeClientData(encode({ type: 'webauthn.create' })), { type: 'webauthn.create' }); for (const bad of [undefined, 42, '***', toBase64Url(utf8Encode('not json')), encode(null), encode('x')]) diff --git a/remote-lib-common/test/web-push.test.mjs b/remote-lib-common/test/web-push.test.mjs index 5d95a3b96..9c19fa7f2 100644 --- a/remote-lib-common/test/web-push.test.mjs +++ b/remote-lib-common/test/web-push.test.mjs @@ -205,11 +205,11 @@ test('a push request carries the encrypted body, the VAPID authorization, TTL, a const signer = await vapidSigner(nodeVapidKeys()); const subscription = browserSubscription(); const endpoint = 'https://web.push.apple.com/QGuQyavXutnMH-5'; - const { headers, body } = await webPushRequest( - { endpoint, keys: subscription.keys }, - utf8Encode('{"v":1}'), - { signer, subject: 'https://relay.example.test', ttlSeconds: 300, nowMs: Date.now() }, - ); + const authorization = await signer.authorization(endpoint, 'https://relay.example.test', Date.now()); + const { headers, body } = await webPushRequest(subscription.keys, utf8Encode('{"v":1}'), { + authorization, + ttlSeconds: 300, + }); assert.deepEqual(Object.keys(headers).sort(), [ 'authorization', 'content-encoding', @@ -217,7 +217,7 @@ test('a push request carries the encrypted body, the VAPID authorization, TTL, a 'ttl', 'urgency', ]); - assert.match(headers.authorization, /^vapid t=[^,]+, k=/); + assert.equal(headers.authorization, authorization); assert.equal(headers['content-encoding'], 'aes128gcm'); assert.equal(headers.ttl, '300'); assert.equal(headers.urgency, 'high'); diff --git a/scripts/e2e-lint-selftest.mjs b/scripts/e2e-lint-selftest.mjs index 8477c3b45..ed49e42fe 100644 --- a/scripts/e2e-lint-selftest.mjs +++ b/scripts/e2e-lint-selftest.mjs @@ -38,6 +38,7 @@ import { RELAY_ROUTING, RULES, SECURITY_SPEC, + WEB_PUSH_SENDER, } from './e2e-lint.mjs'; const selftest = makeSelftest('e2e-lint.mjs', '.e2e-selftest.bak'); @@ -328,6 +329,11 @@ selftest.withAppended( "\nconst __selftest = { name: 'AES-GCM' };\n", 'AES-GCM in the module beside the at-rest wrapper stays green', ); +selftest.withAppended( + 'remote-lib-common/src/remote/relay-common.ts', + "\nconst __selftest = { name: 'AES-GCM' };\n", + `AES-GCM in the module beside ${WEB_PUSH_SENDER} stays green`, +); const cited = new Map(); for (const rule of RULES) { diff --git a/scripts/e2e-lint.mjs b/scripts/e2e-lint.mjs index 6745ac93c..cd3b75062 100644 --- a/scripts/e2e-lint.mjs +++ b/scripts/e2e-lint.mjs @@ -233,12 +233,21 @@ export const NATIVE_PEER_FACTORY = 'lib/src/host/remote/native-direct-peer.ts'; export const PEER_FACTORIES = [NATIVE_PEER_FACTORY, 'lib/src/remote/client/browser-direct-peer.ts']; /** - * The one file the AES-GCM ban excuses, as `docs/specs/security-remote.md` -> - * "Credentials at rest" names it. Excused by path rather than dropped from the - * scan, so a rename that leaves the cipher behind turns the rule red. + * The two files the AES-GCM ban excuses, as `docs/specs/security-remote.md` -> + * "Credentials at rest" names them, each by exact path rather than dropped + * from the scan, so a rename that leaves the cipher behind turns the rule red. + * This one wraps Pocket's private key at rest. */ const AT_REST_KEY_WRAPPER = 'lib/src/remote/client/pocket-private-key.ts'; +/** + * And this one is the Hosted Relay's Web Push sender, whose + * `aes128gcm` record RFC 8291 fixes as AES-128-GCM. It encrypts to a push + * service's subscription key, outside the Noise channel, around an envelope + * already sealed inside it. + */ +export const WEB_PUSH_SENDER = 'remote-lib-common/src/remote/web-push.ts'; + /** * One entry per structural property. Every rule states the line it enforces in * `security`, which must still appear in its `spec` (`SECURITY_SPEC` when @@ -292,8 +301,9 @@ export const RULES = [ security: 'AES-GCM appears in production source under `remote-lib-common/src/`', kind: 'forbid', trees: SOURCE_TREES, - allow: (match, file) => file === AT_REST_KEY_WRAPPER, - // The one exception encrypts local private-key storage, never wire data. + allow: (match, file) => file === AT_REST_KEY_WRAPPER || file === WEB_PUSH_SENDER, + // The exceptions encrypt local private-key storage and a Web Push record, + // never a Noise frame. // `AES-GCM` is the substitution the Noise suite exists to refuse: it *is* in // shipping WebCrypto, which is exactly what makes it the tempting one, and // the protocol name is part of the transcript so swapping it is a different diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 12d357f40..56873eafa 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,13 +12,13 @@ "docs/specs/dor-tools-builtin.md": 1050, "docs/specs/dor-tools-lib.md": 400, "docs/specs/glossary.md": 3000, - "docs/specs/hosted.md": 4600, + "docs/specs/hosted.md": 4700, "docs/specs/layout.md": 11500, "docs/specs/mobile-terminal-ui.md": 2300, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/one-time.md": 3850, "docs/specs/pocket-app.md": 5100, - "docs/specs/relay.md": 10200, + "docs/specs/relay.md": 10250, "docs/specs/remote-api.md": 5250, "docs/specs/remote-network.md": 2450, "docs/specs/remote-security-model.md": 5450, From ac4395cdbfedc01e081f649256cf557bbab5ec4c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 1 Oct 2026 12:43:55 -0700 Subject: [PATCH 3/4] Refuse a push key off P-256 at registration A 65-byte 0x04-led p256dh that is not on the curve passed the shape check, registered, and then failed every send with DataError, holding a subscription slot forever. importableWebPushKeys (was isWebPushKeys) now imports the point with WebCrypto after the shape checks; the async admissiblePushSubscription (was isPushSubscriptionPayload) threads it through both Relays' subscribe routes, still answering the existing malformed-request 400. Co-Authored-By: Claude Opus 5.5 --- docs/specs/relay.md | 6 +++--- hosted/server/relay-push.ts | 4 ++-- hosted/server/tests/relay.test.ts | 16 +++++++++++++++ relay/src/app.ts | 4 ++-- relay/test/push.test.mjs | 9 +++++++++ remote-lib-common/src/remote/relay-common.ts | 14 +++++++------ remote-lib-common/src/remote/web-push.ts | 21 ++++++++++++++++---- remote-lib-common/test/relay-common.test.mjs | 16 +++++++++++---- scripts/spec-word-budgets.json | 2 +- 9 files changed, 70 insertions(+), 22 deletions(-) diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 1577e2cd2..435ff686c 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -276,15 +276,15 @@ stale row rather than leave one per rotation: `MAX_PUSH_ENDPOINT_LENGTH` (1024) on admission; both `keys` bounded at the base64 lengths RFC 8291 fixes, each at its *padded* encoding so a browser that pads still registers, and refused unless they decode to them — - `p256dh` an uncompressed P-256 point (`0x04`-led), `auth` the 16-byte - secret (`isWebPushKeys`). An upsert then caps the committed set at + `p256dh` an uncompressed (`0x04`-led) point on P-256, `auth` the 16-byte + secret (`importableWebPushKeys`). An upsert then caps the committed set at `MAX_PUSH_SUBSCRIPTIONS_PER_BURROW` (32) and `MAX_PUSH_SUBSCRIPTIONS_TOTAL` (256), **evicting the oldest `subscribedAt` first and never the row it just wrote**. Eviction covers every Burrow, so a hand-edited file over the cap converges on the next write. Source of truth: `relay/src/state.ts`; the caps and field bounds in -`remote-lib-common/src/remote/relay-common.ts`; `isWebPushKeys` in +`remote-lib-common/src/remote/relay-common.ts`; `importableWebPushKeys` in `remote-lib-common/src/remote/web-push.ts`. ## WebAuthn without a WebAuthn library diff --git a/hosted/server/relay-push.ts b/hosted/server/relay-push.ts index 024003f00..fa3923a46 100644 --- a/hosted/server/relay-push.ts +++ b/hosted/server/relay-push.ts @@ -8,10 +8,10 @@ import { MAX_PUSH_SUBSCRIPTIONS_PER_BURROW, PUSH_SEND_DEADLINE_MS, PUSH_TTL_SECONDS, + admissiblePushSubscription, concatBytes, defaultVapidSubject, isPushDeliveryId, - isPushSubscriptionPayload, isSealedPushRecipient, readJson, utf8Encode, @@ -262,7 +262,7 @@ export function relayPushRoutes(app: Hono<{ Bindings: RelayEnv }>) { !body || typeof body.burrowId !== "string" || !isPushDeliveryId(body.deliveryId) || - !isPushSubscriptionPayload(body.subscription) + !(await admissiblePushSubscription(body.subscription)) ) return c.json({ error: "malformed request" }, 400); if (!knownPushEndpoint(body.subscription.endpoint)) diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index 0f8fab2a8..b789f7500 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -1603,3 +1603,19 @@ test("send requires and bounds its recipients, and the push routes refuse an id expect((await f.devices(f.sessionToken)).status).toBe(401); expect((await f.query([randomSecret()], f.laptop.token)).status).toBe(401); }); + +test("subscribe refuses a p256dh of the right shape off P-256, writing nothing", async ({ onTestFinished }) => { + const f = await pushFixture(); + onTestFinished(f.close); + const { subscription } = browserSubscription(); + // 65 bytes led by 0x04 with the low bit of y flipped: every send to it would fail. + const offCurve = Buffer.from(subscription.keys.p256dh, "base64url"); + offCurve[64] ^= 1; + expect( + await f.subscribe(randomSecret(), { + ...subscription, + keys: { ...subscription.keys, p256dh: offCurve.toString("base64url") }, + }), + ).toMatchObject({ status: 400, json: { error: "malformed request" } }); + expect(await f.rows()).toEqual([]); +}); diff --git a/relay/src/app.ts b/relay/src/app.ts index 8cbf19288..6a830a9c2 100644 --- a/relay/src/app.ts +++ b/relay/src/app.ts @@ -41,7 +41,7 @@ import { presenceChallenge, toBase64Url, isPushDeliveryId, - isPushSubscriptionPayload, + admissiblePushSubscription, isSealedPushRecipient, verifyPasskeyAssertion, TokenBucket, @@ -968,7 +968,7 @@ export function createApp(config: AppConfig): CreatedApp { !body || typeof body.burrowId !== 'string' || !isPushDeliveryId(body.deliveryId) || - !isPushSubscriptionPayload(body.subscription) + !(await admissiblePushSubscription(body.subscription)) ) { return c.json({ error: 'malformed request' }, 400); } diff --git a/relay/test/push.test.mjs b/relay/test/push.test.mjs index 989285ff2..7e1a002ba 100644 --- a/relay/test/push.test.mjs +++ b/relay/test/push.test.mjs @@ -135,6 +135,13 @@ function browserKeys() { const KEYS = browserKeys(); +/** `point` with the low bit of `y` flipped: the shape of a P-256 point, off the curve. */ +function offCurve(point) { + const bad = Buffer.from(point); + bad[64] ^= 1; + return bad; +} + function subscription(endpoint = 'https://push.example.com/sub/abc') { return { endpoint, @@ -354,6 +361,8 @@ test('encryption keys RFC 8291 could not have produced are refused', async () => { p256dh, auth: '' }, { p256dh: 'BFakeP256dhKey', auth }, { p256dh, auth: 'FakeAuthSecret' }, + // 65 bytes led by 0x04 but off P-256: every send to it would fail, holding the row forever. + { p256dh: offCurve(KEYS.p256dh).toString('base64url'), auth }, ]) { const res = await subscribe(app, { sessionToken, diff --git a/remote-lib-common/src/remote/relay-common.ts b/remote-lib-common/src/remote/relay-common.ts index f2f1b672d..7f8483479 100644 --- a/remote-lib-common/src/remote/relay-common.ts +++ b/remote-lib-common/src/remote/relay-common.ts @@ -26,7 +26,7 @@ import { import { boundedPushText } from '../security/push.js'; import { MAX_SEALED_PUSH_LENGTH, isSealedPushV1 } from '../security/push-seal.js'; import { getWebCrypto } from '../security/webcrypto.js'; -import { isWebPushKeys } from './web-push.js'; +import { importableWebPushKeys } from './web-push.js'; import { E2E_ID_LENGTH, MAX_CLIENT_ID_LENGTH, @@ -230,11 +230,13 @@ export function isPushDeliveryId(value: unknown): value is string { /** * True if `value` is a `PushSubscriptionPayload` whose keys a sender can * encrypt to: each within its bound, `p256dh` decoding to an uncompressed - * P-256 point and `auth` to 16 bytes, padded or not (`isWebPushKeys`), so a - * row no push could ever reach is refused at registration. **Every stored - * field is bounded**: these three are the whole row. + * point on P-256 and `auth` to 16 bytes, padded or not + * (`importableWebPushKeys`), so a row no push could ever reach is refused at + * registration. **Every stored field is bounded**: these three are the whole + * row. Async for the curve check, so never named `is…`: an un-awaited call + * would read as always true. */ -export function isPushSubscriptionPayload(value: unknown): value is PushSubscriptionPayload { +export async function admissiblePushSubscription(value: unknown): Promise { if (!value || typeof value !== 'object') return false; const v = value as PushSubscriptionPayload; return ( @@ -243,7 +245,7 @@ export function isPushSubscriptionPayload(value: unknown): value is PushSubscrip typeof v.keys === 'object' && isBoundedNonEmptyString(v.keys.p256dh, MAX_PUSH_KEY_P256DH_LENGTH) && isBoundedNonEmptyString(v.keys.auth, MAX_PUSH_KEY_AUTH_LENGTH) && - isWebPushKeys(v.keys) + (await importableWebPushKeys(v.keys)) ); } diff --git a/remote-lib-common/src/remote/web-push.ts b/remote-lib-common/src/remote/web-push.ts index fe3774376..131423afd 100644 --- a/remote-lib-common/src/remote/web-push.ts +++ b/remote-lib-common/src/remote/web-push.ts @@ -358,11 +358,24 @@ export function defaultVapidSubject(origin: string): string | null { /** * True if `keys` are subscription keys {@link encryptWebPush} can encrypt to: - * `p256dh` an uncompressed P-256 point, `auth` the 16-byte secret. Both Relays - * refuse any other at registration (`isPushSubscriptionPayload`). + * `p256dh` an uncompressed point on P-256, `auth` the 16-byte secret. Both + * Relays refuse any other at registration (`admissiblePushSubscription`). The + * shape checks run first; only a 65-byte `0x04`-led point reaches WebCrypto, + * whose import refuses one off the curve — a key that would otherwise register + * and then fail every send with `DataError`, holding its row forever. */ -export function isWebPushKeys(keys: { readonly p256dh: unknown; readonly auth: unknown }): boolean { - return decodeP256dh(keys.p256dh) !== null && decodeAuthSecret(keys.auth) !== null; +export async function importableWebPushKeys(keys: { + readonly p256dh: unknown; + readonly auth: unknown; +}): Promise { + const point = decodeP256dh(keys.p256dh); + if (!point || !decodeAuthSecret(keys.auth)) return false; + try { + await subtleOf(getWebCrypto()).importKey('raw', point, ECDH, false, []); + return true; + } catch { + return false; + } } /** A `p256dh` as its 65-byte uncompressed point (leading `0x04`), or null. */ diff --git a/remote-lib-common/test/relay-common.test.mjs b/remote-lib-common/test/relay-common.test.mjs index bd8a92ab2..6d7160b95 100644 --- a/remote-lib-common/test/relay-common.test.mjs +++ b/remote-lib-common/test/relay-common.test.mjs @@ -16,7 +16,7 @@ import { readJson, verifySigninAssertion, importableSpkiP256, - isPushSubscriptionPayload, + admissiblePushSubscription, normalizeChallenge, pocketContentSecurityPolicy, reducePasskeyLabel, @@ -28,8 +28,14 @@ import { SimAuthenticator, randomSecret, registrationClientData } from './harnes const ORIGIN = 'https://relay.example'; const RP_ID = 'relay.example'; const encode = (value) => toBase64Url(utf8Encode(JSON.stringify(value))); +/** `point` with the low bit of `y` flipped: 65 bytes, `0x04`-led, and not on P-256. */ +const offCurve = (point) => { + const bad = Buffer.from(point); + bad[64] ^= 1; + return bad; +}; -test('isPushSubscriptionPayload admits only keys a sender can encrypt to, padded or not', () => { +test('admissiblePushSubscription admits only keys a sender can encrypt to, padded or not', async () => { const ecdh = createECDH('prime256v1'); ecdh.generateKeys(); const point = ecdh.getPublicKey(); @@ -40,13 +46,15 @@ test('isPushSubscriptionPayload admits only keys a sender can encrypt to, padded { p256dh: point.toString('base64url'), auth: auth.toString('base64url') }, { p256dh: padded(point), auth: padded(auth) }, ]) - assert.equal(isPushSubscriptionPayload(payload(keys)), true, JSON.stringify(keys)); + assert.equal(await admissiblePushSubscription(payload(keys)), true, JSON.stringify(keys)); const ok = { p256dh: point.toString('base64url'), auth: auth.toString('base64url') }; for (const keys of [ // A compressed point, a point without its prefix, and 65 bytes not led by 0x04. { ...ok, p256dh: ecdh.getPublicKey(undefined, 'compressed').toString('base64url') }, { ...ok, p256dh: point.subarray(1).toString('base64url') }, { ...ok, p256dh: Buffer.concat([Buffer.from([2]), point.subarray(1)]).toString('base64url') }, + // The right shape, off the curve: every send to it would fail with DataError. + { ...ok, p256dh: offCurve(point).toString('base64url') }, { ...ok, p256dh: 'BFakeP256dhKey' }, { ...ok, auth: randomBytes(15).toString('base64url') }, { ...ok, auth: randomBytes(17).toString('base64url') }, @@ -55,7 +63,7 @@ test('isPushSubscriptionPayload admits only keys a sender can encrypt to, padded { ...ok, p256dh: '' }, { ...ok, auth: undefined }, ]) - assert.equal(isPushSubscriptionPayload(payload(keys)), false, JSON.stringify(keys)); + assert.equal(await admissiblePushSubscription(payload(keys)), false, JSON.stringify(keys)); }); test('decodeClientData answers an object or null', () => { diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 56873eafa..db6679e67 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,7 +12,7 @@ "docs/specs/dor-tools-builtin.md": 1050, "docs/specs/dor-tools-lib.md": 400, "docs/specs/glossary.md": 3000, - "docs/specs/hosted.md": 4700, + "docs/specs/hosted.md": 4750, "docs/specs/layout.md": 11500, "docs/specs/mobile-terminal-ui.md": 2300, "docs/specs/mouse-and-clipboard.md": 3750, From 4958cff32bd5be5be8c2ae8b15f1bd6054aa7eb4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 1 Oct 2026 13:27:45 -0700 Subject: [PATCH 4/4] Disable Hosted push for noncanonical VAPID secrets --- docs/specs/hosted.md | 2 +- hosted/server/tests/relay-push.test.ts | 14 ++++++++++++++ remote-lib-common/src/remote/web-push.ts | 7 +++---- remote-lib-common/test/web-push.test.mjs | 12 ++++++++++++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index e01c9d1fa..bfde5abc3 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -125,7 +125,7 @@ A session-gated route answers a session of an account no longer entitled with th - **Must cap subscriptions at `MAX_PUSH_SUBSCRIPTIONS_PER_BURROW` and `MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT`** in place of self-host's file total, evicting the oldest `subscribedAt`, never the row just written, in the upsert's transaction under the account's advisory lock (rationale). **Must stamp `subscribedAt` and every capped expiry with `clock_timestamp()`**, never `now()`, so a write that waited on its lock sorts after the one it followed. **Must share-lock the Burrow row**, so a subscribe racing its removal answers 404. - **Must register and fetch only a known Web Push service's endpoint** (`knownPushEndpoint`) in place of the self-host DNS guard: `https:` on the default port, no credentials, at most `MAX_PUSH_ENDPOINT_LENGTH`, and host `fcm.googleapis.com` or `updates.push.services.mozilla.com`, or one under `.push.apple.com` or `.notify.windows.com` (rationale). **Never follow a redirect**: a 3xx is `failed`. A refusal's log keeps at most 1 KiB of its body, copying no chunk past it, and cancels the rest. - **Must send through `webPushRequest`** (`remote-lib-common/src/remote/web-push.ts`), WebCrypto with no `web-push`: one RFC 8291 `aes128gcm` record and an RFC 8292 `ES256` VAPID JWT, `aud` the endpoint's origin, `exp` `VAPID_JWT_LIFETIME_S` (12 hours) ahead, `sub` the relay's `APP_ORIGIN`, signed once per origin per send (`vapidAuthorizations`). The route bounds each delivery by `PUSH_SEND_DEADLINE_MS`, aborting its fetch. **Never hold a Postgres connection across a push service's fetch**: read the targets (`readAsBurrow`), release, fan out, then reconnect only to prune. -- **Push is disabled, not half-working**: without both `RELAY_VAPID_PUBLIC_KEY` and `RELAY_VAPID_PRIVATE_KEY`, with a private key that does not sign for its public point, or without a subject (`defaultVapidSubject`: an https, non-loopback `APP_ORIGIN`), the config route answers `null` and subscribe and send 503. The pair is a relay Worker secret; previews derive theirs ("PR previews"), and the dev loop has none. +- **Push is disabled, not half-working**: without both `RELAY_VAPID_PUBLIC_KEY` and `RELAY_VAPID_PRIVATE_KEY`, with malformed keys (including noncanonical base64url), with a private key that does not sign for its public point, or without a subject (`defaultVapidSubject`: an https, non-loopback `APP_ORIGIN`), the config route answers `null` and subscribe and send 503. The pair is a relay Worker secret; previews derive theirs ("PR previews"), and the dev loop has none. **Pocket at the root.** `build` stages `lib/dist-pocket` at the root of the relay's assets and the one-time page beside it, checking both shells. Pocket is served per `docs/specs/pocket-app.md` -> "Serving the built bundle", except that a path naming no file (no extension, outside `/diagnostics`) gets the shell in one asset fetch. diff --git a/hosted/server/tests/relay-push.test.ts b/hosted/server/tests/relay-push.test.ts index 86370e26d..cf6022ee8 100644 --- a/hosted/server/tests/relay-push.test.ts +++ b/hosted/server/tests/relay-push.test.ts @@ -66,6 +66,20 @@ test("push is configured only by a matching pair and an https, non-loopback orig expect(await pushConfigOf(env(extra)), name).toBeNull(); }); +test("noncanonical VAPID secrets disable push across repeated cached config reads", async () => { + const keys = testVapidKeys(); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + for (const field of ["RELAY_VAPID_PUBLIC_KEY", "RELAY_VAPID_PRIVATE_KEY"] as const) { + const value = keys[field]; + const noncanonical = value.slice(0, -1) + alphabet[alphabet.indexOf(value.at(-1)!) | 1]; + expect(noncanonical).not.toBe(value); + const invalid = env({ [field]: noncanonical }); + expect(await pushConfigOf(invalid), field).toBeNull(); + expect(await pushConfigOf(invalid), `${field} cached`).toBeNull(); + } + expect(await pushConfigOf(env())).not.toBeNull(); +}); + /** A subscription a browser could hold. */ function target(endpoint = "https://fcm.googleapis.com/fcm/send/abc") { const ecdh = createECDH("prime256v1"); diff --git a/remote-lib-common/src/remote/web-push.ts b/remote-lib-common/src/remote/web-push.ts index 131423afd..ab125bc35 100644 --- a/remote-lib-common/src/remote/web-push.ts +++ b/remote-lib-common/src/remote/web-push.ts @@ -222,10 +222,9 @@ export async function vapidSigner( ) { return null; } - const publicKey = fromBase64Url(keys.publicKey); - const privateScalar = fromBase64Url(keys.privateKey); - if (publicKey.length !== P256_POINT_LENGTH || publicKey[0] !== 4) return null; - if (privateScalar.length !== P256_SCALAR_LENGTH) return null; + const publicKey = decodeP256dh(keys.publicKey); + const privateScalar = decodePushKey(keys.privateKey, P256_SCALAR_LENGTH); + if (!publicKey || !privateScalar) return null; const subtle = subtleOf(crypto); let signingKey: CryptoKeyLike; try { diff --git a/remote-lib-common/test/web-push.test.mjs b/remote-lib-common/test/web-push.test.mjs index 9c19fa7f2..15bab278a 100644 --- a/remote-lib-common/test/web-push.test.mjs +++ b/remote-lib-common/test/web-push.test.mjs @@ -201,6 +201,18 @@ test('a malformed or mismatched VAPID pair yields no signer', async () => { assert.ok(await vapidSigner(keys)); }); +test('noncanonical trailing bits in either VAPID key yield no signer', async () => { + const keys = nodeVapidKeys(); + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + for (const field of ['publicKey', 'privateKey']) { + const value = keys[field]; + const noncanonical = value.slice(0, -1) + alphabet[alphabet.indexOf(value.at(-1)) | 1]; + assert.notEqual(noncanonical, value); + assert.deepEqual(Buffer.from(noncanonical, 'base64url'), Buffer.from(value, 'base64url')); + assert.equal(await vapidSigner({ ...keys, [field]: noncanonical }), null); + } +}); + test('a push request carries the encrypted body, the VAPID authorization, TTL, and urgency', async () => { const signer = await vapidSigner(nodeVapidKeys()); const subscription = browserSubscription();