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
7 changes: 4 additions & 3 deletions .github/audit/application-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ nothing and must reach the direct path — `docs/specs/security-remote.md` ->
of `service.ts` (the one baked origin and its nullable Hosted origin, the
rendezvous gate before any socket, the single runtime, and the approval routed
by `kind`); `lib/src/host/remote/local-networks.ts` and
`native-direct-peer.ts` (Local networks' hold on a one-time direct path: the
bound socket, the stripped offer and answer, and the selected-pair check —
`docs/specs/security-remote.md` -> "One-time connection");
`native-direct-peer.ts` (Local networks' hold on a direct path: the bound
socket, the stripped offer and answer, and the selected-pair check —
`docs/specs/security-remote.md` -> "One-time connection" — and a paired
session's that nothing relayed is read — "Direct path");
`lib/src/remote/burrow/push-delivery.ts`; `lib/src/remote/client/pocket-client.ts`
and `session-core.ts` (the phone's ceremonies, and the established session they
promote), `one-time-client.ts` (the one-time phone, which keeps nothing and
Expand Down
3 changes: 2 additions & 1 deletion .github/audit/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ Be adversarial, and go past the `FAIL IF` list. Ask specifically:
no one can forge a device code that redeems another's approval; an approval
must need a recent admin login from the account's own origin and never
replace a live one; the redemption must be one statement whose owner is that
approver; and a removed Burrow's row must be gone, its token opening nothing
approver, and a redeemed approval must never redeem again, even once its
Burrow is removed; 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?**
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ A spec is the accurate reference for the current code: it states the invariants
- **`docs/specs/website-docs.md`** — Public documentation on the marketing site: the generated references, the Markdown rendering contract they share, the left rail across the docs section, `vscode-ext/README.md` as the canonical guide published off-site, and the lint that pins their links.
- **`docs/specs/webgl-text.md`** — SDF text rendering for the 3D/WebXR effort: the diffplug/xterm.js fork pipeline and its version lockstep, the SDF glyph architecture, the canopy Storybook lab.
- **`docs/specs/remote-security-model.md`** — Remote-control trust model: one Noise channel per ceremony, passkeys proving presence inside it, per-Burrow Client statics, the Burrow (not the Relay) authorizing the pair. Read first for anything remote.
- **`docs/specs/remote-network.md`** — The network policy (Nothing / Local networks / Anywhere / My Relay only): its choke points, the update reminder, the Local networks path check, Cloudflare STUN, and the staged Hosted persistent transport.
- **`docs/specs/remote-network.md`** — The network policy (Nothing / Local networks / Anywhere / My Relay only): its choke points, the update reminder, the Local networks path check, Cloudflare STUN, and each level's paired-phone path.
- **`docs/specs/remote-api.md`** — What an authorized Client speaks: the shipped terminal-only **protocol-v1** and the staged remainder.
- **`docs/specs/relay.md`** — The selfhost coordinating Relay and shared Burrow-service runtime: env config, JSON-file state, WebAuthn without a library, HTTP API, relay flow, enrollment, running it end to end.
- **`docs/specs/hosted.md`** — Hosted accounts: application boundary, login/linking policy, local development, and staged paid services.
Expand Down
4 changes: 2 additions & 2 deletions SELF_HOST.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,8 @@ Burrow displays (`docs/specs/relay.md` → Setup tokens and the pairing QR).
on their own; the section then shows the Relay, the relay connection and the
paired-device count.

A stock build shows only a disabled "Use hosted.dormouse.sh" under
**Persistent Relay**, with nothing to enroll: the expected symptom of a stock
A stock build offers only "Enroll with hosted.dormouse.sh" under
**Persistent Relay**, and no setup password: the expected symptom of a stock
build, not a Relay problem.

3. **The phone, and only then the code.** On the phone, open
Expand Down
14 changes: 7 additions & 7 deletions docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Marketing is a separate bundle and deployment. `remote-lib-common` is compiled i

**Must run committed Better Auth migrations before deploying code that needs them, never during a Worker request.** Postgres is reached through an uncached Hyperdrive binding. The runtime creates and closes its database pool within each request.

**Must install released core/auth packages from npm and commit their lockfile integrity hashes.** The installed packages' `dist/provenance.json` must name the same clean pgstencil commit; no runtime import depends on a sibling checkout. The auth migrations remain owned by the package; Dormouse's own tables migrate from `hosted/server/dormouse-migrations/`.
**Must install released core/auth packages from npm and commit their lockfile integrity hashes.** The installed packages' `dist/provenance.json` must name the same clean pgstencil commit; no runtime import depends on a sibling checkout. The auth migrations remain owned by the package; Dormouse's own tables migrate from `hosted/server/dormouse-migrations/`. **Never edit a merged migration**: a migrated database never reruns one, so append the next number (pinned by `hosted/server/tests/migrations.test.ts`).

**Must declare every peer dependency of the installed packages in `hosted/package.json`**, so they share Hosted's copy and Renovate updates them.

Expand Down Expand Up @@ -146,25 +146,26 @@ The relay Worker serves `GET /ws/burrow` and `GET /ws/client` at the self-host p
- **Must recheck a Burrow's row in the object before accepting its socket**, under `blockConcurrencyWhile`: enrolled, the account's, its owner entitled, else the Worker's 401 or 403. A removal committed after the Worker's token check is refused here; one committed later has its `closeBurrow` delivered after the accept. **Never open a database connection in the object** (rationale): it reads rows through the relay Worker's `RelayRows` entrypoint, reached by `ctx.exports`. **Must bound every row read at `RELAY_ROW_READ_TIMEOUT_MS`**, under the runtime's 30 s `blockConcurrencyWhile` reset: past it the upgrade answers 503 and the sweep leaves its Burrows to the next.
- **Must accept every socket through the Hibernation API**, tagged by role and id, keeping the routing state in its attachment — role, `clientId`, bound `burrowId`, `expiresAt` — so a woken object rebuilds it from `ctx.getWebSockets`.
- **Must measure a text frame in UTF-8 bytes against `MAX_RELAY_FRAME_BYTES` before parsing it**, as the self-host `maxPayload` counts, closing 1009 past it; a binary frame is dropped. The Client cap is per account object.
- **Must keep one alarm at the earlier of the earliest held Client's `expiresAt` and, while a Burrow socket is held, the next sweep**, at most `RELAY_ROOM_SWEEP_MS` (an hour) away. Each alarm closes every expired Client 1008 `unauthorized`, its Burrow told `client-gone`; then reads every held Burrow's row in one query and closes 4001 each removed, another account's, or de-entitled, its Clients told `burrow-gone`; then re-arms, or clears while nothing is held. An upgrade only brings the alarm earlier and a close leaves it. It replaces the self-host expiry and revocation sweeps.
- **Must keep one alarm at the earlier of the earliest held Client's `expiresAt` and, while a Burrow socket is held, the next sweep**, at most `RELAY_ROOM_SWEEP_MS` (an hour) away. Each alarm closes every expired Client 1008 `unauthorized`, its Burrow told `client-gone`; then reads every held Burrow's row in one query and closes each removed or another account's 4001 (`WS_CLOSE_BURROW_REVOKED`) and each de-entitled 4002 (`WS_CLOSE_BURROW_NOT_ENTITLED`), its Clients told `burrow-gone`; then re-arms, or clears while nothing is held. An upgrade only brings the alarm earlier and a close leaves it. It replaces the self-host expiry and revocation sweeps.
- **Must answer `RELAY_PING` with `RELAY_PONG` as the object's auto-response**, which never wakes it, reaches no handler, and is never forwarded. **A socket whose last auto-response is older than `RELAY_IDLE_TIMEOUT_MS` is gone**, retired with 1001; a socket that never pinged is never judged. It replaces the self-host heartbeat. Two gaps are accepted: a Burrow that never pings is never judged, since every build that can enroll with Hosted pings; and at the Client cap a backgrounded Pocket, its pings paused, counts as gone once silent that long — the cap is far above real use, and the Burrow reaps its session at 120 s anyway.
- **Must close a removed Burrow's socket from the account Worker's `DELETE /api/relay/burrows/:burrowId`**, through its `RELAY_ROOM` binding (`script_name` the relay) calling `closeBurrow`: 4001, its Clients `burrow-gone`. **Must answer 204 once the row is gone, even if the close fails**, logging it; the sweep closes the socket. No route reaches either RPC; `GET /api/burrows` reads `online` through `onlineBurrows`, beside its query.

Source of truth: `relaySocketRoutes` in `hosted/server/relay-sockets.ts`; `relayRoom` / `RELAY_ROOM_PARAMS` / `RELAY_ROOM_SWEEP_MS` / `RELAY_ROW_READ_TIMEOUT_MS` in `hosted/server/relay-room-contract.ts`; `RelayRoom` in `hosted/server/relay-room.ts`; `RelayRows` in `hosted/server/relay-rows.ts`; `forwardUpgrade` / `refuseSocket` in `hosted/server/socket-room.ts`; `relayAccountRoutes` in `hosted/server/relay-account.ts`; `exceedsRelayFrameBytes` in `remote-lib-common/src/remote/relay-routing.ts`; `durable_objects` and `migrations` in `hosted/wrangler.relay.jsonc` and `hosted/wrangler.jsonc`. Pinned by `hosted/server/tests/relay-room.test.ts`, which also runs the routing cases every Relay passes (`remote-lib-common/test/harness/relay-parity.mjs`), and `hosted/server/tests/workers.test.ts`.

## Burrow enrollment

A Burrow joins an account by device code, in place of the self-host setup password. The account owns the Burrow; the Burrow's own ACL still authorizes every Client. The relay serves the Burrow's two routes, the account the rest; wire types are `BurrowEnrollBeginResponse` / `BurrowEnrollPollResponse` in `remote-lib-common/src/remote/wire.ts`.
A Burrow joins an account by device code, in place of the self-host setup password. The account owns the Burrow; the Burrow's own ACL still authorizes every Client. The relay serves the Burrow's two routes, the account the rest; wire types are `BurrowEnrollBeginResponse` / `BurrowEnrollPollResponse` in `remote-lib-common/src/remote/wire.ts`. The desktop's side is `docs/specs/relay.md` -> "Burrow side".

1. The Burrow sends `{ origin }` to `POST /api/burrow/enroll/begin`. Another origin, or none, is the self-host 409 `ORIGIN_MISMATCH_ERROR`. The answer: `deviceCode`, `userCode` (`XXXX-XXXX`), `verificationUrl` (`ACCOUNT_ORIGIN/enroll#<userCode>`, absent without `ACCOUNT_ORIGIN`), `expiresAt` (`ENROLLMENT_TTL_MS`, 10 minutes), and `interval` (5 seconds).
2. The user opens the link, signs in, compares the code, and approves, writing the approval `{ userCode, userId, expiresAt }`.
3. The Burrow polls `POST /api/burrow/enroll/poll` with `{ deviceCode }` every `interval`: `expired` for a malformed or expired code; `pending` while no live approval holds its user code; or `enrolled` with the self-host `BurrowEnrollResponse`, `origin` the relay's `APP_ORIGIN` and `rpId` its hostname, without `requireUserVerification`. 403 `NOT_ENTITLED_ERROR` when the approver is no longer entitled; 409 naming `ACCOUNT_ORIGIN/account` at `MAX_ENROLLED_BURROWS` Burrows. Both refusals keep the approval, so a later poll can enroll.
3. The Burrow polls `POST /api/burrow/enroll/poll` with `{ deviceCode }` every `interval`: `expired` for a malformed or expired code; `pending` while no live approval holds its user code; `enrolled` with the self-host `BurrowEnrollResponse`, `origin` the relay's `APP_ORIGIN` and `rpId` its hostname, without `requireUserVerification`; or `redeemed` with the `burrowId` an earlier poll enrolled, until the approval expires, so the Burrow can name what the account must remove. 403 `NOT_ENTITLED_ERROR` when the approver is no longer entitled; 409 naming `ACCOUNT_ORIGIN/account` at `MAX_ENROLLED_BURROWS` Burrows. Both refusals keep the approval, so a later poll can enroll.

- **Never write from begin.** The device code is 32 bytes, the bearer shape: a 4-byte big-endian expiry in epoch seconds, then 28 random bytes. The user code is `enrollUserCode`: `HMAC-SHA-256(RELAY_ENROLL_SECRET, deviceCode)` read five bits at a time into `ENROLL_USER_CODE_ALPHABET`, values past it skipped (rationale).
- **Must store the approval alone** (`dormouse_relay_enrollment_approvals`): it cannot tell an issued code from any well-formed one, so it approves any; one no Burrow redeems expires (rationale).
- **Must store the approval alone** (`dormouse_relay_enrollment_approvals`; its redeemed columns in `004_relay_enrollment_redeemed.sql`): it cannot tell an issued code from any well-formed one, so it approves any; one no Burrow redeems expires (rationale).
- **Never admit a begin or poll request carrying `Origin`** (403): only a Node Burrow calls them. Then 429 with `Retry-After` past `RELAY_ENROLL_BEGIN_LIMIT` (10 a minute per address) or `RELAY_ENROLL_POLL_LIMIT` (60), before the body limit and any database read.
- **Must answer an expired device code from the code alone**, reading no database.
- **Must redeem in one statement**: the poll recomputes the user code, deletes its live approval, and inserts the Burrow owned by the approval's `userId`, under the account's lock with the cap check, so two polls mint one Burrow. The entitlement is rechecked first.
- **Must redeem in one statement**: the poll recomputes the user code, marks its live unredeemed approval with `redeemedBurrowId` and `redeemedAt`, and inserts the Burrow owned by the approval's `userId`, under the account's lock with the cap check, so two polls mint one Burrow. The entitlement is rechecked first.
- **Must keep a redeemed approval until it expires**, swept hourly, so a poll whose `enrolled` answer was lost reads `redeemed`. **Never key it to the Burrow**: removing that Burrow leaves it redeemed; only an approval after it expires replaces it.

| Route (account) | Credential | Success |
|---|---|---|
Expand Down Expand Up @@ -223,4 +224,3 @@ 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 — **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.
Loading
Loading