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
8 changes: 4 additions & 4 deletions .github/audit/canaries/passkey-removal-keeps-sessions.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
}
});

Expand All @@ -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" }]);
Expand Down
2 changes: 1 addition & 1 deletion .github/audit/remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions docs/specs/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/remote-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand Down
14 changes: 14 additions & 0 deletions hosted/server/dormouse-migrations/012_relay_burrow_label.sql
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion hosted/server/policy-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
2 changes: 1 addition & 1 deletion hosted/server/relay-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function relayAccountRoutes(app: Hono<any>, 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],
);
Expand Down
9 changes: 5 additions & 4 deletions hosted/server/relay-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
presenceChallenge,
randomBase64Url,
readJson,
reduceBurrowLabel,
toBase64Url,
verifyPasskeyAssertion,
verifySigninAssertion,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions hosted/server/tests/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const PINNED: Record<string, string> = {
"f385398d16126381b6a976d74e23e10b07370c1a1da3205e71697aa62a3399f5",
"011_enrollment_confirm_digits.sql":
"54afe9ae8a2efd3c654503d479fce96c1ce8ff4f2df249aa93b5a91e925def6e",
"012_relay_burrow_label.sql":
"45b3df1530402622edeb12e12e1572698840ecc8ea6b9cfc585f3a9e28b4f230",
};

const directory = new URL("../dormouse-migrations/", import.meta.url);
Expand Down
46 changes: 44 additions & 2 deletions hosted/server/tests/relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -290,8 +291,8 @@ async function fixture({ bindings = {} }: { bindings?: Record<string, string | u
};
}

const poll = (deviceCode: unknown, purpose: "remote" | "voice" = "remote") =>
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`),
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion hosted/server/tests/settle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 6 additions & 4 deletions hosted/server/tests/workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1177,15 +1177,17 @@ 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;
expect(
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);
Expand Down
Loading
Loading