Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions .github/audit/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 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
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
Expand Down
32 changes: 22 additions & 10 deletions docs/specs/hosted.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions docs/specs/hosted.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

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.
Expand Down
25 changes: 15 additions & 10 deletions docs/specs/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,16 +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 (`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`.
Source of truth: `relay/src/state.ts`; the caps and field bounds in
`remote-lib-common/src/remote/relay-common.ts`; `importableWebPushKeys` in
`remote-lib-common/src/remote/web-push.ts`.

## WebAuthn without a WebAuthn library

Expand Down Expand Up @@ -369,8 +372,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`;
Expand All @@ -385,7 +389,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`.
Expand Down Expand Up @@ -544,7 +548,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

Expand Down Expand Up @@ -1151,7 +1156,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

Expand Down
3 changes: 1 addition & 2 deletions docs/specs/remote-network.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Loading
Loading