diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 4c627c657..4939db49e 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -1082,14 +1082,10 @@ bundle); `describePushTargets` in `lib/src/components/SettingsDialog.tsx`; `lib/src/host/remote/enroll-offer.ts` for the offer's well-known per-platform path, read by `readUsableOffer` in `lib/src/host/remote/service.ts`. -The `window.dormouseBurrow` console hook — the scripting seam — exposes the -seven enrollment commands: `enroll(password, label)`, -`enrollOffer(label)`, `beginHostedEnrollment(label)`, -`cancelHostedEnrollment`, `status`, `reconnect`, `clearEnrollment`. **Pairing confirmation is never here**: it is a -modal because it must interrupt, and because the digits it takes are read off a -phone ([remote-security-model.md](./remote-security-model.md) -> Pairing). The -one-time commands are not on the hook either; `status()` prints `serving` -beside `enrolled`. +**Never expose pairing confirmation or one-time commands on `window.dormouseBurrow`.** +Its enrollment scripting methods belong to `installBridgeMode`; `status()` includes +serving and enrollment state. +Source of truth: `installBridgeMode` in `lib/src/remote/burrow/activation.ts`. `docs/stories/pairing.mdx` is a narrative Storybook page walking this section and the pairing modal in sequence with the rest of the setup, rendering the real diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 34803c08e..f929a8c26 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -19,13 +19,7 @@ One protocol, two consumption depths: the **phone** (Dormouse Pocket) shipped, a ## v1 scope -**Scope: protocol-v1** — the shipped protocol, the smallest that lets a phone **sign in, pick a pane, see it live, and type into it**: - -* Hello (version + viewer kind) -* `directory.watch`, snapshot-only (no deltas, no thumbnails), terminal entries only -* `surface.attach` / `surface.detach`, one attachment per session -* Terminal: attach-is-the-resize, live data, `terminal.write` / `terminal.resize`, last-attach-wins size authority -* One implicit grant: every authorized session — paired or one-time — has full input (selfhost is single-user), no layout operations +**Scope: protocol-v1** — the shipped protocol, the smallest that lets a phone **sign in, pick a pane, see it live, and type into it**. **Must restrict it to terminal listing, one attachment per session, and terminal input/resize; never layout operations.** The sections below own directory snapshots, attachment and size authority, and grants. Canonical method/event syntax lives in `remote-lib-common/src/remote/wire.ts`. Everything else, browser-surface remoting included, is staged in [Future](#future). @@ -35,7 +29,7 @@ Source of truth: `remote-lib-common/src/remote/wire.ts` (the fixed wire contract **The Burrow runs in the process that owns the PTYs, never a webview** (`docs/specs/relay.md` → "Burrow side"). Within it, `RemoteApiSession` speaks this protocol and nothing else: surface ids, PTY ids, sizes, bytes. -**Every environment-specific answer sits behind `BurrowSurfaceProvider`** — `collectDirectory` / `watchDirectory`, `resolveSurface` returning a `SurfaceHandle`, `releaseSurface`, `writePty` / `resizePty` / `streamPty` — because *where* a named surface lives is a deployment fact, not a protocol concept. **The session imports no platform adapter, no store, and no `document`**, and both installations share the ask-backed half, so an attach cannot be answered differently in one burrow than the other. +**Must keep environment-specific answers behind `BurrowSurfaceProvider`.** **The session imports no platform adapter, no store, and no `document`**, and both installations share the ask-backed half, so an attach cannot be answered differently in one burrow than the other. **`SurfaceHandle.ptyId` is a provider-local routing key**, not necessarily the PTY process's own id — the VS Code provider mints an opaque per-peer handle. (rationale) @@ -45,7 +39,7 @@ Source of truth: `BurrowSurfaceProvider` in `lib/src/remote/burrow/burrow-surfac ## Terminology -A Surface is named on the wire by `surfaceId`; the picker lists Panes, so attaching to a Pane means attaching to its selected Surface. Remote-only vocabulary: +A Surface is named on the wire by `surfaceId`; the picker projects registered terminal Surfaces, as defined in "Directory (the phone's picker)". Remote-only vocabulary: * **Viewer** — one connected Client session. Multiple viewers may coexist. @@ -53,7 +47,7 @@ Source of truth: the surface model the wire shapes reuse — `dor/src/protocol.t ## Transport -**Every message below is JSON, carried as one length-prefixed application message on one authorized Noise session** that the WebSocket relay pipes without decoding, the Burrow multiplexing every session over its single relay socket (`docs/specs/relay.md` → "Routing", "E2E framing"). **Terminal data rides that same stream** — it is small and ordering matters; media channels arrive with browser surfaces ([Future](#future)). **The API and the security model are identical in selfhost and (future) SaaS modes**, where only account creation differs (`docs/specs/relay.md` → Future). +**Every message below is JSON, carried as one length-prefixed application message on one authorized Noise session** that the WebSocket relay pipes without decoding, the Burrow multiplexing every session over its single relay socket (`docs/specs/relay.md` → "Routing", "E2E framing"). **Terminal data rides that same stream** — it is small and ordering matters; media channels arrive with browser surfaces ([Future](#future)). **Must use the same API and session authorization for self-host and Hosted accounts.** Account login and Burrow enrollment belong to `docs/specs/relay.md` and `docs/specs/hosted.md`. **A `RemoteApiSession` exists only for an authorized session.** Created at promotion — presence proof and ACL conjunction both passed ([remote-security-model.md](./remote-security-model.md) → Connection) — and disposed when the Client disconnects, when the Burrow reaps the session, and by any promotion that replaces it, so **a re-authorizing Client can never inherit the previous session's attachment**. @@ -75,8 +69,8 @@ on. **Every signal rides inside the session**, as one of four control messages ([relay.md](./relay.md) → E2E framing) on the established session over the relay path: `direct-offer` (Client→Burrow, SDP), `direct-answer` (Burrow→Client, SDP), -`direct-decline` (Burrow→Client), `direct-switch` (either direction) — each -`{ v: 1, t }` with exact keys and no other field. **The Relay never sees an SDP, +`direct-decline` (Burrow→Client), `direct-switch` (either direction) — all guarded by `DirectSignalV1` in +`remote-lib-common/src/security/direct-path.ts`. **The Relay never sees an SDP, a candidate, or that a direct path exists.** **An unknown control shape on an established session is ignored, never a session failure**, so a peer without this stack simply stays relayed. @@ -203,7 +197,7 @@ by `BurrowRuntime.#promoteConnection` in Requests are correlated by `requestId`, events by `subId` (`RemoteRequest`, `RemoteResponse`, `RemoteEventMsg`). -**A subscribing method (`directory.watch`, `surface.attach`) opens its stream under the request's own id** — `requestId` reused as the `subId` — so the Client installs its handler before sending and never races a snapshot or a first data frame. **The six methods and three events are named constants** (`REMOTE_METHODS`, `REMOTE_EVENTS`) dispatched by name, so a future event lands additively and an old client ignores what it does not know. +**A subscribing method (`directory.watch`, `surface.attach`) opens its stream under the request's own id** — `requestId` reused as the `subId` — so the Client installs its handler before sending and never races a snapshot or a first data frame. **Must dispatch by the canonical `REMOTE_METHODS` and `REMOTE_EVENTS` names** in `remote-lib-common/src/remote/wire.ts`, so a future event lands additively and an old client ignores what it does not know. **Every peer-supplied `cols`/`rows` passes through `clampTerminalDimension`** — 1 … `MAX_TERMINAL_DIMENSION` (2000), falling back to the current size when absent or non-finite — on the Burrow, in the webview responder driving the real xterm, and in the Client adapter. The upper bound is the security-relevant half. (rationale) @@ -215,7 +209,7 @@ Reserved: a `capabilities` field on the client hello (what the client can render ## Directory (the phone's picker) -`directory.watch` subscribes to a live, lightweight listing of every pane — enough to render the picker and know which pane wants attention, without attaching. `DirectoryEntry` / `DirectorySnapshot` carry the terminal-only payload: identity, derived title, focus, semantic state, PTY liveness, and the `ringing` / `hasTODO` badges. Nothing else — thumbnails are staged. +**Must list registered terminal Surfaces, excluding helper Sessions.** A Tool remains listed through its terminal even while showing its browser capability. `directory.watch` subscribes without attaching; `DirectoryEntry` / `DirectorySnapshot` in `remote-lib-common/src/remote/wire.ts` own the payload. Thumbnails are staged. Reserved: **`paneRef` is set to the same value as `surfaceId`** and no Client reads it — it becomes the Pane handle when `window.watch` lands ([Future](#future), @@ -233,7 +227,7 @@ not render yet. **A late answer — one for an ask that already settled — invalidates the directory rather than being dropped**: only the next collect repairs a snapshot missing what it names. Each burrow's ask bridge applies it (`docs/specs/standalone.md`, `docs/specs/vscode.md`). -**Browser and iframe surfaces are neither listed nor attachable** — they never enter the xterm registry the directory collects from, so `surface.attach` cannot resolve them either. ([Future](#future) stages browser remoting; iframes stay unsupported even there.) +**Never list or attach standalone browser or iframe Surfaces**: neither enters the xterm registry. ([Future](#future) stages browser remoting; iframes stay unsupported even there.) **`alive` is real PTY-process liveness**, distinct from `exitCode` — the last finished command's shell-integration status: a pane may report `alive: true` with an `exitCode` set, or `alive: false` with none. **An exited pane stays listed at `alive: false`**, since Dormouse keeps it open until the user closes it, and the picker stops offering it — attaching would transfer nothing. @@ -269,7 +263,7 @@ Source of truth: `resize` in `standalone/sidecar/pty-core.js`, shared by both ho **Normal-screen history does not regenerate on resize** and is absent from the shipped protocol (see [Future](#future): in-flight replay, then semantic scrollback). -Payloads: `AttachParams`, `TerminalAttachResult`, `TerminalDataEvent`, `TerminalClosedEvent`, `TerminalWriteParams`, `TerminalResizeParams`. PTY bytes are base64url. +**Must encode PTY bytes as base64url.** Payload types live in `remote-lib-common/src/remote/wire.ts`. `terminal.data` and `terminal.closed` are the whole v1 stream: **a viewer is not notified when another display takes size authority**, and semantic state (activity/cwd/title) reaches the client only through `directory.snapshot`. The burrow→client `terminal.resize` and `terminal.semantic` events are staged in [Future](#future) (item 5). diff --git a/docs/specs/remote-network.md b/docs/specs/remote-network.md index 57e81437b..9647ce9dd 100644 --- a/docs/specs/remote-network.md +++ b/docs/specs/remote-network.md @@ -6,7 +6,7 @@ ## Policy -**The policy is one record, `{ level, allowed, autoUpdate }`, held host-side** in the Burrow state store: `dormouse.burrow.network-policy` in VS Code's `globalState`, and the sidecar's own `network-policy.json`, 0600 beside `burrow.json`. **Never keep it in `burrow.json`**, which a build from before the policy rewrites without it, letting the default recompute. **Never take it from a Client, Relay, or Hosted response**; the webview reads it with `networkPolicy` and writes it with `setNetworkPolicy`, and the Burrow service is its only writer. +**The policy is one record, `{ level, allowed, autoUpdate }`, held host-side** in the Burrow state store: `dormouse.burrow.network-policy` in VS Code's `globalState`, and the sidecar's own `network-policy.json` beside `burrow.json`, protected by the credential-directory boundary (`docs/specs/security-remote.md` -> "Credentials at rest"). **Never keep it in `burrow.json`**, which a build from before the policy rewrites without it, letting the default recompute. **Never take it from a Client, Relay, or Hosted response**; the webview reads it with `networkPolicy` and writes it with `setNetworkPolicy`, and the Burrow service is its only writer. | Level | Offered in | What Dormouse opens on its own | |---|---|---| @@ -40,7 +40,7 @@ Under `local` each runtime — a one-time link, or the persistent Burrow — is - **The attempt's UDP socket binds the one allowed address when exactly one is present**: a single interface holds every address in the allowed networks, loopback and link-local aside, and exactly one in its preferred family, IPv4 over IPv6. **Otherwise it listens on every interface** (`docs/specs/remote-security-model.md` -> "Direct path"), and the level restricts the path, not the listener. Chosen per attempt (rationale). - **Must strip every candidate outside the allowed networks from the Burrow's answer**, and send a default address outside them as `0.0.0.0`. **An answer left with no candidate refuses the attempt.** - **Must strip the phone's offer the same way before the Burrow applies it**, a hostname, mDNS name, or unreadable candidate included, so its ICE agent sends no check and makes no lookup toward an address the level does not hold. **An offer left with no candidate is still answered**: the phone's checks reach the answer's candidates, and the pair forms peer-reflexive (rationale). -- **Must check the selected candidate pair on the Burrow before its channel reports open**, and again while it is open and `connected` — on every ICE or connection state change, and every `DIRECT_PATH_RECHECK_MS` (rationale): both ends parse as IP addresses — IPv4-mapped IPv6 matching its IPv4 range — each inside an allowed CIDR. **A hostname, an mDNS name, or a pair the stack will not report refuses**, and a frame arriving before the open is checked first; once open, a reading with no pair is left to the connection's own state (rationale). +- **Must check the selected candidate pair on the Burrow before its channel reports open**, and again while it is open and `connected` — on every ICE or connection state change, and every `DIRECT_PATH_RECHECK_MS` (1,000 ms; rationale): both ends parse as IP addresses — IPv4-mapped IPv6 matching its IPv4 range — each inside an allowed CIDR. **A hostname, an mDNS name, or a pair the stack will not report refuses**, and a frame arriving before the open is checked first; once open, a reading with no pair is left to the connection's own state (rationale). - **Never trust SDP candidates, Hosted-observed addresses, or Client claims** as path evidence; only the Burrow's own ICE agent answers (rationale). - **A refusal is a violation**: it ends the session `network-not-allowed` (`docs/specs/one-time.md` -> "Burrow runtime"), switched or not. - **The check gates terminal traffic, not approval** (rationale): an off-network phone holding a link can reach the two-digit prompt and still receives no terminal byte. diff --git a/docs/specs/remote-network.rationale.md b/docs/specs/remote-network.rationale.md index 23311f488..a73adf562 100644 --- a/docs/specs/remote-network.rationale.md +++ b/docs/specs/remote-network.rationale.md @@ -57,10 +57,10 @@ needs no policy to run. ## Settings → Network **Why the list states only what is built (2026-09-30).** The list is what a -person reads to decide which level to trust, so a row for a connection no code -makes — the Hosted Relay — would promise traffic that never -happens, and a missing row would hide one that does. The prototype listed the -whole design; the real list drops each row until its stage ships. +person reads to decide which level to trust, so a row for unbuilt behavior +promises traffic that never happens, and a missing row hides one that does. +The 2026-09-30 prototype listed the whole design; `connectionsFor` now derives +rows from the shipped policy and runtime facts, including Hosted enrollment. **Why the push row names its condition (2026-09-30).** Push is on by the application default or by any Workspace's own override, and Workspaces in other diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 8b7bef1c3..f6587363d 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -2,29 +2,9 @@ > See `docs/specs/glossary.md` for Client, Burrow, Relay, and Session vocabulary. -The trust model for remote control: three primitives between the Client -(Dormouse Pocket), Burrow (Dormouse Terminal), and coordinating Relay. - -* **One end-to-end channel per ceremony** — pairing and connection each run a - Noise IK handshake whose two `CipherState`s carry everything after it, and the - Relay routes that ciphertext without reading it. -* **Passkeys prove fresh user presence** inside that channel, over a challenge - derived from the handshake itself. A passkey authenticates the user; it grants - access to no Burrow. -* **Each Client pairs explicitly, one-to-one, with each Burrow** — the Burrow keeps - its own local ACL of approved Clients, each identified by a per-Burrow X25519 - static generated in the browser; storage follows Client statics below. - -Account compromise is therefore insufficient for burrow access -([Security Guarantees](#security-guarantees)). `docs/specs/security-remote.md` -> "Remote Control" -is this model's audited face — the properties checked nightly and the gaps left -open (revocation, the audit trail). - -**Every primitive here lives in `remote-lib-common/src/security/`**, shared -verbatim by Relay, Burrow, and Pocket so the three cannot disagree on what a -valid credential is. Message sequences are [relay.md](./relay.md) (Relay); -this spec defines what they must establish. Evidence: -[remote-security-model.rationale.md](./remote-security-model.rationale.md). +> Owns ceremony trust and authorization. `docs/specs/relay.md` owns message orchestration; `docs/specs/security-remote.md` owns the audited checks and open gaps. + +**Must share cryptographic primitives through `remote-lib-common/src/security/`.** ## Goals @@ -35,10 +15,10 @@ burrow-controlled authorization; long-lived trusted client devices; passkeys. Non-goals: a compromised browser runtime or operating system; a user intentionally clearing browser data; permanent device identity across browser -resets; **availability** — the relay is down whenever the machine is (a -per-login user agent), and the Relay is a hard online dependency for every new -session but a [one-time connection](#one-time-connection)'s -([relay.md](./relay.md)); **traffic analysis** +resets; **availability** — a self-host Relay shares its deployment machine's +availability; the Relay is an online dependency for each new paired session, +and a one-time session needs Hosted's rendezvous until the direct switch +([relay.md](./relay.md); [One-time connection](#one-time-connection)); **traffic analysis** ([Residual metadata](#residual-metadata)). ## Trust Model @@ -120,7 +100,7 @@ Source of truth: `generatePocketKeyPair` in `BurrowAclRecord` binds the Burrow and account to one passkey credential and public key hash, one Client static public key, a fresh 256-bit `deliveryId`, approval metadata, and a nullable revocation time. Persisted through `BurrowStateStore` — a -0600 file in standalone, `globalState` in VS Code — **never on the Relay** +private file in standalone (0600 on POSIX; protected user-only DACL on Windows), `globalState` in VS Code — **never on the Relay** ([relay.md](./relay.md)). **Authorization is the conjunction, on one record.** `BurrowAcl.authorize` @@ -200,11 +180,11 @@ newly-added passkey is not automatically trusted; its Client must still pair. public key ([relay.md](./relay.md) owns the grammar); **it carries no Burrow static, no label, and no signature** (rationale). - **Invitation lifecycle, Burrow-owned**, and the QR panel renders it: `live` - until a valid Noise message 1 decrypts against it (`reserved`), which then - always ends `consumed`; an un-scanned one ends `expired` by TTL or `dropped` + until a valid message 1 and the responder's message 2 complete the Noise + handshake (`reserved`), which then always ends `consumed`; an un-scanned one ends `expired` by TTL or `dropped` when the Burrow discards it — lost relay socket, or evicted at the cap. **A mint whose keygen straddles a teardown is refused rather than inserted** - (rationale). **Each invitation accepts one request**; a failed decrypt leaves + (rationale). **Must accept one completed handshake per invitation**; a failed handshake leaves it live and redemption at the Relay flips nothing, and **neither may read as a scan**. - **IK against the invitation key**: Client initiator, fresh per-Burrow static as @@ -309,9 +289,9 @@ runtime that carries these rules out ("Burrow runtime"). - **The prologue binds every link field under its own kind** (field order: `docs/specs/one-time.md` -> "Link"), so a one-time transcript equals no pairing or connection transcript, and is useless in any other room. -- **The first valid message 1 reserves the link**: the one-use key is erased - with it, a later `init` is dropped before any WebCrypto, and one that fails - to decrypt reserves nothing. +- **Must reserve the link only after a valid message 1 and responder message 2 + complete the handshake.** The one-use key is erased with it; a later `init` + is dropped before WebCrypto, and a failed handshake reserves nothing. - **The one carve-out from [Presence proofs](#presence-proofs): a fresh local confirmation authorizes exactly one session and writes nothing.** `OneTimeRequestV1` carries the phone's code and label and nothing else; the @@ -442,13 +422,14 @@ runtime holds the same line against the rendezvous (`docs/specs/one-time.md` other identity at the cap gets the fixed-size `burrow-busy` and **evicts no other entry**. Pending caps and the token bucket stay active at the cap. - **A Burrow-global token bucket gates the WebCrypto an accepted `init` buys**, on - the Burrow's own clock, and **answers a refused frame with nothing** — as do - refusals by shape, size, or a pending cap (rationale). + the Burrow's own clock, and **must answer a refused init with nothing**, as for shape or size refusals. + **Must enforce pending caps after a valid handshake by evicting the oldest + pending entry**, with the outcome in the expiry table below (rationale). - **A message is processed only for its exact pending ID and expected step**: unknown IDs are dropped without decryption, established frames decrypt only at their session's next nonce, and **the first invalid ciphertext destroys its session** (rationale). -- **Rejected frames perform no WebCrypto operation and allocate no entry.** +- **Must reject malformed or over-size routing frames before WebCrypto or entry allocation.** **`MAX_RELAY_TO_BURROW_FRAME_LENGTH` is measured on the received string before `JSON.parse`** (a non-string payload is dropped) and given to the socket implementation's `maxPayload` where it takes one (rationale); the wire guard diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index e8db112b1..dc24b41ac 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -15,10 +15,10 @@ model exists to make *authorized* hard to reach and impossible to reach by accid still applies to it is [One-time connection](#one-time-connection) — the one way in without them, one session confirmed at the Burrow and writing nothing — with the direct path it runs on and the service→webview checks. Two deployment modes are -defined (`docs/specs/remote-api.md` -> "Transport"); all of the below but the -one-time connection, which needs no Relay, is **self-hosted**, the only one that -ships. Cloud-hosted is staged -([Cloud-hosted mode](#cloud-hosted-mode)). +defined (`docs/specs/remote-api.md` -> "Transport"). Self-host deployment rules +are scoped below; Hosted's implemented admin-entitled account routing and +one-time rendezvous defer to `docs/specs/security-hosted.md`. Paid activation +remains staged ([Cloud-hosted mode](#cloud-hosted-mode)). ### Trust boundary @@ -31,6 +31,10 @@ ships. Cloud-hosted is staged terminal stream; there is no negotiation, no cipher or pattern selector, no plaintext relay route, and no reader for any of the pre-cutover frames. +The setup-password and `burrowToken` escalation rows describe the self-host +Relay's passkey account. Hosted login is `docs/specs/security-hosted.md` -> +"Account boundary". + | Compromise | Buys | What still stands | | --- | --- | --- | | Relay | account state, routing metadata | **no new authorization and no plaintext**. On an established session, availability only — drop, delay, reorder, or refuse, never read and never inject — and the first invalid ciphertext destroys the session. Web Push holds **confidentiality**, not **freshness**: a kept envelope re-delivers as current, accepted residual (rationale). A session switched to the [direct path](#direct-path) leaves it the lifecycle levers alone — it can still end that session by dropping a socket, but sees, delays, and reorders none of its traffic | @@ -39,11 +43,11 @@ no plaintext relay route, and no reader for any of the pre-cutover frames. | Synced or stolen passkey | sign-in, and the ability to *ask* | the paired Client static is missing, so `BurrowAcl` answers `client-not-paired` | | Client static | use in place; encrypted fallback also permits private-byte extraction by compromised same-origin code | connecting still needs the paired passkey's fresh assertion, and it authorizes exactly one Burrow | -**The only path into a Burrow's ACL is a human typing, on that Burrow, two digits displayed -on the phone that is asking**, and the Burrow gets the comparison exactly once. **The -webview is inside the trust boundary for *relaying* a confirmation and for nothing -else** — it cannot choose what is authorized, satisfy the confirmation without the -phone, or fabricate a request (rationale). The only path back out is +**Must mint an ACL record only after the Burrow accepts one local confirmation +of the phone's two digits.** The webview relays the immutable ceremony id and +typed digits; it cannot read the expected code, choose the record, or fabricate +a pending request. A compromised webview gets one guess at an honest Client's unknown uniform +code: 1/100 success, without proving a person read the phone (rationale). Removal is [Revocation and the audit trail](#revocation-and-the-audit-trail). - **FAIL IF** the Burrow stops being the final authority: `BurrowRuntime.#onConnectionTransport` in `lib/src/remote/burrow/burrow-runtime.ts` must consume its own challenge, verify the presence proof with `verifyPresenceProof` against a binding built from the Burrow's own `burrowId`, connection id, challenge and handshake hash, and require one active `BurrowAclRecord` holding the account, the passkey credential, that key's hash, and the IK-authenticated Client static — before any session is established, and with no code path letting a Relay-supplied claim stand in for any of them. @@ -59,7 +63,7 @@ phone, or fabricate a request (rationale). The only path back out is - **FAIL IF** the Burrow's Noise static is ever sent to the Relay, or a Burrow runs with halves that do not correspond: it is minted locally *before* the enrollment request and never sent in it, persisted only where `burrowToken` is, and `BurrowService` derives the public point from the private half and compares before starting — a mismatch keeps the Burrow down (rationale). - **FAIL IF** `remote-lib-common/src/security/` stops being the shared implementation: the Relay, the Burrow, and the Pocket client must verify assertions, presence challenges, handshakes, and transport framing with the same modules. Conformance is proven against an independent implementation's published vector (`remote-lib-common/test/noise.test.mjs`), never against a value the production state machine computed, and this section's properties are driven end to end by `remote-lib-common/test/security-guarantees.test.mjs`. - **FAIL IF** `scripts/e2e-lint.mjs` and `scripts/e2e-lint-selftest.mjs` stop running in the root `pnpm test`, or a rule is added to the lint without the self-test proving it load-bearing. Each rule in `RULES` names the line above that it enforces, or one in `docs/specs/security-hosted.md` -> "Rendezvous boundary" (rationale). -- **FAIL IF** the self-host Relay (`relay/`) begins admitting an `accountId` other than `SELFHOST_ACCOUNT_ID` (`remote-lib-common/src/remote/wire.ts`), or gains a self-serve signup path. The Hosted Relay's accounts are `docs/specs/security-hosted.md` -> "Relay boundary"; Reserved: the cloud boundary is analyzed in `## Future` -> Cloud-hosted mode before Hosted carries terminal traffic. +- **FAIL IF** the self-host Relay (`relay/`) begins admitting an `accountId` other than `SELFHOST_ACCOUNT_ID` (`remote-lib-common/src/remote/wire.ts`), or gains a self-serve signup path. The Hosted Relay's accounts are `docs/specs/security-hosted.md` -> "Relay boundary"; Reserved: paid activation remains subject to `## Future` -> Cloud-hosted mode. ### Relay origin @@ -76,10 +80,10 @@ phone, or fabricate a request (rationale). The only path back out is ### Credentials at rest **Persistent credentials are a full bypass of some layer if they leak to another -local account.** Protection states the *property* — reachable only by -the owning user account — and every row reaches it the same way, the caption of the -column: mode `0700`/`0600` on unix, a one-ACE DACL on Windows, where Node's file modes -are a silent no-op. Rows carry only what is additional. +local account.** File-backed credentials use mode `0700`/`0600` on Unix and +owner-only DACLs in the installed Windows Relay and standalone Burrow; Node +modes do not protect Windows files. VS Code uses its own storage mechanisms, +as specified in each row. | Credential | Where it lives | Protection | | --- | --- | --- | @@ -87,10 +91,10 @@ are a silent no-op. Rows carry only what is additional. | Enrollment offer | `run/enroll-offer.json` in the install root, under an owner-only `run/` | mode and DACL both applied before the token is written; one-time (`docs/specs/relay.md` -> "Configuration"); never printed, the service definition and wrapper carrying only its path | | `burrowToken` (the `/ws/burrow` bearer) and the Burrow's Noise static private key | Relay `burrows.json` (the token only); Burrow side both in the enrollment record, the Noise static minted locally and never sent to the Relay (`docs/specs/remote-security-model.md` -> "Burrow identity") | the Relay state dir and every file in it; on Windows the files inherit the installer's DACL on `state`, so `manage verify` checks them individually. Burrow side a `0600` file in standalone (on Windows the app-data-dir DACL the Rust side applies), `SecretStorage` (the OS keychain) in VS Code — never a webview realm | | VAPID private key | Relay `vapid.json` | nothing additional | -| Burrow ACL | `BurrowStateStore`, keyed per `burrowId` | a `0600` file in standalone; VS Code `globalState`. Mostly public keys, with one exception: each record's `deliveryId` is a bearer capability for that Client's push rows, so a reader could delete or hijack a subscription — not reach a terminal. Neither store provides *integrity* against a same-user process and nothing here claims otherwise; the mode only stops another local **account** adding a record (rationale). Deliberately never on the Relay | +| Burrow ACL | `BurrowStateStore`, keyed per `burrowId` | a `0600` file in standalone; VS Code `globalState`, protected by VS Code's storage permissions rather than a Dormouse-applied DACL. Mostly public keys, with one exception: each record's `deliveryId` is a bearer capability for that Client's push rows, so a reader could delete or hijack a subscription — not reach a terminal. Neither store provides *integrity* against a same-user process and nothing here claims otherwise; standalone's private storage stops another local **account** adding a record (rationale). Deliberately never on the Relay | -**Without explicit modes these files inherit the umask and end up world-readable**, -handing live burrow tokens to any other local account on a shared machine. The Client's +**Must apply explicit private permissions to file-backed credentials rather +than rely on the ambient umask.** The Client's per-Burrow browser storage follows `docs/specs/remote-security-model.md` -> "Client statics". @@ -144,12 +148,12 @@ a fixed delay (rationale). ### Cross-origin access -**No browser origin but the configured one may drive the API.** Pocket is served -with the API at that origin and calls it with relative URLs; a Burrow's HTTP client runs -in its Node service, not a webview. No supported caller is a cross-origin browser, so -a grant would widen the guessing surface and buy no compatibility (rationale). +**Never grant cross-origin browser reads or authenticate from a cookie.** +Pocket uses relative API URLs at the configured origin; Burrow HTTP runs in +Node. The Relay grants no preflight or CORS response; it does not reject every +request carrying a foreign `Origin` (rationale). -- **FAIL IF** the Relay installs CORS middleware, emits `Access-Control-Allow-Origin`, or accepts authentication from a cookie — the two clauses hold each other up (rationale). Pinned by `relay/test/cors.test.mjs`. +- **FAIL IF** the Relay installs CORS middleware, emits `Access-Control-Allow-Origin`, or accepts authentication from a cookie (rationale). Pinned by `relay/test/cors.test.mjs`. ### Network posture (self-hosted) @@ -256,7 +260,7 @@ connection" owns the ceremony. - **FAIL IF** the Relay or `BurrowRuntime` can accept a one-time frame: `E2eKind` and `isE2eKind` in `remote-lib-common/src/remote/wire.ts` must admit exactly `pairing` and `connection`, and no one-time name may appear under `relay/src/`, in `remote-lib-common/src/remote/wire.ts`, or in `lib/src/remote/burrow/burrow-runtime.ts`. `scripts/e2e-lint.mjs` holds both textually. - **FAIL IF** the one-time prologue stops binding every link field under its own kind: `oneTimeLinkPrologue` in `remote-lib-common/src/security/one-time-link.ts` must hash, through `e2eOneTimePrologue` in `remote-lib-common/src/security/noise-transport.ts`, the E2E domain, `one-time`, the room id, then the link's version, expiry, and one-use key in link order. Pinned by `remote-lib-common/test/one-time-link.test.mjs`. - **FAIL IF** a one-time connection grants or writes anything that outlives it. `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts` must name no ACL, ACL store, delivery id, or presence verifier, persist nothing, and send a success outcome carrying the Burrow label alone. `scripts/e2e-lint.mjs` holds the naming textually. -- **FAIL IF** a link can admit a second phone or a second guess. The first message 1 that completes IK against the link's key must reserve the link and erase that key; every later `init` is dropped before any WebCrypto, and one that fails leaves the link open. `OneTimeRuntime.#approve` must set `attempted` before its expiry check and `constantTimeEqual`, and every outcome — success and each denial — is one padded control message sealed with `sealControl` in `lib/src/remote/burrow/established-session.ts`. +- **FAIL IF** a link can admit a second phone or a second guess. The first handshake whose message 1, message 2, and Split succeed against the link's key must reserve the link and erase that key; every later `init` is dropped before any WebCrypto, and one that fails leaves the link open. `OneTimeRuntime.#approve` must set `attempted` before its expiry check and `constantTimeEqual`, and every outcome — success and each denial — is one padded control message sealed with `sealControl` in `lib/src/remote/burrow/established-session.ts`. - **FAIL IF** the one-time approval modal can show text the phone chose, which could tell the person which digits to type. `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts` must pass the request's label through `knownOneTimeDeviceLabel` in `remote-lib-common/src/security/e2e-ceremony.ts` before `requestApproval` or any `OneTimeState` carries it, so a label that is not exactly a member of `ONE_TIME_DEVICE_LABELS` reaches the modal, the panel, and the Baseboard as `Phone browser`; the page's `oneTimeDeviceLabel` in `lib/src/remote/one-time-app/OneTimeApp.tsx` returns only members. Pinned by `lib/src/remote/burrow/one-time-runtime.test.ts` and `remote-lib-common/test/e2e-ceremony.test.mjs`. - **FAIL IF** an application message crosses the rendezvous, or a one-time session outlives a missed direct deadline. `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts` must make its one session `directOnly` on `EstablishedE2eSession`: after the outcome an application message decrypted off the rendezvous ends the session unread, and a decline, an abandoned attempt, or no switch by `DIRECT_ONLY_DEADLINE_MS` ends it — no relayed fallback. `scripts/e2e-lint.mjs` holds the flag textually. After the switch the direct channel is the lifecycle authority: the runtime closes the rendezvous, and channel loss or `ESTABLISHED_E2E_IDLE_TIMEOUT_MS` idle ends the session. - **FAIL IF** the phone can reach the room unasked or put protocol-v1 on it. `OneTimeClient` in `lib/src/remote/client/one-time-client.ts` must open its socket only inside `connectOnce`, refuse every protocol-v1 method until both directions are direct, and close the rendezvous normally at the switch; a decline, an abandoned attempt, or no switch by `DIRECT_ONLY_DEADLINE_MS` fails the attempt. It reads every frame through `parseOneTimeFrame` in `lib/src/remote/one-time-rendezvous.ts`, which measures it against `MAX_ONE_TIME_FRAME_LENGTH` before `JSON.parse`, and runs `isOneTimeBurrowFrame` on it. Pinned by `lib/src/remote/client/one-time-client.test.ts` and `lib/src/remote/client/one-time-e2e.test.ts`. @@ -281,10 +285,10 @@ restart is the whole lever — it reloads the ACL and, by dropping the relay soc every established session. Relay-pushed propagation is staged in `docs/specs/remote-security-model.md` -> "Future" (Revocation propagation). -**There is no audit trail.** The ACL records `approvedAt` / `approvedBy` for a pairing, -and nothing records connects, attaches, denials, or writes. A self-hoster cannot answer -"did anyone connect to my laptop last night", which also means an ACL entry added by -any of the paths above would be invisible after the fact. +**There is no structured audit trail covering connects, attaches, denials, or +writes.** The ACL records `approvedAt` / `approvedBy`; owner-local logs report +some rejections, without recording a complete session history. A self-hoster cannot answer +"did anyone connect to my laptop last night". ## Auxiliary helpers @@ -296,11 +300,10 @@ Source of truth: `collectDirectorySnapshot` in `lib/src/remote/burrow/directory- ### Cloud-hosted mode -Nothing here is implemented; it exists so the boundary is stated before the code -arrives. When Dormouse operates the coordinating Relay, "Relay compromise buys no -Burrow access" is unchanged, but two things change character and must be re-analyzed here -rather than inherited: +Hosted's admin-entitled routing is implemented (`docs/specs/security-hosted.md` +-> "Relay boundary"). Broad paid activation remains staged; its review must +cover these operator responsibilities: -- **We become the operator** of the relay. The end-to-end protocol keeps ceremony, terminal, remote-api, and notification content out of that operator's reach; what stays visible is exactly the metadata in `docs/specs/remote-security-model.md` -> "Residual metadata". +- **Must review Hosted operator handling of residual metadata before paid activation.** The visible metadata is `docs/specs/remote-security-model.md` -> "Residual metadata"; the trust boundary above still excludes plaintext and new Burrow authorization. - **An independent cryptographic review is a precondition** of claiming this model for a paid service (`docs/specs/remote-security-model.md` -> "Security Guarantees"). -- **The tailnet stops carrying load.** Every argument above that leans on "the origin is reachable only from the user's tailnet" has no cloud equivalent, and the multi-tenant account model replaces the single-owner setup password entirely (`docs/specs/hosted.md` -> "Burrow enrollment"). +- **Never rely on tailnet reachability for paid Hosted admission.** Review public admission and the multi-tenant account boundary before activation (`docs/specs/hosted.md` -> "Burrow enrollment"); the self-host setup password supplies no Hosted identity. diff --git a/docs/specs/security-remote.rationale.md b/docs/specs/security-remote.rationale.md index 909eb46a8..c0b7b8206 100644 --- a/docs/specs/security-remote.rationale.md +++ b/docs/specs/security-remote.rationale.md @@ -25,15 +25,14 @@ against. Relay demanding user verification while the Burrow did not would leave the weaker verifier deciding. -**Why the webview can relay a confirmation safely.** Reading the two digits requires -holding the device — a relayed or injected request has no screen to read from. The -confirmation arrives as a bridge command carrying the displayed ceremony's immutable -`pairingId` and the typed digits; the service, not the webview, holds the expected code -and decides whether that ceremony is still confirmable, which is what leaves the webview -unable to choose what is authorized, to satisfy a confirmation without the phone, or to -fabricate a request. A mirrored code would make the confirmation something anything in -the webview realm could satisfy, and a leaked invitation key would let a photographed QR -be completed by whoever holds it. +**Why the expected code stays in the Burrow process.** The bridge carries the +displayed ceremony's immutable `pairingId` and typed digits; only the service +knows the expected code and the pending record. Mirroring the code would let +compromised webview code satisfy every confirmation. Keeping it private leaves +one guess per ceremony, not an absolute proof that a person read the phone. +`samplePairingCode` samples uniformly over 100 values; local confirmation spends +`attempted` before comparing, so guessing succeeds with probability 1/100 for a +ceremony whose other gates already passed. **Why the pending maps need caps on both sides.** Every `e2e` frame allocates under a `clientId` the relay chooses, in both `BurrowRuntime`'s client map and the service's @@ -104,10 +103,8 @@ build was never pointed at. That is also why any new Burrow→Relay call goes th home-directory permissions vary by distro — `0700` on RHEL, `0755` historically on Debian, `0750` on Ubuntu since 21.04 — so without an explicit mode, whether a second account can read `burrows.json` depends on which distro the selfhoster happened to pick. -It buys nothing on Windows, where modes are a no-op and the profile ACL already -excludes other accounts; nothing in a container, where the namespace is the boundary; -and nothing on a serverless deployment backed by a database, where this file never -runs. +Windows modes are a no-op; the installed Relay and standalone Burrow need +native DACL controls instead. A database-backed Worker does not use this file. **What the Burrow ACL's file mode does not buy.** Neither store defends its records against a process running as the same user, and nothing in the table claims it does — a @@ -233,3 +230,6 @@ Both gaps are stated in this spec rather than left in a Future list for two reas the audit's qualitative pass should not keep rediscovering them as findings, and a reader deciding whether to run this needs to know that "revoke a device" is not currently something they can do quickly. + +The owner-local rejection logs are diagnostic fragments, not a complete or +structured record of successful connects, attaches, or writes. diff --git a/lib/src/components/RemoteControlSection.tsx b/lib/src/components/RemoteControlSection.tsx index c0b3fb280..8698d72b3 100644 --- a/lib/src/components/RemoteControlSection.tsx +++ b/lib/src/components/RemoteControlSection.tsx @@ -291,7 +291,7 @@ function outcomeSentence( /** * The phone-setup panel's whole lifecycle: mint on open, replace the code before - * it dies, and flip to spent when the Relay says the phone used it. + * it dies, and flip to spent when the Burrow retires its invitation. * * **Its own busy and error, not the section's {@link useBusyAction}.** A mint * here fires on a timer rather than on a click: running it through the shared diff --git a/lib/src/host/remote/burrow-state-store.ts b/lib/src/host/remote/burrow-state-store.ts index 41d5137f4..27229a573 100644 --- a/lib/src/host/remote/burrow-state-store.ts +++ b/lib/src/host/remote/burrow-state-store.ts @@ -27,13 +27,10 @@ export type { BurrowAclRecord }; export interface BurrowStateStore { /** - * Whether a write survives this process. Only the dev-harness store (no state - * directory) says `false`. Required rather than optional so a store that - * forgot to answer is not silently read as durable. - * - * Nothing reads it today — its consumer went with the webview-persisted Burrow - * hand-off — and it is kept as the store contract's own statement of - * durability, which an implementor must make before anything can rely on it. + * Whether a write survives this process. A host without a usable private + * state directory uses an ephemeral store and reports `false`. Required + * rather than optional: an implementor must state its durability before + * a consumer can rely on it. */ readonly persistent: boolean; loadEnrollment(): Promise; diff --git a/lib/src/remote/burrow/activation.ts b/lib/src/remote/burrow/activation.ts index 485552e56..3c40470e8 100644 --- a/lib/src/remote/burrow/activation.ts +++ b/lib/src/remote/burrow/activation.ts @@ -1,7 +1,7 @@ /** * Activation glue: wires this webview to the Burrow service behind the * platform adapter, and exposes a `window.dormouseBurrow` console hook for - * enrolling in the POC (no settings UI needed). + * enrollment scripting alongside the Settings UI. * * The Burrow itself is a service in the process that owns the PTYs * (`lib/src/host/remote/service.ts`) — the Tauri sidecar, the VS Code extension diff --git a/lib/src/remote/burrow/burrow-runtime.ts b/lib/src/remote/burrow/burrow-runtime.ts index 0463357f8..9d70da2f7 100644 --- a/lib/src/remote/burrow/burrow-runtime.ts +++ b/lib/src/remote/burrow/burrow-runtime.ts @@ -373,8 +373,8 @@ export class BurrowRuntime { * (`docs/specs/remote-security-model.md` → Pairing). * * Kept on the Burrow rather than in the service that composes the QR so its - * lifetime *is* this Burrow's: a new Burrow starts with none, and a Burrow that - * reconnects keeps the codes still on screen. Capped at + * lifetime *is* this Burrow's: a new Burrow starts with none, and losing its + * Relay socket retires every outstanding invitation. Capped at * {@link MAX_TOKENS_PER_BURROW}, the Relay's own bound on the setup tokens * these ride with, so the two sides agree on live-versus-spent. */ @@ -1153,8 +1153,8 @@ export class BurrowRuntime { session = new NoiseTransportSession(handshake.session); handshakeHash = toBase64Url(session.handshakeHash); } catch { - // The invitation stays live: nothing decrypted against it, so no scanner - // has been spent — only a valid message 1 reserves one. + // The invitation stays live until both handshake messages complete; + // a failed read or response spends no scanner. return; } // Nothing above allocated a client entry: a handshake that fails must cost diff --git a/lib/src/remote/burrow/burrow-status-store.ts b/lib/src/remote/burrow/burrow-status-store.ts index b11566c1e..34c176521 100644 --- a/lib/src/remote/burrow/burrow-status-store.ts +++ b/lib/src/remote/burrow/burrow-status-store.ts @@ -74,7 +74,7 @@ let generation = 0; * Publish a new state, skipping a write that says the same thing. * * The poll re-reads every 2 s and the service answers with a fresh object each - * time, so without this the section re-renders twice a minute to paint + * time, so without this the section re-renders on every poll to paint * identical text. The sibling store this same dialog reads guards the same way * (`setPushDevices` in `lib/src/lib/push-devices.ts`); comparing the fields in * {@link STATUS_FIELDS} is the whole of it. diff --git a/lib/src/remote/burrow/directory-collect.ts b/lib/src/remote/burrow/directory-collect.ts index 58b47370d..532f900fa 100644 --- a/lib/src/remote/burrow/directory-collect.ts +++ b/lib/src/remote/burrow/directory-collect.ts @@ -1,9 +1,9 @@ /** * The impure half of the directory: reads the live terminal registry, pane * state store, and activity store to produce the `DirectoryEntry[]` the phone's - * picker renders. Every entry is a terminal pane (the POC is terminal-only); - * browser/iframe surfaces never enter the xterm registry, so iterating it lists - * exactly the terminal panes. + * picker renders. It projects registered terminal Surfaces except helper + * Sessions, including a Tool's terminal while its browser face is displayed. + * Standalone browser/iframe Surfaces never enter the xterm registry. */ import type { DirectoryEntry } from 'remote-lib-common'; diff --git a/lib/src/remote/burrow/enrollment.ts b/lib/src/remote/burrow/enrollment.ts index 0dd5c235c..37504747b 100644 --- a/lib/src/remote/burrow/enrollment.ts +++ b/lib/src/remote/burrow/enrollment.ts @@ -43,7 +43,7 @@ export interface BurrowEnrollment { * * **Local only.** It is delivered to a Client inside the encrypted pairing and * connection outcomes and nowhere else; the Relay never stores or sees it - * past the enroll request. Optional because an enrollment persisted before + * in an enrollment request. Optional because an enrollment persisted before * this field existed must keep loading rather than reading as un-enrolled. */ label?: string; @@ -65,7 +65,7 @@ export interface BurrowEnrollment { * and it lives only where the enrollment lives, which is owner-only storage * on both burrows (`docs/specs/security-remote.md` → "Credentials at rest"). Optional today * because an enrollment persisted before this field existed must keep - * loading; nothing reads it yet. + * loading; the service backfills a missing static before starting. */ noiseStaticPrivateKey?: string; /** The raw 32-byte public half of that static, base64url. */ diff --git a/lib/src/remote/burrow/one-time-runtime.ts b/lib/src/remote/burrow/one-time-runtime.ts index ac08a0508..1b80f698c 100644 --- a/lib/src/remote/burrow/one-time-runtime.ts +++ b/lib/src/remote/burrow/one-time-runtime.ts @@ -183,7 +183,7 @@ export interface OneTimeRuntimeOptions { readonly setTimer?: RemoteTimer; } -/** A phone that completed message 1: the link is reserved for it. */ +/** A phone whose IK handshake and Split completed: the link is reserved for it. */ interface ReservedPhone { readonly session: NoiseTransportSession; /** Set once its first control message parsed; until then there is nothing to confirm. */ @@ -468,8 +468,8 @@ export class OneTimeRuntime { // --- The ceremony ---------------------------------------------------------- /** - * Noise message 1 against the link's key. **The first valid one reserves the - * link**; every later one is dropped before any WebCrypto runs. + * Noise message 1 against the link's key. **Reserve only after message 2 and + * Split also succeed**; every later init is dropped before any WebCrypto runs. */ async #onInit(ct: string): Promise { const link = this.#link; @@ -491,7 +491,7 @@ export class OneTimeRuntime { message2 = await handshake.writeMessage(); session = new NoiseTransportSession(handshake.session); } catch { - // The link stays open: nothing decrypted against it, so nobody holds it. + // The link stays open: IK and Split did not both complete, so nobody holds it. return; } // Ended while the WebCrypto ran — the end erases the key. diff --git a/remote-lib-common/src/security/noise-transport.ts b/remote-lib-common/src/security/noise-transport.ts index 673b13fad..2acf7f38f 100644 --- a/remote-lib-common/src/security/noise-transport.ts +++ b/remote-lib-common/src/security/noise-transport.ts @@ -3,7 +3,7 @@ * (`docs/specs/relay.md` -> "Routing" -> "E2E framing"). * * One implementation, so no two speakers can disagree about what a transport - * plaintext is; today the harness is the only one. It knows nothing about the + * plaintext is across the Burrow, Pocket, and one-time phone. It knows nothing about the * relay envelope that carries the ciphertext — routing metadata is never * authenticated application content. */ diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 552d140fe..34e3eecb2 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -19,9 +19,9 @@ "docs/specs/one-time.md": 3850, "docs/specs/pocket-app.md": 5200, "docs/specs/relay.md": 11100, - "docs/specs/remote-api.md": 5300, + "docs/specs/remote-api.md": 5200, "docs/specs/remote-network.md": 2850, - "docs/specs/remote-security-model.md": 5450, + "docs/specs/remote-security-model.md": 5400, "docs/specs/security-audit.md": 2100, "docs/specs/security-ci.md": 2950, "docs/specs/security-hosted.md": 2350,