diff --git a/SELF_HOST.md b/SELF_HOST.md index 8ca984a44..0cc0552b1 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -589,10 +589,8 @@ refuse to rewrite an origin; and **every Burrow is rebuilt with that origin** backup story as any other install (checkpoint 6): `config/` and `state/` hold Burrow bearer credentials and a VAPID private key. -A managed cloud deployment would buy a stable origin independent of any one -machine's name — the one thing the above does not give — and belongs with the -multi-tenant work in `docs/specs/relay.md` `## Future`, not with a single-user -install. +Managed cloud accounts and deployment belong to `docs/specs/hosted.md` -> +"Application boundary". ## Installer contract (maintainers) @@ -705,8 +703,8 @@ reports which mode is live rather than asserting either. (`manage rollback`, `manage restart`), which also absorbs the window where an outgoing process answers one last time. `run-relay` passes `DORMOUSE_RUNTIME_FILE` and `DORMOUSE_RELEASE_ID` (`docs/specs/relay.md` → - Configuration); the Relay records `{pid, releaseId, port, origin, startedAt}` - there once **bound**, so `listening_release` (macOS, Linux) / + Configuration); the Relay writes `RuntimeInfo` only once **bound**, so + `listening_release` (macOS, Linux) / `Get-ListeningRelease` (Windows) is a file read, a port match and a liveness check. **It cannot go in `/api/hello`**, which is unauthenticated and reachable through the HTTPS proxy. **Empty means unknown, never diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 0c32b9c9f..567126867 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -320,7 +320,7 @@ Paste reads the clipboard in three tiers, preferred in order: 1. **File references** (a Finder/Explorer Copy of a file). Each path is shell-escaped; the space-joined list is written to the PTY with a trailing space, so the next token starts cleanly. 2. **Plain text.** The adapter's native `readClipboardText` where it has one, else `navigator.clipboard.readText()`. **Never reverse that order** (rationale). A non-empty string goes to the PTY (bracket-wrapped, §8.5). -3. **Raw image data.** Only when both of the above come back empty and the clipboard holds image bytes (e.g. a `Cmd+Shift+4` screenshot): the bytes are written to a newly-created private temp directory as `-clipboard.png`, and that path is pasted as in tier 1. **On Unix-like systems the temp directory is owner-only and the image file owner-read/write**, so clipboard screenshots are not exposed to other local users. File and directory are unlinked ~5 minutes later (rationale). +3. **Raw image data.** If file references and text are empty, save image bytes as `-clipboard.png` in a new temp directory and paste its path as tier 1. **Must use owner-only directory and owner-read/write file modes on Unix-like systems.** Windows storage limits: `docs/specs/security-local.md` -> "Browser panes". File and directory cleanup runs after ~5 minutes while the host remains running (rationale). **Tiers 1 and 2 are read in parallel** (independent IPC roundtrips) and the file reference wins; tier 3 is sequential because it allocates a temp file. Every tier empty ⇒ silent no-op. diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index d6c3fdea2..b16fa5dc6 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -28,8 +28,8 @@ Pocket is therefore: out on `FakePtyAdapter` by `website/src/components/PocketTerminalExperience.tsx`. -Three phases, one component: `SetupOrSignin`, `BurrowsView`, then `ConnectedView` -wrapping `PocketWall`. **Everything outside the PTY core no-ops or is absent** — +`Phase` in `lib/src/remote/pocket-app/App.tsx` owns screen state; `ConnectedView` +wraps `PocketWall`. **Everything outside the PTY core no-ops or is absent** — `getCwd` → null, shells/clipboard empty, alerts inert, `alertAwait` settling `cancelled` rather than never resolving. @@ -142,7 +142,7 @@ Three details the table leaves implicit: - **Exited surfaces stay in the directory** with `alive:false` as history, so the wall filters them out of selectable sessions and the active-pane default. -**The pinned record picks a row's one action.** The Burrows view — titled +**The pinned record picks a row's primary action.** The Burrows view — titled **Burrows** (rationale) — lists the `KnownBurrowV1` records (no record, no row), labeled from the record and stamped online from `GET /api/burrows`, offering Connect alone or **Pair again** alone, never a Connect that can only fail. @@ -163,7 +163,7 @@ list**, showing that copy instead where the Burrow is gone; a failed re-read keeps the original. Source of truth: `PlatformAdapter` in `lib/src/lib/platform/types.ts`; -`SetupOrSignin` / `BurrowsView` / `ConnectedView` and the `probeNoiseSupport` +`Phase`, `SetupOrSignin` / `BurrowsView` / `ConnectedView` and the `probeNoiseSupport` gate in `lib/src/remote/pocket-app/App.tsx`; `PairingCodeView` in `lib/src/remote/pocket-app/views.tsx`; `mountRemoteWall` in `lib/src/remote/pocket-app/remote-wall.ts`; @@ -578,18 +578,12 @@ replacement refused after the request has gone leaves none. Pinned by outcome arrives` and `leaves a working session alone when the replacement never reaches the Burrow` in `lib/src/remote/client/pocket-client.test.ts`. -**The connected header names the live path** — `relay` or `direct`, captioned, -never coloured — so a relayed fallback is visible rather than silent, **with the -reason behind it in the hover text and never in the label**: an attempt that -quietly stayed relayed is still `relay`, and a third state for the common case -would read as a fault. **The transport hands up a `DirectRelayCause`, never its -failure text**, and Pocket owns the sentence for each: what an attempt fails -with includes a runtime's own exception message, which belongs in the operator's -log. **A -channel that dies after this session has switched is burrow loss**: the phone -leaves the wall exactly as it does for a `burrow-gone`, and returning costs a -fresh handshake and one WebAuthn prompt. Before the switch a failed channel -costs nothing. +**Must label the connected header with the live path, `relay` or `direct`, +without status colours**, keeping fallback reasons in hover text. **Must pass +only a `DirectRelayCause` to Pocket, never runtime failure text**; +`TRANSPORT_RELAY_CAUSES` owns the displayed sentences. **Must treat a channel +failure after the switch as Burrow loss**, returning to the list and requiring +a fresh handshake and WebAuthn prompt; before the switch, a session permitting relay stays relayed. Source of truth: `PocketClient.connect` in `lib/src/remote/client/pocket-client.ts`; `deploymentDirectPeer` in diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 4939db49e..1e54f6e79 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -9,7 +9,7 @@ The coordinating Relay from the remote security model, in selfhost mode, cut down to the smallest thing that completes this loop: > Run the Relay; it generates its setup password. Enroll your laptop's Dormouse -> Terminal with it. Point your phone's camera at the code that Burrow shows: it creates a +> Terminal with it. Scan the Burrow's code inside Pocket: it creates a > passkey, signs in, and pairs. Pick up a running terminal session from the > laptop on the phone. @@ -60,7 +60,7 @@ Production configuration (`pnpm --filter relay start`, containers, and installer | `DORMOUSE_BIND_HOST` | Interface to listen on; unset binds every interface (below). | | `DORMOUSE_VAPID_PUBLIC_KEY` / `DORMOUSE_VAPID_PRIVATE_KEY` | Web Push signing keypair; set both or neither. At startup the Relay decodes both, derives the P-256 public point from the private key, and exits on a missing, malformed, or mismatched pair. Unset, it mints a pair on first boot into `vapid.json`. | | `DORMOUSE_VAPID_SUBJECT` | `mailto:`/`https:` contact for push-service operators (RFC 8292), defaulted from `DORMOUSE_ORIGIN` and validated at startup — Web Push below. | -| `DORMOUSE_RUNTIME_FILE` | Absolute path the Relay records `{pid, releaseId, port, origin, startedAt}` into once it has **bound**, mode `0600`. Unset — dev, containers, every test — writes nothing. A relative value is a `ConfigError`; the installers keep it in `run/`, outside `DORMOUSE_STATE_DIR` (`SELF_HOST.md`), which nothing in the Relay enforces (rationale). | +| `DORMOUSE_RUNTIME_FILE` | Absolute runtime-record path, written only after binding (POSIX mode `0600`). Unset writes nothing. A relative value is a `ConfigError`; the installers keep it in `run/`, outside `DORMOUSE_STATE_DIR` (`SELF_HOST.md`), which nothing in the Relay enforces (rationale). | | `DORMOUSE_RELEASE_ID` | The release directory's name, supplied by the installer's `run-relay` wrapper, recorded in the runtime file. `null` when the Relay was not started by an installer. | | `DORMOUSE_ENROLL_TOKEN_FILE` | Absolute path to the installer's enrollment offer — `{origin, token, mintedAt}`, shape in `remote-lib-common/src/remote/enroll-offer.ts` — which `POST /api/burrow/enroll` accepts in place of the setup password; unset, one-click enrollment is off. A relative value is a `ConfigError` (rationale). | @@ -106,8 +106,8 @@ rather than re-parsing it; `createApp` re-checks that same shape and takes **`startRelay` in `relay/src/start.ts` validates the VAPID pair and subject before building the app** — the only disk half of an otherwise pure env→config mapping. -Source of truth: `readConfig` in `relay/src/config.ts`, -`relay/src/enroll-token.ts`, `relay/scripts/dev.mjs`; pinned by `relay/test/config.test.mjs`, +Source of truth: `readConfig` in `relay/src/config.ts`; `RuntimeInfo` in +`relay/src/runtime-file.ts`; `relay/src/enroll-token.ts`, `relay/scripts/dev.mjs`; pinned by `relay/test/config.test.mjs`, `relay/test/runtime-file.test.mjs`, `relay/test/bind-host.test.mjs`, `relay/test/enroll-token.test.mjs`. @@ -212,8 +212,7 @@ hand-editing them is the documented revocation mechanism (Guardrails): - `setup-password.json` — `{ password, createdAt }`; Relay-generated once **Must refuse a malformed singleton record** (`account.json`, `vapid.json`, -`setup-password.json`) rather than read it as first boot and mint over it — a -whole-file `null` is otherwise indistinguishable from absence. The collection files +`setup-password.json`) rather than mint over it as first boot. Collection files keep their row-level tolerance. Source of truth: `loadRecord` in `relay/src/state.ts`; test: `relay/test/state-records.test.mjs`. @@ -225,8 +224,8 @@ a per-store promise chain**, so a crash cannot leave an unparseable file and two concurrent read-modify-writes cannot lose each other. `burrows.json` stores `burrowToken` — the burrow↔Relay bearer secret — in plaintext, `setup-password.json` the enrollment credential, and `vapid.json` a private key, -so the state dir is created `0o700` and every write lands in a -`0o600` temp file before the rename. **Any new file under `$DORMOUSE_STATE_DIR` +so POSIX creation uses `0o700` for the state directory and `0o600` for temp +files before rename. Windows writes inherit the containing directory's DACL. **Any new file under `$DORMOUSE_STATE_DIR` must go through `writeAtomic`.** **Never build anything on that mode** (rationale); what protects the *installed* Relay’s state is the installer's directory permissions, in "Installing it" below. @@ -300,12 +299,14 @@ parse. **Assertions go through `verifyPasskeyAssertion` in `remote-lib-common`, the same function the Burrow uses**, so Relay and Burrow cannot disagree on what a valid assertion is. -`POST /api/setup/finish` takes `{ credentialId, publicKey, clientDataJSON }`. +`POST /api/setup/finish` takes `{ credentialId, publicKey, clientDataJSON, label? }`. `checkRegistration`, which this and the Hosted Relay both call, checks, in order, each a 400: `clientDataJSON` decodes; `type === 'webauthn.create'`; its challenge redeems; `origin` equals the configured origin; the public key imports as an ECDSA P-256 verify key — refusing anything assertions could not -later be verified against; and the credential id is bounded base64url. Each +later be verified against; and the credential id is bounded base64url. **Must +redeem the challenge before the origin check**, so a wrong-origin registration +still burns its challenge. Each Relay's store then requires the credential id be new (409 otherwise, so a re-registered credential cannot silently displace a stored key). @@ -767,18 +768,13 @@ rules: * **A store that cannot persist still holds what it is given** in memory and reports `persistent: false` rather than dropping writes. -Each store's mechanics — the sidecar's single 0600 JSON file, rename semantics, -and memory fallback; VS Code's SecretStorage/globalState split and cross-window -memo invalidation — live in that burrow's spec. +Source of truth: `FileBurrowStateStore` in `lib/src/host/remote/burrow-state-store.ts`; host persistence boundaries follow `docs/specs/standalone.md` → "Burrow service" and `docs/specs/vscode.md` → "Burrow: a service in the extension host". * **Enrollment** (Settings dialog, or the console hook, once): one credential → `POST /api/burrow/enroll` at the baked origin (Relay origin) → the service persists - `{ relayUrl, burrowId, burrowToken, origin, rpId }` (+ `requireUserVerification` - when the Relay sent it, + the `noiseStaticPrivateKey` / - `noiseStaticPublicKey` this Burrow mints locally **before** the request and - never sends in it — - [remote-security-model.md](./remote-security-model.md)) through its - `BurrowStateStore`, then opens and maintains `GET /ws/burrow` under every + the `isEnrollment`-validated record through `BurrowStateStore`. **Must mint + the Noise static before requesting enrollment; never include either half in the request** + ([remote-security-model.md](./remote-security-model.md)). It then opens and maintains `GET /ws/burrow` under every network policy level but `nothing`, which holds the enrollment without a socket ([remote-network.md](./remote-network.md) -> Policy). **Must persist the operator's `label` locally and disclose it only inside encrypted outcomes** — the request body @@ -822,13 +818,9 @@ memo invalidation — live in that burrow's spec. - **Never change what ended until the new begin has its code.** - **Must detach a cancelled begin immediately**, so a new begin never joins its stale request. - - **The device code never leaves the service**, as `burrowToken` does not: - `status` carries `hostedEnrollment` — `waiting` with `userCode`, - `verificationUrl`, `expiresAt`, and `accountFull`; `redeeming`; or `ended` - with a reason from `HOSTED_ENROLLMENT_END_REASONS`, `answer-lost` naming its - `burrowId` — and `accountOrigin` - (`accountOriginFor`: `HOSTED_ACCOUNT_ORIGIN` in a release build, the last - begin's account origin in a dev one). Each change is a `status` event. + - **Never send the device code to the webview**, as for `burrowToken`. + `BurrowConsoleStatus` owns the public enrollment state and account origin; + each change is a service→webview `status` event. - **Must compose the verification URL, never take it from the Relay in a release build**: `enrollVerificationUrl` answers `HOSTED_ACCOUNT_ORIGIN/enroll#`. A dev Hosted build @@ -937,7 +929,8 @@ Source of truth: `lib/src/host/remote/service.ts` (`BurrowService`, `#enrollWith`, `#adoptEnrollment`, `#beginHostedEnrollment`, `#pollHostedEnrollment`, `#redeemHostedEnrollment`, `enrollVerificationUrl`, `accountOriginFor`, `#status`, `#setupQr`, -`unenrolledStatus`, lifecycle + console commands), +`unenrolledStatus`, lifecycle + console commands); `BurrowConsoleStatus` in +`lib/src/host/remote/service-protocol.ts`, `lib/src/host/remote/burrow-state-store.ts`, `lib/src/host/remote/serial-queue.ts`, `lib/src/remote/burrow/enrollment.ts` (`performEnrollment`, `beginHostedEnrollment`, `pollHostedEnrollment`), `isBurrowEnrollBeginResponse` diff --git a/docs/stories/pairing.mdx b/docs/stories/pairing.mdx index 05e578636..b59e39dd9 100644 --- a/docs/stories/pairing.mdx +++ b/docs/stories/pairing.mdx @@ -61,8 +61,8 @@ below asks you to trust it with any of them. ## 1. Stand up the Relay -The whole self-host story today is one coordinating Relay on your own laptop, -reachable only from your tailnet. One idempotent command builds the current +This walkthrough uses one coordinating Relay on your own machine, +fronted by Tailscale Serve. One idempotent command builds the current checkout into a self-contained release and installs it: ```sh @@ -215,8 +215,8 @@ An enrolled machine can mint one, on request: That code is the origin you would otherwise type into mobile Safari, carrying a **setup token** the Relay issues and will redeem in place of the setup -password, plus an **invitation**: an id and a one-use public key that are the -*laptop's own* and never go near the Relay. The invitation's private half stays +password, plus an **invitation**: an id and a one-use public key minted by the +laptop. The Relay sees the id when routing the handshake. The private half stays in the laptop's Burrow process, so a phone that completes a handshake against it has provably read this screen — which is what makes it worth something in §6. (`docs/specs/relay.md` → Setup tokens and the pairing QR owns the grammar.) @@ -312,7 +312,8 @@ from the record, which learned it inside an encrypted pairing outcome; only the online dot comes from the Relay. The card above them is push, which §7 comes back to. -Each row offers exactly one action, and nothing is asked to decide which. +The local record and Relay presence choose each row's primary action. +A machine removed from the account offers **Forget**; otherwise **Connect** is offered where this browser holds an authorized record, and **Pair again** where an authenticated denial took that authorization away — which is the only thing that can move a row, and which keeps the pinned Burrow key @@ -338,8 +339,8 @@ phone relay burrow (laptop) |<-- outcome (encrypted) -----|<-- outcome -----------------| ACL record saved ``` -The handshake runs against the one-use key printed in the QR, which never left -the laptop, so a phone that completes it has provably read *this* screen. The +The handshake runs against the QR's one-use public key; its private half stays +on the laptop, so completing it proves the phone read *this* screen. The Relay sees two routing ids and a handshake hash. Then the phone shows two digits, and the laptop asks for them. The phone paints diff --git a/lib/src/remote/client/pocket-db.ts b/lib/src/remote/client/pocket-db.ts index aaaedd7af..a3aeb79a8 100644 --- a/lib/src/remote/client/pocket-db.ts +++ b/lib/src/remote/client/pocket-db.ts @@ -379,7 +379,7 @@ export function promisifyTransaction(tx: IDBTransaction): Promise { /** * Ask the browser to keep this origin's storage, best-effort. * - * **Never throws and never blocks a write.** The keys here are recoverable by + * **Never throws; a refusal never prevents a write.** The keys here are recoverable by * re-pairing (`docs/specs/remote-security-model.md` → Client static loss), so a * browser that refuses, or has no `navigator.storage` at all — Safari answers * nothing here — gets the ordinary eviction-prone storage rather than an diff --git a/relay/src/runtime-file.ts b/relay/src/runtime-file.ts index 113f8f643..d9caf3532 100644 --- a/relay/src/runtime-file.ts +++ b/relay/src/runtime-file.ts @@ -25,7 +25,8 @@ export interface RuntimeInfo { } /** - * Write `info` to `path` atomically, mode `0600`. + * Write `info` to `path` atomically, POSIX mode `0600`; Windows inherits the + * containing directory's DACL, protected by the shipped installer. * * Called only after a successful bind: writing before would claim a port this * process may fail to take, which is precisely the confusion the file exists to diff --git a/relay/src/state.ts b/relay/src/state.ts index 60be4dd1f..2826051de 100644 --- a/relay/src/state.ts +++ b/relay/src/state.ts @@ -167,12 +167,10 @@ abstract class JsonFileStore { /** * Overwrite the whole file atomically (temp file + rename). `burrows.json` - * holds `burrowToken` in plaintext, so the directory is owner-only (`0o700`) - * and every file owner-read/write (`0o600`) — without an explicit mode both - * inherit the umask, which on a typical Linux box yields world-readable - * `0o755`/`0o644` and leaks live burrow tokens to every other local account. - * The mode only applies when the file is created, so `rename` onto an - * existing path keeps the temp file's `0o600`. + * holds a plaintext bearer: POSIX creation uses `0o700` for the directory + * and `0o600` for each replacement file. Windows inherits the directory DACL; + * the installer establishes its privacy before Relay startup. Existing + * directory modes are not tightened here. */ protected async writeAtomic(value: unknown): Promise { await mkdir(this.#stateDir, { recursive: true, mode: 0o700 }); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 34e3eecb2..f9ce01cba 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -17,8 +17,8 @@ "docs/specs/mobile-terminal-ui.md": 2100, "docs/specs/mouse-and-clipboard.md": 5000, "docs/specs/one-time.md": 3850, - "docs/specs/pocket-app.md": 5200, - "docs/specs/relay.md": 11100, + "docs/specs/pocket-app.md": 5150, + "docs/specs/relay.md": 11000, "docs/specs/remote-api.md": 5200, "docs/specs/remote-network.md": 2850, "docs/specs/remote-security-model.md": 5400,