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
10 changes: 4 additions & 6 deletions SELF_HOST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/mouse-and-clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<uuid>-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 `<uuid>-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.

Expand Down
26 changes: 10 additions & 16 deletions docs/specs/pocket-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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`;
Expand Down Expand Up @@ -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
Expand Down
47 changes: 20 additions & 27 deletions docs/specs/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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). |

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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`.

Expand All @@ -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.
Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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#<userCode>`. A dev Hosted build
Expand Down Expand Up @@ -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`
Expand Down
15 changes: 8 additions & 7 deletions docs/stories/pairing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/src/remote/client/pocket-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ export function promisifyTransaction(tx: IDBTransaction): Promise<void> {
/**
* 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
Expand Down
3 changes: 2 additions & 1 deletion relay/src/runtime-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 4 additions & 6 deletions relay/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await mkdir(this.#stateDir, { recursive: true, mode: 0o700 });
Expand Down
Loading
Loading