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
133 changes: 133 additions & 0 deletions .github/audit/canaries/uv-raise-lost-on-restart.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
Domain: remote
Rule: S181 — nor may a restart lower it
Expect: lib/src/host/remote/service.ts
Source: reverses part of #1242 (a UV raise the store failed to save was lost at the next restart)

diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts
index 8c884618a..3e110b162 100644
--- a/lib/src/host/remote/service.test.ts
+++ b/lib/src/host/remote/service.test.ts
@@ -1303,50 +1303,6 @@ describe('pairing queue', () => {
expect(store.acl[BURROW_ID]).toHaveLength(1);
});

- it('keeps a raised UV demand across a restart when the store could not save it', async () => {
- const socket = await running();
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
- // A locked keychain or a full disk: the raise cannot be written.
- let saves = 0;
- const save = store.saveEnrollment;
- store.saveEnrollment = async () => {
- saves++;
- throw new Error('store unavailable');
- };
- socket.receive({ t: 'policy', requireUserVerification: true });
- await vi.waitFor(() => expect(saves).toBe(1));
- expect(store.enrollment!.requireUserVerification).toBeFalsy();
- warn.mockRestore();
-
- // The store recovers; the Burrow restarts, and its Relay never repeats the demand.
- store.saveEnrollment = save;
- await setPolicy(NOTHING);
- await setPolicy(RELAY_ON);
- const restarted = sockets.at(-1)!;
- expect(restarted).not.toBe(socket);
- restarted.open();
- // The start retried the save.
- expect(store.enrollment).toEqual({ ...ENROLLMENT, requireUserVerification: true });
-
- // A presence-only passkey never reaches the modal.
- const presenceOnly = await createTestAuthenticator({
- rpId: ENROLLMENT.rpId,
- origin: ORIGIN,
- userVerified: false,
- });
- const qr = (await command('setupQr')).result as SetupQrResult;
- const invitation = await parsePairingInvitationUrl(qr.url, ORIGIN);
- await pairThroughSocket({
- socket: restarted,
- burrowId: ENROLLMENT.burrowId,
- clientId: 'c1',
- invitation: invitation!,
- authenticator: presenceOnly,
- });
- await settle();
- expect(queueEvents().flatMap((event) => event.queue)).toEqual([]);
- });
-
it('rejects stale modal actions after the client replaces its pairing', async () => {
const socket = await running();
const first = await pair(socket, 'c1', { label: BROWSER });
diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts
index a63efe071..236cee44b 100644
--- a/lib/src/host/remote/service.ts
+++ b/lib/src/host/remote/service.ts
@@ -1508,9 +1508,7 @@ export class BurrowService {
return false;
}

- async #startBurrow(stored: BurrowEnrollment): Promise<void> {
- if (this.#disposed) return;
- const enrollment = await this.#withRaisedPolicy(stored);
+ async #startBurrow(enrollment: BurrowEnrollment): Promise<void> {
if (this.#disposed) return;
if (!(await this.#hasUsableNoiseStatic(enrollment)) || this.#disposed) return;
// Never two. Callers are serialized (see `#serialize`), but a Burrow left in
@@ -1558,49 +1556,24 @@ export class BurrowService {
* Keep the Relay's raise to user verification across restarts, onto the
* enrollment whose Burrow heard it — and only while that is still this
* machine's enrollment, so a raise queued behind a clear or a re-enroll
- * writes nothing. The running Burrow already enforces it. The raise lands on
- * `#enrollment` before the save and stays there if the save fails, so a
- * restart in this process still starts raised (`#withRaisedPolicy`), never
- * lowered by a Relay that stays silent.
+ * writes nothing. The running Burrow already enforces it; a failed save is
+ * retried by the raise after the next start.
*/
#persistRaisedPolicy(raisedBy: BurrowEnrollment): void {
void this.#serialize(async () => {
const current = this.#enrollment;
if (this.#disposed || current?.burrowToken !== raisedBy.burrowToken) return;
const raised = { ...current, requireUserVerification: true };
+ try {
+ await this.#store.saveEnrollment(raised);
+ } catch (error) {
+ console.warn('[burrow] could not persist the Relay\'s user-verification demand', error);
+ return;
+ }
this.#enrollment = raised;
- await this.#saveRaisedPolicy(raised);
});
}

- /**
- * `stored` raised to user verification when this process already holds a
- * raise for the same enrollment that the store lacks — a save that failed —
- * retrying the save. A store that keeps failing is raised again only by the
- * Relay's next `policy` frame after a relaunch.
- */
- async #withRaisedPolicy(stored: BurrowEnrollment): Promise<BurrowEnrollment> {
- const held = this.#enrollment;
- if (
- stored.requireUserVerification ||
- !held?.requireUserVerification ||
- held.burrowToken !== stored.burrowToken
- ) {
- return stored;
- }
- const raised = { ...stored, requireUserVerification: true };
- await this.#saveRaisedPolicy(raised);
- return raised;
- }
-
- async #saveRaisedPolicy(raised: BurrowEnrollment): Promise<void> {
- try {
- await this.#store.saveEnrollment(raised);
- } catch (error) {
- console.warn('[burrow] could not persist the Relay\'s user-verification demand', error);
- }
- }
-
/**
* The remote-api handler both runtimes serve an authorized session through,
* under a fresh holder id: what a pane it sizes names it by, and what Take
2 changes: 1 addition & 1 deletion docs/specs/remote-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Every pairing carries one WebAuthn assertion, verified by the Burrow inside the

> **Passkeys are user credentials, not device identities** — synchronization puts one passkey on many physical devices and changes nothing here (rationale).

**Presence, or verification.** The default demand is the authenticator's user-*presence* flag; `DORMOUSE_REQUIRE_USER_VERIFICATION=true` raises it to user *verification*, mirrored by the Relay into every Burrow's enrollment response and again on every connect, and copied by the Burrow into its policy. **A Burrow must never lower it** on a Relay's word; lowering takes a re-enrollment. A raise voids every proof verified before it: one still undecided is `presence-rejected`, unless its pairing's ACL write has started (`docs/specs/security-remote.md` -> "Trust boundary"; rationale). **Both verifiers must demand the same thing** (rationale). Pocket asks `userVerification: 'preferred'` either way, so platform authenticators prompt for biometrics in practice — convention, not a guarantee.
**Presence, or verification.** The default demand is the authenticator's user-*presence* flag; `DORMOUSE_REQUIRE_USER_VERIFICATION=true` raises it to user *verification*, mirrored by the Relay into every Burrow's enrollment response and again on every connect, and copied by the Burrow into its policy. **A Burrow must never lower it** on a Relay's word; lowering takes a re-enrollment. A raise outlives a restart even when it could not be saved (`docs/specs/security-remote.md` -> "Trust boundary"). A raise voids every proof verified before it: one still undecided is `presence-rejected`, unless its pairing's ACL write has started (`docs/specs/security-remote.md` -> "Trust boundary"; rationale). **Both verifiers must demand the same thing** (rationale). Pocket asks `userVerification: 'preferred'` either way, so platform authenticators prompt for biometrics in practice — convention, not a guarantee.

**The Burrow stores only a hash of each paired passkey's public key**, checked against the full key presented inside the channel, so a compromised Relay cannot substitute a passkey. **The Relay likewise verifies against its own *stored* key**, never one a request carries. **Only ES256 (ECDSA P-256 / SHA-256) is accepted**, the mandatory-to-implement WebAuthn algorithm.

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/security-remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Only one local confirmation of the phone's two digits mints an ACL record. The w
- **FAIL IF** `S178` local confirmation stops being the only thing that **mints** an ACL record: `BurrowAcl.approve` must have no caller but `BurrowRuntime.#approvePairing`, the comparison must be constant-time and happen **exactly once** per ceremony, and it must match the displayed request's immutable `pairingId`, never a mutable `clientId` alone; and the record it mints authorizes nothing until its durable save succeeds, nor anything after a save that failed. `scripts/e2e-lint.mjs` holds the callers textually.
- **FAIL IF** `S179` the expected two-digit code, or an invitation's private key, ever leaves the Burrow process: `PairingQueueItem` in `lib/src/host/remote/service-protocol.ts` carries `{ kind, clientId, pairingId, label, requestedAt }` and nothing else (rationale). An answer is routed by its `kind` — a one-time request's only to the runtime that asked, by the random ticket its modal displayed — and **a missing `kind` is a pairing** (`approvalKind`), so an answer that names none can never reach a one-time request.
- **FAIL IF** `S180` any rule in `docs/specs/remote-security-model.md` -> "Burrow bounds" stops being enforced by the Burrow itself, on its own clock, with no help from the relay: no `client-gone` it must receive, no Relay gate, and no deadline that waits for a frame. The pending-pairing cap binds **both** `BurrowRuntime`'s client map in `lib/src/remote/burrow/burrow-runtime.ts` and the service's mirrored queue, oldest evicted first, and outstanding invitations are capped at `MAX_TOKENS_PER_BURROW`. A handshake that fails to decrypt, or an `init` the bucket refuses, allocates no entry, and a frame the Burrow refuses performs no operation and allocates nothing. Pinned by `lib/src/remote/burrow/burrow-bounds.test.ts`, the service queue's cap by `lib/src/host/remote/service.test.ts`, and `relay/test/malicious-relay.test.mjs` (rationale).
- **FAIL IF** `S181` `requireUserVerification` is reachable on one side without being mirrored to the other: the Relay reads `DORMOUSE_REQUIRE_USER_VERIFICATION`, and `BurrowEnrollResponse` must carry it into the Burrow's `ConnectionPolicy`, as must the `policy` frame on every `/ws/burrow` open, which no Relay frame lowers (rationale).
- **FAIL IF** `S181` `requireUserVerification` is reachable on one side without being mirrored to the other: the Relay reads `DORMOUSE_REQUIRE_USER_VERIFICATION`, and `BurrowEnrollResponse` must carry it into the Burrow's `ConnectionPolicy`, as must the `policy` frame on every `/ws/burrow` open, which no Relay frame lowers; nor may a restart lower it: `BurrowService` in `lib/src/host/remote/service.ts` must start every later runtime of the enrollment a raise reached with that raise, held in memory whether or not saving it succeeded (rationale).
- **FAIL IF** `S182` the Burrow accepts an `e2e` frame it has not shape-validated itself with `isE2eRelayToBurrowFrame`, relying instead on the relay's own guard in `relay/src/relay.ts`, or lets the Client's device label reach any consumer except through `knownPairingDeviceLabel`, so no phone-chosen text is shown (`docs/specs/remote-security-model.md` -> "Pairing"; rationale).
- **FAIL IF** `S183` a ceremony outcome stops being a fixed-size padded control message, or begins carrying which ACL half failed: success and every denial encrypt to the same length, every ACL miss answers `pairing-required`, and the specific miss is logged owner-locally only.
- **FAIL IF** `S184` any **service→webview** message can carry `burrowToken`, **or any other bearer credential the receiving realm has no route that takes** — `deliveryId` most of all, which is why `PushDevicesResult` is labels only. Check every service→webview shape in `lib/src/host/remote/service-protocol.ts`, `OneTimeState` included; the test is whether the webview *calls* anything with the value, not whether exposing it is currently exploitable. The only credentials that cross outbound are the Relay's **setup token** and the invitation's **public** half, inside `SetupQrResult.url`; a one-time link's room id and one-use **public** key, inside `OneTimeState`'s `waiting.url`; and a Hosted enrollment's **user code**, in `HostedEnrollmentState`'s `waiting` and inside its `verificationUrl`, which the webview opens — each minted only on request, single-use, and short-lived. Whichever account approves a live user code first owns the Burrow it enrolls (`docs/specs/security.md` -> "What is not defended"). Inbound, `EnrollParams` carries the setup password by design (rationale).
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/security-remote.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

**Why the burrow-token edge exists.** A `burrowToken` mints setup tokens and a setup token is the only thing that registers an owner passkey, so account takeover is transitive rather than direct. That is deliberate: the QR *is* the credential, so whatever can mint one can be set up by one. It still buys no Burrow access for a structural reason — pairing runs Noise IK against an invitation keypair the Burrow generated locally and never sent anywhere, so the Burrow has no invitation to match a stolen setup token against.

**Why `requireUserVerification` is mirrored.** The Burrow is the final authority, so a Relay demanding user verification while the Burrow did not would leave the weaker verifier deciding.
**Why `requireUserVerification` is mirrored.** The Burrow is the final authority, so a Relay demanding user verification while the Burrow did not would leave the weaker verifier deciding. A raise that only the store remembered would let a failed save plus a restart start the Burrow at presence, lowered by nothing more than a Relay that stops sending `policy`; holding it in memory leaves only a relaunch on a store that keeps failing, which `docs/specs/security.md` -> "Known gaps" states.

**Why the expected code stays in the Burrow process.** The bridge carries the displayed ceremony's immutable `pairingId` and typed digits; only the service knows the expected code and the pending record. Mirroring the code would let compromised webview code satisfy every confirmation. Keeping it private leaves one guess per ceremony, not an absolute proof that a person read the phone. `samplePairingCode` samples uniformly over 100 values; local confirmation spends `attempted` before comparing, so guessing succeeds with probability 1/100 for a ceremony whose other gates already passed.

Expand Down
1 change: 1 addition & 0 deletions docs/specs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Gaps rather than accepted risks: we intend to close them.
- **Neither VS Code's peer-link token, its Tool trust receipts, nor the `recovery.json` beside them carries a Windows ACL applied by Dormouse.** They are written owner-only by unix mode, which Windows makes a no-op; standalone locks its state directory instead ([Persisted state](./security-local.md#persisted-state)).
- *One-time link, Hosted phone, Self-hosted Relay.* **No independent third party has reviewed remote control's cryptography.** Dormouse's Noise integration, passkey (WebAuthn) channel binding, key storage, and push sealing are checked only by this project's own tests, lints, and nightly audits, which AI agents run ([Noise suite](./remote-security-model.md#noise-suite)).
- *Self-hosted Relay.* **Anyone who can reach your Relay can hold off sign-in by flooding it**: unauthenticated requests share the capped pool of sign-in challenges, evicting a real one before its passkey prompt is answered. The default install admits only your tailnet ([Network posture (self-hosted)](./security-remote.md#network-posture-self-hosted)).
- *Hosted phone, Self-hosted Relay.* **A user-verification demand raised after enrollment can be lost if this computer cannot save it.** The Burrow keeps the raise in memory and retries the save at each start, but a relaunch while saving keeps failing starts it at presence until the Relay repeats the demand ([Trust boundary](./security-remote.md#trust-boundary)).
- *Hosted phone, Self-hosted Relay.* **Revocation has no mechanism**: nothing in the product revokes a paired phone ([Revocation propagation](./remote-security-model.md#revocation-propagation)).
- *One-time link, Hosted phone, Self-hosted Relay.* **There is no structured audit trail** covering connects, attaches, denials, or writes, so a self-hoster cannot answer "did anyone connect to my laptop last night"; each pairing records its approval, and owner-local logs report some rejections ([Burrow Authorization](./remote-security-model.md#burrow-authorization), [Trust boundary](./security-remote.md#trust-boundary)).
- **A fork PR the bot checks out mid-run reaches it with that fork's instruction files.** The harness pins project-instruction paths to the base branch only for the checkouts it makes before the agent starts ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)).
Expand Down
44 changes: 44 additions & 0 deletions lib/src/host/remote/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,50 @@ describe('pairing queue', () => {
expect(store.acl[BURROW_ID]).toHaveLength(1);
});

it('keeps a raised UV demand across a restart when the store could not save it', async () => {
const socket = await running();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// A locked keychain or a full disk: the raise cannot be written.
let saves = 0;
const save = store.saveEnrollment;
store.saveEnrollment = async () => {
saves++;
throw new Error('store unavailable');
};
socket.receive({ t: 'policy', requireUserVerification: true });
await vi.waitFor(() => expect(saves).toBe(1));
expect(store.enrollment!.requireUserVerification).toBeFalsy();
warn.mockRestore();

// The store recovers; the Burrow restarts, and its Relay never repeats the demand.
store.saveEnrollment = save;
await setPolicy(NOTHING);
await setPolicy(RELAY_ON);
const restarted = sockets.at(-1)!;
expect(restarted).not.toBe(socket);
restarted.open();
// The start retried the save.
expect(store.enrollment).toEqual({ ...ENROLLMENT, requireUserVerification: true });

// A presence-only passkey never reaches the modal.
const presenceOnly = await createTestAuthenticator({
rpId: ENROLLMENT.rpId,
origin: ORIGIN,
userVerified: false,
});
const qr = (await command('setupQr')).result as SetupQrResult;
const invitation = await parsePairingInvitationUrl(qr.url, ORIGIN);
await pairThroughSocket({
socket: restarted,
burrowId: ENROLLMENT.burrowId,
clientId: 'c1',
invitation: invitation!,
authenticator: presenceOnly,
});
await settle();
expect(queueEvents().flatMap((event) => event.queue)).toEqual([]);
});

it('rejects stale modal actions after the client replaces its pairing', async () => {
const socket = await running();
const first = await pair(socket, 'c1', { label: BROWSER });
Expand Down
Loading
Loading