From 14262c8376dfe995d9e195992b0fcdfb6996934f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 23:57:21 -0700 Subject: [PATCH 1/4] Hosted stores the name each computer enrolled under The remote enrollment poll now carries the Burrow's label (by default its hostname), reduced by the shared reduceBurrowLabel: control, format, surrogate, and line/paragraph separator characters gone, whitespace collapsed, at most 64 code points. Hosted stores it in a new nullable dormouse_relay_burrows.label column whose CHECK refuses what the reduction strips, and the account route lists it. An older Burrow sends none and still enrolls. The self-host Relay still learns no label, and no Pocket route returns Hosted's. The privacy page lists the name among what Hosted stores for an enrolled computer. Co-Authored-By: Claude Opus 5.5 --- .../passkey-removal-keeps-sessions.patch | 8 ++-- docs/specs/hosted.md | 3 +- docs/specs/relay.md | 4 +- docs/specs/remote-security-model.md | 2 +- .../012_relay_burrow_label.sql | 14 ++++++ hosted/server/relay-account.ts | 2 +- hosted/server/relay-api.ts | 9 ++-- hosted/server/tests/migrations.test.ts | 2 + hosted/server/tests/relay.test.ts | 46 ++++++++++++++++++- hosted/server/tests/workers.test.ts | 10 ++-- lib/src/host/remote/service.test.ts | 4 +- lib/src/remote/burrow/enrollment.test.ts | 19 ++++++-- lib/src/remote/burrow/enrollment.ts | 33 +++++++------ lib/src/remote/client/pocket-db.ts | 6 +-- remote-lib-common/src/remote/wire.ts | 34 +++++++++++++- remote-lib-common/test/wire.test.mjs | 16 +++++++ scripts/spec-word-budgets.json | 2 +- website/src/pages/Privacy.tsx | 2 +- 18 files changed, 173 insertions(+), 43 deletions(-) create mode 100644 hosted/server/dormouse-migrations/012_relay_burrow_label.sql diff --git a/.github/audit/canaries/passkey-removal-keeps-sessions.patch b/.github/audit/canaries/passkey-removal-keeps-sessions.patch index 41140be08..4f16f9322 100644 --- a/.github/audit/canaries/passkey-removal-keeps-sessions.patch +++ b/.github/audit/canaries/passkey-removal-keeps-sessions.patch @@ -56,10 +56,10 @@ index 09806ad75..cb7005345 100644 + "892b0116797b52e9f42617be5703d2e516f3ec5c6d37cbc8c4f8504e4fcce969", "011_enrollment_confirm_digits.sql": "54afe9ae8a2efd3c654503d479fce96c1ce8ff4f2df249aa93b5a91e925def6e", + "012_relay_burrow_label.sql": + "45b3df1530402622edeb12e12e1572698840ecc8ea6b9cfc585f3a9e28b4f230", }; - - const directory = new URL("../dormouse-migrations/", import.meta.url); -@@ -47,7 +47,7 @@ test("no merged Dormouse migration changes, and every one is pinned", () => { +@@ -49,7 +49,7 @@ test("no merged Dormouse migration changes, and every one is pinned", () => { } }); @@ -68,7 +68,7 @@ index 09806ad75..cb7005345 100644 onTestFinished, }) => { // The Dormouse migrations through 009, then 010 over the rows they allowed. -@@ -73,5 +73,4 @@ test("010 deletes the sessions that name no passkey, and every later one must na +@@ -75,5 +75,4 @@ test("010 deletes the sessions that name no passkey, and every later one must na copyFileSync(join(source, "010_relay_session_credential_required.sql"), join(before, "010_relay_session_credential_required.sql")); await migrate(context.database.url, await readMigrations([betterAuthMigrations, before])); expect(await sql(`SELECT "tokenHash" FROM dormouse_relay_sessions`)).toEqual([{ tokenHash: "named" }]); diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 266d6dc78..a6f179f3e 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -189,7 +189,7 @@ sequenceDiagram User->>A: sign in, type the digits, approve {userCode, confirmDigits} Note over R,A: approval row in Postgres, keyed by userCode loop every interval - Burrow->>R: POST /api/burrow/enroll/poll {deviceCode} + Burrow->>R: POST /api/burrow/enroll/poll {deviceCode, label} alt malformed or expired code R-->>Burrow: expired else no live approval @@ -216,6 +216,7 @@ Begin refuses an absent or mismatched origin with `ORIGIN_MISMATCH_ERROR`. The v - **Never admit a begin or poll request carrying `Origin`** (403): only a Node Burrow calls them. Then 429 with `Retry-After` past `RELAY_ENROLL_BEGIN_LIMIT` (10 a minute per address) or `RELAY_ENROLL_POLL_LIMIT` (60), before the body limit and any database read. - **Must answer an expired device code from the code alone**, reading no database. - **Must redeem in one statement** that marks the user code's live unredeemed approval redeemed and inserts the Burrow owned by its `userId`, under the account's lock with the cap check, so two polls mint one Burrow. The entitlement is rechecked first. +- **Must store the label a remote poll carries, reduced by `reduceBurrowLabel` (64 code points, no control, format, or separator characters), never refusing the poll over it**; the column's CHECK refuses what the reduction strips. - **Must keep a redeemed approval until it expires**, swept hourly, so a poll whose `enrolled` answer was lost reads `redeemed`. **Never key it to the Burrow**: removing that Burrow leaves it redeemed; only an approval after it expires replaces it. | Route (account) | Credential | Success | diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 16a44c988..f9ab63e6b 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -50,7 +50,7 @@ Source of truth: `readConfig` in `relay/src/config.ts`; `startRelay` in `relay/s Five JSON files, named because hand-editing them is the revocation mechanism (Guardrails); their shapes are the stores' types in `relay/src/state.ts`: - `account.json` — the one account and its passkeys -- `burrows.json` — `[{ burrowId, burrowToken, enrolledAt }]`; **no label**: the Relay keeps no name for a Burrow +- `burrows.json` — `[{ burrowId, burrowToken, enrolledAt }]`; **no label** - `push-subscriptions.json` — push rows (below) - `vapid.json` — only when env configures no keypair - `setup-password.json` — `{ password, createdAt }`; the installers read `password` (`SELF_HOST.md`) @@ -297,7 +297,7 @@ Source of truth: `resolveRelayOrigin` and `assertRelayOriginBaked` in `scripts/r **Enrollment** (Settings, or the console hook, once): -- **Must mint the Noise static before requesting enrollment, and never send either half** (`docs/specs/remote-security-model.md` -> "Burrow identity"). The request carries one credential and the baked `origin`, nothing else: **the operator's `label` is kept locally and disclosed only inside encrypted outcomes**. +- **Must mint the Noise static before requesting enrollment, and never send either half** (`docs/specs/remote-security-model.md` -> "Burrow identity"). The request carries one credential and the baked `origin`, nothing else: **the operator's `label` reaches a Client only inside encrypted outcomes, and a Relay only in Hosted's remote poll**. - **`burrowToken` never enters a webview realm**: `enroll` answers `{ burrowId }`. - **A 200 that is not an enrollment fails the exchange**, naming the bad field (rationale). - **Burrow→Relay requests time out at `BURROW_REQUEST_TIMEOUT_MS`, under the webview's own 15 s command budget** (rationale); a route the Relay may legitimately hold open longer (push delivery) passes its own. diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index dfc5af82c..25dd158e3 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -259,7 +259,7 @@ Source of truth: `remote-lib-common/src/security/noise.ts`, `isEnrollment` / `pe **No traffic-analysis resistance, per-Burrow unlinkability, or metadata anonymity is claimed.** The Relay still observes account and passkey authentication data, IPs, Burrow IDs and online state, routing relationships, every session's reauth exchange, push endpoints, timing, ciphertext sizes, and volume — **the last three only while the Relay is carrying the session**, since a session that has switched to the [direct path](#direct-path) leaves it the fact of the session and each end's liveness and nothing else. Two leaks follow and are accepted rather than closed (rationale): Client→Burrow timing exposes inter-keystroke timing on a relayed session while keystroke *values* stay encrypted, ending at the switch — which in exchange shows each paired peer the other's addresses, until then known only to the Relay — and one `PushSubscription` per worker scope lets a shared endpoint correlate every `deliveryId` one Pocket profile registers across Burrows. A push carries no counter, so a Relay that kept an envelope can re-deliver it ([Push sealing](#push-sealing)). A connection that rode a [presence window](#presence-window) makes no reauth exchange and answers sooner, so the Relay can tell it from one that proved. -**Hosted's Relay observes what the self-host Relay does**, each account's in its own `RelayRoom` ([hosted.md](./hosted.md) -> "Relay sockets"). +**Hosted's Relay observes what the self-host Relay does**, each account's in its own `RelayRoom` ([hosted.md](./hosted.md) -> "Relay sockets"), **and keeps the label each computer enrolled under** for the account page ([hosted.md](./hosted.md) -> "Burrow enrollment"); a Client never reads it from a Relay. **Hosted's one-time rendezvous observes each room's timing, both ends' IP addresses, the room id, and the size and count of the handshake frames it forwards** — never plaintext, and nothing once the session is direct ([One-time connection](#one-time-connection)). diff --git a/hosted/server/dormouse-migrations/012_relay_burrow_label.sql b/hosted/server/dormouse-migrations/012_relay_burrow_label.sql new file mode 100644 index 000000000..d698e4f3f --- /dev/null +++ b/hosted/server/dormouse-migrations/012_relay_burrow_label.sql @@ -0,0 +1,14 @@ +-- Up Migration +-- The name a computer was enrolled under, by default its hostname, which the +-- account page lists it by (docs/specs/hosted.md -> "Burrow enrollment"). A +-- computer enrolled before this, or by a Dormouse that sends none, has none. +-- The CHECK refuses what `reduceBurrowLabel` (remote-lib-common) strips: Cc, +-- Cf, Zl and Zp characters, padding, nothing, and more than 64 code points. +ALTER TABLE dormouse_relay_burrows ADD COLUMN label text CHECK ( + char_length(label) BETWEEN 1 AND 64 + AND label = btrim(label) + AND label !~ '[\u0001-\u001f\u007f-\u009f\u00ad\u0600-\u0605\u061c\u06dd\u070f\u0890\u0891\u08e2\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u206f\ufeff\ufff9-\ufffb\U000110bd\U000110cd\U00013430-\U0001343f\U0001bca0-\U0001bca3\U0001d173-\U0001d17a\U000e0001\U000e0020-\U000e007f]' +); + +-- Down Migration +ALTER TABLE dormouse_relay_burrows DROP COLUMN label; diff --git a/hosted/server/relay-account.ts b/hosted/server/relay-account.ts index 405ffcb4b..89b1d7885 100644 --- a/hosted/server/relay-account.ts +++ b/hosted/server/relay-account.ts @@ -105,7 +105,7 @@ export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAc app.get("/api/relay/burrows", gate, async (c) => { const burrows = await accountQuery( host(c), - `SELECT "burrowId", "enrolledAt" FROM dormouse_relay_burrows + `SELECT "burrowId", label, "enrolledAt" FROM dormouse_relay_burrows WHERE "userId" = $1 ORDER BY "enrolledAt" DESC, "burrowId"`, [c.get("login").userId], ); diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index 8646af6d0..d84f2e943 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -36,6 +36,7 @@ import { presenceChallenge, randomBase64Url, readJson, + reduceBurrowLabel, toBase64Url, verifyPasskeyAssertion, verifySigninAssertion, @@ -510,7 +511,7 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { }); app.post(API_ROUTES.burrowEnrollPoll, async (c) => { - const body = await readJson<{ deviceCode?: unknown; purpose?: unknown }>(c); + const body = await readJson<{ deviceCode?: unknown; purpose?: unknown; label?: unknown }>(c); const purpose = body?.purpose ?? "remote"; if (purpose !== "remote" && purpose !== "voice") return c.json({ error: "invalid purpose" }, 400); const deviceCode = body?.deviceCode; @@ -593,9 +594,9 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { WHERE "userCode" = $1 AND "userId" = $2 AND "expiresAt" > ${LOCKED_NOW} AND "redeemedAt" IS NULL AND purpose = 'remote' AND "confirmDigits" = $5 RETURNING "userId" - ) INSERT INTO dormouse_relay_burrows ("burrowId", "userId", "tokenHash") - SELECT $3, "userId", $4 FROM spent`, - [userCode, owner.userId, burrowId, digest(burrowToken), confirmDigits], + ) INSERT INTO dormouse_relay_burrows ("burrowId", "userId", "tokenHash", label) + SELECT $3, "userId", $4, $6 FROM spent`, + [userCode, owner.userId, burrowId, digest(burrowToken), confirmDigits, reduceBurrowLabel(body?.label)], ); if (rowCount) return "enrolled"; // Lost to a poll that redeemed it while this one waited on the lock, diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts index 5fdc6f215..adb2b8ef7 100644 --- a/hosted/server/tests/migrations.test.ts +++ b/hosted/server/tests/migrations.test.ts @@ -35,6 +35,8 @@ const PINNED: Record = { "f385398d16126381b6a976d74e23e10b07370c1a1da3205e71697aa62a3399f5", "011_enrollment_confirm_digits.sql": "54afe9ae8a2efd3c654503d479fce96c1ce8ff4f2df249aa93b5a91e925def6e", + "012_relay_burrow_label.sql": + "45b3df1530402622edeb12e12e1572698840ecc8ea6b9cfc585f3a9e28b4f230", }; const directory = new URL("../dormouse-migrations/", import.meta.url); diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index 1938b94e4..31c12e5e4 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -10,6 +10,7 @@ import { MAX_PUSH_QUERY_DELIVERY_IDS, MAX_PUSH_SUBSCRIPTIONS_PER_ACCOUNT, MAX_PUSH_SUBSCRIPTIONS_PER_BURROW, + MAX_BURROW_LABEL_LENGTH, MAX_TOKENS_PER_BURROW, NOT_ENTITLED_ERROR, SETUP_TOKEN_INVALID_ERROR, @@ -290,8 +291,8 @@ async function fixture({ bindings = {} }: { bindings?: Record - call("POST", API_ROUTES.burrowEnrollPoll, { body: { deviceCode, purpose } }); + const poll = (deviceCode: unknown, purpose: "remote" | "voice" = "remote", label?: unknown) => + call("POST", API_ROUTES.burrowEnrollPoll, { body: { deviceCode, purpose, label } }); /** * What the account Worker's approval writes (`hosted/server/relay-account.ts`), @@ -1088,6 +1089,47 @@ test("a device-code enrollment: begin stores nothing, pending until approved, th expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_enrollment_approvals`)).toEqual([{ n: 0 }]); }); +test("a computer is enrolled under the name its poll sends, reduced, or none; the column holds nothing else", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const owner = await f.account(ADMIN_EMAIL); + const enroll = async (label?: unknown) => { + const begun = await f.begin(); + await f.approve(begun.userCode, owner); + const polled = await f.poll(begun.deviceCode, "remote", label); + expect(polled.json!.status).toBe("enrolled"); + const [{ stored }] = await f.sql<{ stored: string | null }>( + `SELECT label AS stored FROM dormouse_relay_burrows WHERE "burrowId" = $1`, + [polled.json!.enrollment.burrowId], + ); + return stored; + }; + expect(await enroll("ned-mbp (VS Code)")).toBe("ned-mbp (VS Code)"); + // Reduced, never refused: a bidi override goes, the rest is capped. + expect(await enroll(` \u202eevil\u202c\n${"x".repeat(80)}`)).toBe(`evil ${"x".repeat(59)}`); + // An older Burrow sends none, or a non-string, and still enrolls. + for (const none of [undefined, 7, "\u200b "]) expect(await enroll(none)).toBeNull(); + + // Past the reduction, the column refuses every character it strips, padding, + // nothing, and more than the cap. + const { burrowId } = await f.burrow(owner); + const store = (label: string) => + f.sql(`UPDATE dormouse_relay_burrows SET label = $2 WHERE "burrowId" = $1`, [burrowId, label]); + await store("a".repeat(MAX_BURROW_LABEL_LENGTH)); + await store("\u{1F42D} b"); + const stripped: string[] = []; + for (let point = 1; point <= 0x10ffff; point++) { + if (point >= 0xd800 && point <= 0xdfff) continue; + const char = String.fromCodePoint(point); + if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(char)) stripped.push(char); + } + expect(stripped.length).toBeGreaterThan(200); + for (const label of [...stripped.map((char) => `a${char}b`), "", " a", "a ", "a".repeat(MAX_BURROW_LABEL_LENGTH + 1)]) + await expect(store(label), JSON.stringify(label)).rejects.toThrow(/check constraint/); +}); + test("an approval redeems only the device code its user code is derived from", async ({ onTestFinished }) => { const f = await fixture(); onTestFinished(f.close); diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts index 9a014c5ad..478d164f5 100644 --- a/hosted/server/tests/workers.test.ts +++ b/hosted/server/tests/workers.test.ts @@ -427,7 +427,7 @@ async function fixture( }; const computers = async () => (await request("/api/relay/burrows")).json() as Promise<{ - burrows: { burrowId: string; enrolledAt: string }[]; + burrows: { burrowId: string; label: string | null; enrolledAt: string }[]; }>; const remove = (burrowId: string, from = origin) => request(`/api/relay/burrows/${burrowId}`, { method: "DELETE", headers: { origin: from } }); @@ -466,7 +466,7 @@ async function fixture( confirmDigits: string; verificationUrl: string; }, - poll: (deviceCode: string) => burrowCall(API_ROUTES.burrowEnrollPoll, { deviceCode }), + poll: (deviceCode: string, label?: string) => burrowCall(API_ROUTES.burrowEnrollPoll, { deviceCode, label }), advance: (time: string) => worker.dispatchFetch(origin + "/__test/time", { method: "POST", @@ -1177,7 +1177,7 @@ test("enrollment: only a recent admin login from this origin approves, and the B // A code no Burrow holds is approved all the same, and expires unredeemed. expect((await admin.approve("ZZZZ-ZZZZ", "00")).status).toBe(204); - const enrolled = await f.poll(begun.deviceCode); + const enrolled = await f.poll(begun.deviceCode, "studio (Dormouse)"); expect(enrolled.json.status).toBe("enrolled"); const { burrowId, burrowToken } = enrolled.json.enrollment; const adminId = (await admin.session())!.user.id; @@ -1185,7 +1185,9 @@ test("enrollment: only a recent admin login from this origin approves, and the B await queryDatabase(f.database.url, `SELECT "userId" FROM dormouse_relay_burrows WHERE "burrowId" = $1`, [burrowId]), ).toEqual([{ userId: adminId }]); const listed = await admin.computers(); - expect(listed.burrows.map(({ burrowId }) => burrowId)).toEqual([burrowId]); + expect(listed.burrows.map(({ burrowId, label }) => ({ burrowId, label }))).toEqual([ + { burrowId, label: "studio (Dormouse)" }, + ]); expect(Math.abs(Date.parse(listed.burrows[0].enrolledAt) - Date.now())).toBeLessThan(60_000); expect((await f.burrowCall(API_ROUTES.burrowSetupToken, undefined, burrowToken)).status).toBe(200); const socket = await f.burrowSocket(burrowToken); diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index 6a4293cb6..6ba8e7f41 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -2829,7 +2829,8 @@ describe('Hosted enrollment', () => { await nextPoll(); expect(pollRequests()).toHaveLength(1); - expect(JSON.parse(pollRequests()[0]!.init!.body as string)).toEqual({ deviceCode: DEVICE_CODE }); + // The name typed for this machine, which the account page lists it by. + expect(JSON.parse(pollRequests()[0]!.init!.body as string)).toEqual({ deviceCode: DEVICE_CODE, label: 'Work laptop' }); expect(pollRequests()[0]!.init!.redirect).toBe('error'); polls.push(enrolledAnswer()); @@ -3336,6 +3337,7 @@ describe('Hosted enrollment', () => { await nextPoll(); expect(JSON.parse(pollRequests().at(-1)!.init!.body as string)).toEqual({ deviceCode: 'E'.repeat(RELAY_BEARER_LENGTH), + label: 'x', }); expect(await hostedEnrollment()).toMatchObject({ status: 'waiting', confirmDigits: '99' }); }); diff --git a/lib/src/remote/burrow/enrollment.test.ts b/lib/src/remote/burrow/enrollment.test.ts index 7c8557ebe..caa228c98 100644 --- a/lib/src/remote/burrow/enrollment.test.ts +++ b/lib/src/remote/burrow/enrollment.test.ts @@ -506,7 +506,7 @@ describe('Hosted device-code enrollment', () => { const poll = (fetchMock: unknown) => pollHostedEnrollment(RELAY, BEGIN.deviceCode, 'My Laptop', noiseStatic, fetchMock as typeof fetch); - it('posts the device code alone, and maps an enrolled answer through the enrollment guard', async () => { + it('posts the device code and this machine\'s name, and maps an enrolled answer through the enrollment guard', async () => { const enrollment = { burrowId: BURROW_ID, burrowToken: 'tok', origin: RELAY, rpId: 'relay.dormouse.sh' }; const voiceToken = `dmv_${'V'.repeat(43)}`; const fetchMock = json(200, { status: 'enrolled', enrollment, voiceToken }); @@ -516,7 +516,7 @@ describe('Hosted device-code enrollment', () => { expect(fetchMock.mock.calls[0]![0]).toBe(`${RELAY}/api/burrow/enroll/poll`); const init = fetchMock.mock.calls[0]![1] as RequestInit; expect(init.redirect).toBe('error'); - expect(JSON.parse(init.body as string)).toEqual({ deviceCode: BEGIN.deviceCode }); + expect(JSON.parse(init.body as string)).toEqual({ deviceCode: BEGIN.deviceCode, label: 'My Laptop' }); expect(answer).toEqual({ status: 'approved', credential: { relayUrl: RELAY, ...enrollment, label: 'My Laptop', ...noiseStatic }, @@ -530,6 +530,19 @@ describe('Hosted device-code enrollment', () => { .toEqual({ status: 'failed', message: expect.stringContaining('burrowId') }); }); + it('sends the name reduced as Hosted stores it, and none when nothing is left', async () => { + const sent = async (label: string) => { + const fetchMock = json(200, { status: 'pending' }); + await pollHostedEnrollment(RELAY, BEGIN.deviceCode, label, noiseStatic, fetchMock as typeof fetch); + return JSON.parse((fetchMock.mock.calls[0]![1] as RequestInit).body as string); + }; + expect(await sent(`\u202eMy\nLaptop${'x'.repeat(80)}`)).toEqual({ + deviceCode: BEGIN.deviceCode, + label: `My Laptop${'x'.repeat(55)}`, + }); + expect(await sent('\u200b')).toEqual({ deviceCode: BEGIN.deviceCode }); + }); + it('reads pending, expired, mismatch, and redeemed as they are', async () => { expect(await poll(json(200, { status: 'pending' }))).toEqual({ status: 'pending' }); expect(await poll(json(200, { status: 'expired' }))).toEqual({ status: 'expired' }); @@ -582,7 +595,7 @@ describe('Hosted device-code enrollment', () => { describe.each(['remote', 'voice'] as const)('%s approval transport', (purpose) => { const poll = (fetchMock: typeof fetch) => pollHostedApproval( - 'https://relay.dormouse.sh', 'D'.repeat(RELAY_BEARER_LENGTH), purpose, fetchMock, + 'https://relay.dormouse.sh', 'D'.repeat(RELAY_BEARER_LENGTH), purpose === 'voice' ? { purpose } : {}, fetchMock, (body) => body && typeof body === 'object' && 'status' in body && body.status === 'pending' ? { status: 'pending' } : null, ); diff --git a/lib/src/remote/burrow/enrollment.ts b/lib/src/remote/burrow/enrollment.ts index 3df03f4da..6dbf42cdf 100644 --- a/lib/src/remote/burrow/enrollment.ts +++ b/lib/src/remote/burrow/enrollment.ts @@ -17,6 +17,7 @@ import { isNoiseStaticMaterial, mintNoiseStaticKeyPair, normalizeOrigin, + reduceBurrowLabel, type BurrowEnrollBeginRequest, type BurrowEnrollBeginResponse, type BurrowEnrollPollRequest, @@ -43,9 +44,9 @@ export interface BurrowEnrollment { * What to call this machine — the "name for this machine" the operator typed * at enrollment. * - * **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 - * in an enrollment request. + * A Client learns it only inside the encrypted pairing and connection + * outcomes. The self-host Relay never sees it; Hosted's remote poll carries + * it for the account page ({@link pollHostedEnrollment}). */ label: string; /** @@ -240,8 +241,7 @@ export async function performEnrollment( headers: { 'content-type': 'application/json' }, // The credential and the baked origin, which a Relay served from another // refuses before saving anything, and nothing else — in particular no - // `label`, which stays local (`docs/specs/remote-security-model.md` -> - // Burrow identity). + // `label`, which the self-host Relay never learns. body: JSON.stringify({ ...credential, origin: relayOrigin } satisfies BurrowEnrollRequest), }); if (!response.ok) { @@ -399,9 +399,10 @@ export type HostedApprovalAnswer = /** * `POST /api/burrow/enroll/poll` once for remote control. **Never throws**: - * the poll loop reads every outcome. An `enrolled` answer is mapped through - * the same {@link isEnrollment} guard as the password exchange's, with `label` - * and the static {@link beginHostedEnrollment} minted. + * the poll loop reads every outcome. The poll carries `label`, reduced, for + * the account page to list this machine by. An `enrolled` answer is mapped + * through the same {@link isEnrollment} guard as the password exchange's, with + * `label` and the static {@link beginHostedEnrollment} minted. */ export async function pollHostedEnrollment( relayOrigin: string, @@ -410,7 +411,8 @@ export async function pollHostedEnrollment( noiseStatic: EnrollmentStatic, fetch: typeof globalThis.fetch, ): Promise> { - return pollHostedApproval(relayOrigin, deviceCode, 'remote', fetch, (body): HostedApprovalAnswer | null => { + const sent = reduceBurrowLabel(label); + return pollHostedApproval(relayOrigin, deviceCode, sent ? { label: sent } : {}, fetch, (body): HostedApprovalAnswer | null => { if (!isBurrowEnrollPollResponse(body)) return null; // Rebuilt, so nothing but the guarded fields reaches status. if (body.status === 'redeemed') return { status: 'redeemed', burrowId: body.burrowId }; @@ -425,21 +427,26 @@ export async function pollHostedVoiceApproval( deviceCode: string, fetch: typeof globalThis.fetch, ): Promise> { - return pollHostedApproval(relayOrigin, deviceCode, 'voice', fetch, (body): HostedApprovalAnswer | null => { + return pollHostedApproval(relayOrigin, deviceCode, { purpose: 'voice' }, fetch, (body): HostedApprovalAnswer | null => { if (!isBurrowVoicePollResponse(body)) return null; // Rebuilt, so nothing but the guarded fields goes further. return body.status === 'enabled' ? { status: 'approved', credential: body.voiceToken } : { status: body.status }; }); } -/** One poll, with retries and refusals shared by both purposes; only success parsing differs. */ +/** + * One poll, with retries and refusals shared by both purposes; only the + * request's other fields and success parsing differ. `fields.purpose` absent + * is remote control. + */ export async function pollHostedApproval( relayOrigin: string, deviceCode: string, - purpose: 'remote' | 'voice', + fields: Omit, fetch: typeof globalThis.fetch, parse: (body: unknown) => T | null, ): Promise { + const purpose = fields.purpose ?? 'remote'; let response: Response; try { response = await fetch(`${relayOrigin}${API_ROUTES.burrowEnrollPoll}`, { @@ -448,7 +455,7 @@ export async function pollHostedApproval( // The device code is a bearer until it expires. redirect: 'error', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ deviceCode, ...(purpose === 'voice' ? { purpose } : {}) } satisfies BurrowEnrollPollRequest), + body: JSON.stringify({ deviceCode, ...fields } satisfies BurrowEnrollPollRequest), }); } catch { return { status: 'retry', slowDown: false }; diff --git a/lib/src/remote/client/pocket-db.ts b/lib/src/remote/client/pocket-db.ts index f4be0e25d..70e59cacb 100644 --- a/lib/src/remote/client/pocket-db.ts +++ b/lib/src/remote/client/pocket-db.ts @@ -228,9 +228,9 @@ export interface KnownBurrowV1 { readonly accountId: string; /** * What to call this machine. The Burrow's own label, as it arrived inside the - * encrypted pairing outcome. The Relay keeps no label for a Burrow at all — - * `burrows.json` rows are `{burrowId, burrowToken, enrolledAt}` - * (`docs/specs/relay.md` -> "State files"). + * encrypted pairing outcome, never one a Relay names: the self-host Relay + * keeps none (`docs/specs/relay.md` -> "State files"), and Hosted's is for + * its account page. */ readonly label: string; /** The pinned Burrow Noise static, base64url. A change is a terminal error. */ diff --git a/remote-lib-common/src/remote/wire.ts b/remote-lib-common/src/remote/wire.ts index da8c8c91b..5d597c229 100644 --- a/remote-lib-common/src/remote/wire.ts +++ b/remote-lib-common/src/remote/wire.ts @@ -453,6 +453,35 @@ export function isBurrowEnrollBeginResponse(value: unknown): value is BurrowEnro export interface BurrowEnrollPollRequest { deviceCode: string; purpose?: 'remote' | 'voice'; + /** + * Remote only: the name this machine was enrolled under, which the account + * page lists it by. Hosted stores it through {@link reduceBurrowLabel}; an + * older Burrow sends none and still enrolls. A Client never reads it from + * the Relay ({@link BurrowsResponse}). + */ + label?: string; +} + +/** The longest Burrow label Hosted stores, in code points. */ +export const MAX_BURROW_LABEL_LENGTH = 64; + +/** + * A Burrow's label as Hosted stores it, and as the Burrow sends it: control, + * format, surrogate, and line or paragraph separator characters gone, so none + * can hide or reorder what the account page shows; whitespace runs one space; + * trimmed; capped at {@link MAX_BURROW_LABEL_LENGTH} code points. Reduced rather + * than refused, and `null` for a non-string or nothing left. Hosted's column + * CHECK (`hosted/server/dormouse-migrations/012_relay_burrow_label.sql`) + * refuses the characters this strips, padding, nothing, and past the cap. + */ +export function reduceBurrowLabel(label: unknown): string | null { + if (typeof label !== 'string') return null; + const cleaned = label + // Format characters carry no width, so they are dropped rather than spaced. + .replace(/[\p{Cf}\p{Cs}]/gu, '') + .replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, ' ') + .trim(); + return Array.from(cleaned).slice(0, MAX_BURROW_LABEL_LENGTH).join('').trim() || null; } /** @@ -598,8 +627,9 @@ export function isSetupTokenResponse(value: unknown): value is SetupTokenRespons /** * Discovery only: which Burrows this account has enrolled, and which are - * connected. **No label** — the Relay holds none, and a Client renders the one - * its own pinned record carries (`docs/specs/pocket-app.md`). + * connected. **No label** — a Client renders the one its own pinned record + * carries (`docs/specs/pocket-app.md`), never one a Relay names; Hosted's, + * from {@link BurrowEnrollPollRequest}, is for its account page alone. */ export interface BurrowsResponse { burrows: Array<{ burrowId: string; online: boolean }>; diff --git a/remote-lib-common/test/wire.test.mjs b/remote-lib-common/test/wire.test.mjs index 08df5d6a5..1f823b99c 100644 --- a/remote-lib-common/test/wire.test.mjs +++ b/remote-lib-common/test/wire.test.mjs @@ -29,7 +29,9 @@ import { isBurrowVoicePollResponse, MAX_ENROLL_POLL_INTERVAL_S, MIN_ENROLL_POLL_INTERVAL_S, + MAX_BURROW_LABEL_LENGTH, RELAY_BEARER_LENGTH, + reduceBurrowLabel, pushSubscriptionDeletePath, } from '../dist/index.js'; @@ -413,3 +415,17 @@ test('isBurrowEnrollPollResponse knows five answers, a redeemed one naming its B assert.equal(isBurrowEnrollPollResponse(wrong), false, JSON.stringify(wrong)); } }); + +test('reduceBurrowLabel keeps a machine name, strips what could hide or reorder it, and caps it', () => { + assert.equal(reduceBurrowLabel('ned-mbp (VS Code)'), 'ned-mbp (VS Code)'); + // Controls and line or paragraph separators are spaced, whitespace runs one space, trimmed. + assert.equal(reduceBurrowLabel(' a\tb\n\u0085c\u2028d\u2029e\u007f '), 'a b c d e'); + // Format characters (bidi overrides, zero-width, soft hyphen, tags) and lone surrogates are dropped. + assert.equal(reduceBurrowLabel('\u202egnp.exe\u200b\u00ad\u{E0041}\ud800x'), 'gnp.exex'); + // Capped in code points, never splitting an astral one. + const long = '\u{1F42D}'.repeat(MAX_BURROW_LABEL_LENGTH + 1); + assert.equal(reduceBurrowLabel(long), '\u{1F42D}'.repeat(MAX_BURROW_LABEL_LENGTH)); + assert.equal(reduceBurrowLabel(`${'a'.repeat(MAX_BURROW_LABEL_LENGTH - 1)} b`), 'a'.repeat(MAX_BURROW_LABEL_LENGTH - 1)); + // Nothing left, or no string, is no label. + for (const none of ['', ' \u200b\n', undefined, null, 7, ['x']]) assert.equal(reduceBurrowLabel(none), null); +}); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 0e2151994..8249db967 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,7 +12,7 @@ "docs/specs/dor-tools-builtin.md": 1750, "docs/specs/dor-tools-lib.md": 350, "docs/specs/glossary.md": 2950, - "docs/specs/hosted.md": 4650, + "docs/specs/hosted.md": 4700, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4200, diff --git a/website/src/pages/Privacy.tsx b/website/src/pages/Privacy.tsx index 92ee42413..07cf35f3d 100644 --- a/website/src/pages/Privacy.tsx +++ b/website/src/pages/Privacy.tsx @@ -43,7 +43,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [ }, { id: "remote-control", title: "Remote control and Pocket", body: <>

The Hosted Relay connects Dormouse on your computers to Pocket on your phone. Terminal traffic and push notification contents are end-to-end encrypted between your devices, so the Relay carries them without being able to read them.

-

We store the computers you enroll (an identifier, a hash of each one’s Relay credential, and when it enrolled), the passkeys you register for Pocket (each one’s public key and the label you gave it), hashes of Pocket sign-in sessions, and short-lived sign-in and pairing challenges. If you turn on push notifications, we store each phone’s push subscription: the address your phone’s browser vendor gave it, the keys that seal notifications to it, and when it subscribed.

+

We store the computers you enroll (an identifier, a hash of each one’s Relay credential, when it enrolled, and the name you gave it, by default its hostname), the passkeys you register for Pocket (each one’s public key and the label you gave it), hashes of Pocket sign-in sessions, and short-lived sign-in and pairing challenges. If you turn on push notifications, we store each phone’s push subscription: the address your phone’s browser vendor gave it, the keys that seal notifications to it, and when it subscribed.

While it carries a connection, the Relay also sees connection metadata: IP addresses, which of your computers are online, which devices talk to which, and the timing and size of encrypted traffic. Once a session switches to a direct connection between your devices, the Relay sees only that the session exists. The trust model lists this in full.

A one-time connection link needs no account. Its rendezvous forwards the encrypted handshake between your phone and computer without reading or storing it.

}, From ef416aa33da37929d233f80bb89e824525243af5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 23:57:21 -0700 Subject: [PATCH 2/4] Hosted account page lists computers by name A computer row and a passkey's "from" line show the name the computer enrolled under, falling back to the short id for one enrolled before Hosted kept names. Stories show a named and an unnamed computer, and a 64-character hostname at phone width. Co-Authored-By: Claude Opus 5.5 --- hosted/server/tests/settle.test.ts | 2 +- hosted/src/App.test.tsx | 26 +++++++++++++++++++++++++- hosted/src/HostedPage.tsx | 13 +++++++++---- hosted/src/api.ts | 2 ++ hosted/src/stories/Account.stories.tsx | 6 ++++++ hosted/src/stories/fixtures.tsx | 5 +++-- 6 files changed, 46 insertions(+), 8 deletions(-) diff --git a/hosted/server/tests/settle.test.ts b/hosted/server/tests/settle.test.ts index 64c987492..a58f76aa3 100644 --- a/hosted/server/tests/settle.test.ts +++ b/hosted/server/tests/settle.test.ts @@ -13,7 +13,7 @@ import { type AccountState, } from "../../src/settle"; -const computer = (burrowId: string) => ({ burrowId, enrolledAt: "2026-10-20" }); +const computer = (burrowId: string) => ({ burrowId, label: null, enrolledAt: "2026-10-20" }); const state = (on: boolean | null, ids: string[] | null): AccountState => ({ voice: on === null ? null : { entitled: true, on }, computers: ids && ids.map(computer), diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx index 380962f66..3ec887613 100644 --- a/hosted/src/App.test.tsx +++ b/hosted/src/App.test.tsx @@ -141,7 +141,7 @@ it.each(["remote", "voice"] as const)("%s approval returns to the account and wa if (purpose === "voice") { vi.mocked(api.getHostedVoice).mockResolvedValue({ entitled: true, on: true }); } else { - vi.mocked(api.getComputers).mockResolvedValue([{ burrowId: "new-computer", enrolledAt: new Date(NOW).toISOString() }]); + vi.mocked(api.getComputers).mockResolvedValue([{ burrowId: "new-computer", label: null, enrolledAt: new Date(NOW).toISOString() }]); } await act(async () => { await vi.advanceTimersByTimeAsync(ENROLLMENT_POLL_INTERVAL_S * 1000); }); expect(container.querySelector("[role=status]")?.textContent).toBe( @@ -150,6 +150,30 @@ it.each(["remote", "voice"] as const)("%s approval returns to the account and wa expect(api.getApproval).toHaveBeenCalledWith("7KQM-X4TD"); }); +it("lists each computer by the name it enrolled under, else its short id, and each passkey's computer the same way", async () => { + vi.mocked(api.getSession).mockResolvedValue(login()); + const enrolledAt = new Date(NOW).toISOString(); + vi.mocked(api.getComputers).mockResolvedValue([ + { burrowId: "named-computer-id", label: "studio (Dormouse)", enrolledAt }, + { burrowId: "older-computer-id", label: null, enrolledAt }, + ]); + vi.mocked(api.getPasskeys).mockResolvedValue([ + { credentialId: "one", label: "iPhone", createdAt: enrolledAt, burrowId: "named-computer-id" }, + { credentialId: "two", label: "iPad", createdAt: enrolledAt, burrowId: "older-computer-id" }, + ]); + await mount(null); + const rows = (section: string) => + [...container.querySelectorAll(`section[aria-labelledby=${section}] > div`)].map((row) => row.querySelector("span")?.textContent); + expect(rows("computers")).toEqual([ + expect.stringMatching(/^studio \(Dormouse\)named-co/), + expect.stringMatching(/^Computerolder-co/), + ]); + expect(rows("passkeys")).toEqual([ + expect.stringMatching(/from studio \(Dormouse\)$/), + expect.stringMatching(/from Computer older-co$/), + ]); +}); + it("an approval that completes after a newer link opened keeps the newer code", async () => { vi.mocked(api.getSession).mockResolvedValue(login()); let resolve!: () => void; diff --git a/hosted/src/HostedPage.tsx b/hosted/src/HostedPage.tsx index 1989b293d..a48fcede1 100644 --- a/hosted/src/HostedPage.tsx +++ b/hosted/src/HostedPage.tsx @@ -53,6 +53,11 @@ const HEADING_CLASS = "mb-2 font-display text-xl"; const ROW_CLASS = `flex min-h-14 items-center justify-between gap-4 py-1.5 ${DASHED_ROW_CLASS}`; const DETAIL_CLASS = `block text-sm ${MUTED_TEXT_CLASS}`; const FOOTNOTE_CLASS = `mt-10 text-sm ${MUTED_TEXT_CLASS}`; +const shortId = (burrowId: string) => burrowId.slice(0, 8); +/** The name a computer enrolled under, or its short id before it sent one. */ +function computerName(burrowId: string, computers: Computer[] | null): string { + return computers?.find((computer) => computer.burrowId === burrowId)?.label ?? `Computer ${shortId(burrowId)}`; +} /** A computer's id in the shape the app draws a minimized Pane: a door. */ const DOOR_CLASS = `${DOOR_SHAPE_CLASS} ml-1.5 inline-flex items-center justify-center border-b border-[var(--color-text)]/30 bg-[var(--color-text)]/[0.12] px-2.5`; @@ -412,9 +417,9 @@ export function HostedPage({ )} {computers.map((computer) => (
- - Computer - {computer.burrowId.slice(0, 8)} + + {computer.label ?? "Computer"} + {shortId(computer.burrowId)} Enrolled{" "} {new Date(computer.enrolledAt).toLocaleDateString()} @@ -452,7 +457,7 @@ export function HostedPage({ Added{" "} {new Date(passkey.createdAt).toLocaleDateString()} {passkey.burrowId - ? ` from Computer ${passkey.burrowId.slice(0, 8)}` + ? ` from ${computerName(passkey.burrowId, computers)}` : ""} diff --git a/hosted/src/api.ts b/hosted/src/api.ts index ec411f2d8..15463dd94 100644 --- a/hosted/src/api.ts +++ b/hosted/src/api.ts @@ -120,6 +120,8 @@ export async function turnOffHostedVoice() { export interface Computer { burrowId: string; + /** The name it enrolled under, by default its hostname; null if it sent none. */ + label: string | null; enrolledAt: string; } async function relay(path: string, init: RequestInit = {}): Promise { diff --git a/hosted/src/stories/Account.stories.tsx b/hosted/src/stories/Account.stories.tsx index 317add1c5..fea5ee4c1 100644 --- a/hosted/src/stories/Account.stories.tsx +++ b/hosted/src/stories/Account.stories.tsx @@ -31,6 +31,12 @@ export const LongIdentity: Story = { args: { session: { ...SESSION, user: { ...SESSION.user, id: "account-with-a-long-immutable-identifier-for-a-provider-only-user", email: "alexandra.long-identity@example.department.example.com" } }, passkeys: [{ ...PASSKEYS[0], label: "Personal phone with a long descriptive passkey label" }], } }; +/** A hostname is one unbroken word, up to the 64 characters Hosted keeps. */ +export const LongComputerName: Story = { args: { + ...ACCOUNT, + phone: true, + computers: [{ ...COMPUTERS[0], label: "build-agent-0042.eu-central-1.compute.internal.example (VS Code)" }, COMPUTERS[1]], +} }; export const RemovingComputer: Story = { args: { ...ACCOUNT, busy: `remove-${COMPUTERS[0].burrowId}` } }; export const RemovingPasskey: Story = { args: { ...ACCOUNT, busy: `passkey-${PASSKEYS[0].credentialId}` } }; export const RemovalFailed: Story = { args: { ...ACCOUNT, error: { read: false, message: "Computers and passkeys are temporarily unavailable. Try again." } } }; diff --git a/hosted/src/stories/fixtures.tsx b/hosted/src/stories/fixtures.tsx index 2bed45b23..5c76eeb98 100644 --- a/hosted/src/stories/fixtures.tsx +++ b/hosted/src/stories/fixtures.tsx @@ -9,12 +9,13 @@ export const SESSION: Session = { session: { createdAt: "2026-10-21T12:00:00Z", expiresAt: "2026-10-22T12:00:00Z" }, }; export const COMPUTERS: Computer[] = [ - { burrowId: "studio01-workstation", enrolledAt: "2026-10-20T12:00:00Z" }, - { burrowId: "laptop02-notebook", enrolledAt: "2026-10-21T12:00:00Z" }, + { burrowId: "studio01-workstation", label: "studio (Dormouse)", enrolledAt: "2026-10-20T12:00:00Z" }, + { burrowId: "laptop02-notebook", label: null, enrolledAt: "2026-10-21T12:00:00Z" }, ]; export const PASSKEYS: Passkey[] = [ { credentialId: "phone-one", label: "iPhone", createdAt: "2026-10-20T12:00:00Z", burrowId: COMPUTERS[0].burrowId }, { credentialId: "phone-two", label: "", createdAt: "2026-10-21T12:00:00Z", burrowId: null }, + { credentialId: "tablet", label: "iPad", createdAt: "2026-10-22T12:00:00Z", burrowId: COMPUTERS[1].burrowId }, ]; export const ENROLLMENT = { code: "7KQM-X4TD", purpose: "remote" as const }; export const ACCOUNT = { session: SESSION, computers: COMPUTERS, passkeys: PASSKEYS }; From fd4723211d5635b2003d94e843e991ab56997aab Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 00:01:40 -0700 Subject: [PATCH 3/4] Hosted policies: last updated 2026-10-28, for the computer name Co-Authored-By: Claude Opus 5.5 --- hosted/server/policy-constants.ts | 2 +- website/src/components/HostedPolicyLayout.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hosted/server/policy-constants.ts b/hosted/server/policy-constants.ts index f2c8c23e3..69abb9efa 100644 --- a/hosted/server/policy-constants.ts +++ b/hosted/server/policy-constants.ts @@ -32,4 +32,4 @@ export const ENROLLMENT_POLL_INTERVAL_S = 5; // (`website/src/components/HostedPolicyLayout.tsx`), which // `hosted/server/tests/policy.test.ts` pins this to. A new version is a new // acceptance row, never an edit of an old one. -export const TERMS_VERSION = "2026-10-27"; +export const TERMS_VERSION = "2026-10-28"; diff --git a/website/src/components/HostedPolicyLayout.tsx b/website/src/components/HostedPolicyLayout.tsx index 6f2ea8f65..6403e6559 100644 --- a/website/src/components/HostedPolicyLayout.tsx +++ b/website/src/components/HostedPolicyLayout.tsx @@ -18,7 +18,7 @@ export default function HostedPolicyLayout({ path, title, applicability, section

Dormouse Hosted

{title}

-

Last updated . {applicability}

+

Last updated . {applicability}

{sections.map(({ id, title, body }) => (

{title}

From 732001e73493971d8292f394ddc751a328713417 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 00:05:25 -0700 Subject: [PATCH 4/4] Audit prompt: the remote enrollment poll's label is the one a Relay reads Co-Authored-By: Claude Opus 5.5 --- .github/audit/remote.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/audit/remote.md b/.github/audit/remote.md index 18d17ecd4..11308622b 100644 --- a/.github/audit/remote.md +++ b/.github/audit/remote.md @@ -33,7 +33,7 @@ Be adversarial, and go past the `FAIL IF` list; a bare section name is `docs/spe - **Can an operator choose the bootstrap credential?** Trace first boot from the entrypoint through generation and atomic persistence against "Credentials at rest" and "The setup password". A malformed existing record must stop startup rather than rotate the credential or fall back to configuration. - **Does a self-host build reach Dormouse's servers with nobody clicking?** Follow `DORMOUSE_RELAY_ORIGIN` from `scripts/relay-origin.mjs` into both host bundles and the standalone webview (`standalone/vite.config.ts`), then list every request a build baked with a non-default origin could make to `dormouse.sh` or any of its subdomains — the one-time half of `service.ts`, `lib/src/host/managed-voice-host.ts`, `standalone/src/updater.ts` and the updater endpoint `standalone/scripts/tauri.mjs` overlays away, and anything else that fetches ("Relay origin"). A release build that accepts `DORMOUSE_RELAY_IS_HOSTED` or a loopback `http:` origin is the same finding. - Can anything reach a Burrow's ACL without a human approving on that Burrow? Trace every writer — including the ACL read filter, anything that rehydrates a record from disk, and what a compromised webview or a compromised Relay could send — against "Trust boundary", and check that no path lets a mismatch, a timeout, or a superseded request end anywhere but spending the invitation. -- **Is the relay actually opaque?** The Relay must be unable to read a pairing decision, a Burrow label, a remote-api message, a terminal byte, or a notification's text. Look for anywhere plaintext could re-enter: a debug log of a decoded frame, a route that inspects a sealed payload, a metric derived from content rather than size. +- **Is the relay actually opaque?** The Relay must be unable to read a pairing decision, a Burrow label (apart from the one Hosted's remote enrollment poll carries for its account page, `docs/specs/remote-security-model.md` -> "Residual metadata"), a remote-api message, a terminal byte, or a notification's text. Look for anywhere plaintext could re-enter: a debug log of a decoded frame, a route that inspects a sealed payload, a metric derived from content rather than size. - **Can a forged or replayed frame do anything?** Assume the relay is hostile: it may invent `clientId`s, reorder, drop `client-gone`, replay old ciphertext, and inject frames of its own choosing. What does the Burrow allocate, decrypt, or answer before it has authenticated anything? `docs/specs/remote-security-model.md` -> "Burrow bounds" is the list to hold it to. - **Is the presence proof bound to *this* ceremony?** Trace the challenge from `presenceChallenge` through `/api/reauth/*` to `verifyPresenceProof` (`docs/specs/remote-security-model.md` -> "Presence proofs"). Can an assertion captured in one ceremony, one Burrow, or one kind be replayed in another? - **Does a presence window rest only on a proof this Burrow verified?** Trace every `PresenceWindows` seed and every activity fold in `burrow-runtime.ts` against `docs/specs/remote-security-model.md` -> "Presence window" and S177. Can anything but a Burrow-verified proof — a Relay frame, a ride, an unauthenticated message — open or extend one, or can a window authorize a connection for other identities or another Client static than the proof it rests on? Then hold every proof still in flight when the UV demand rises — mid-verification, behind a window or an offer, or waiting on the approval modal — to S190.