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
2 changes: 1 addition & 1 deletion docs/specs/remote-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ Source of truth: `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts`

Reserved: a Relay- or Pocket-initiated revocation calls this entry point with a requester of its own (`remote-trust-followups` -> [Revocation propagation](#revocation-propagation)).

- **A running Burrow's revocation takes effect before its save**: the live ACL drops the records, every established session no active record still authorizes ends with the goodbye, every pending connection whose Client static it left unpaired is refused `pairing-required`, and those statics' [presence windows](#presence-window) close. It queues with approvals, so neither overwrites the other's snapshot. A failed save rejects with the phone already refused, and the service applies the revocation to every ACL it reads for that Burrow, so a network-policy change that restarts the runtime keeps it; the next ACL write carries it, and quitting Dormouse before one undoes it (rationale).
- **A running Burrow's revocation takes effect before its save**: the live ACL drops the records, every established session no active record still authorizes ends with the goodbye, every pending connection whose Client static it left unpaired is refused `pairing-required`, and those statics' [presence windows](#presence-window) close. It queues with approvals, so neither overwrites the other's snapshot. A failed save rejects with the phone already refused, and the service applies the revocation to every ACL it reads for that Burrow, so a network-policy change that restarts the runtime keeps it; the next ACL write carries it, and the service stopping before one undoes it — Dormouse quitting, or in VS Code the broker window closing (`docs/specs/vscode.md` -> "Which window: bind-as-lease"), since the next broker loads the stored ACL (rationale).
- **A stopped Burrow's revocation is its save**, on the service's lifecycle chain, so a Burrow starting meanwhile loads the ACL after it.
- **A paired browser is named by `deviceIdFor`**: the first 64 bits of its Client static in hex, a name that authorizes nothing and the only identifier of a record that reaches a webview or `dor` (rationale). **An id that matches more than one Client static revokes nothing.**

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/remote-security-model.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ The self-host operator explicitly accepted the fallback tradeoff: same-origin ma

## Revocation

**Why a revocation acts before it saves, when an approval saves first.** The two fail in opposite directions. An approval that published before a failed save would grant access nothing durable records; a revocation that waited for its save would leave a phone the person just cut off connected for as long as the disk refuses. Acting first fails closed, and the cost of a failed save is a phone that comes back once Dormouse quits, which the error says. The service holds the revocation over every runtime it starts meanwhile, since a network-policy change restarts one from the store.
**Why a revocation acts before it saves, when an approval saves first.** The two fail in opposite directions. An approval that published before a failed save would grant access nothing durable records; a revocation that waited for its save would leave a phone the person just cut off connected for as long as the disk refuses. Acting first fails closed, and the cost of a failed save is a phone that comes back once the service stops — Dormouse quitting, or in VS Code the broker window closing — which the error says. The service holds the revocation over every runtime it starts meanwhile, since a network-policy change restarts one from the store.

**Why a device id is 64 bits of the static, in hex.** The static is a public key and authorizes nothing, but it is long and stable, so it stays in the service; a prefix is short enough to type into `dor`. A paired phone chooses its own static and could grind one sharing a shorter prefix with another phone's, so that the person revoking one cuts off the other; 64 bits puts that out of reach, and the ambiguity refusal covers what is left. Hex never begins with the `-` a command line reads as a flag, which base64url can.

Expand Down
14 changes: 13 additions & 1 deletion lib/src/host/remote/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1790,13 +1790,25 @@ describe('paired devices and revocation', () => {
expect((await command('status')).result).toMatchObject({ pairedClients: 0 });
});

it('names Dormouse quitting as the end of an unsaved revocation in standalone', async () => {
createService({ enrollment: ENROLLMENT, acl: { [BURROW_ID]: [PHONE] } }, { kind: 'standalone' });
await service.start();
sockets[0]!.open();
vi.spyOn(store, 'saveAcl').mockRejectedValueOnce(new Error('disk full'));

expect((await command('revokeDevice', { id: phoneId })).error).toMatch(/refused until Dormouse quits.*disk full/);
});

it('keeps a revocation whose save failed across a network-policy restart, until a save lands', async () => {
createService({ enrollment: ENROLLMENT, acl: { [BURROW_ID]: [PHONE, TABLET] } });
await service.start();
sockets[0]!.open();
const save = vi.spyOn(store, 'saveAcl').mockRejectedValueOnce(new Error('disk full'));

expect((await command('revokeDevice', { id: phoneId })).error).toMatch(/refused until Dormouse quits.*disk full/);
// VS Code's next broker window loads the stored ACL, so the hold ends with this window.
expect((await command('revokeDevice', { id: phoneId })).error).toMatch(
/refused until the window running remote control closes.*disk full/,
);
expect(store.acl[BURROW_ID]!.every((record) => record.revokedAt === null)).toBe(true);
// The hold covers every record of the target approved by the failure — one
// an approval queued ahead of the revocation committed included — and none
Expand Down
7 changes: 5 additions & 2 deletions lib/src/host/remote/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,9 @@ export class BurrowService {
* the store has them applied ({@link #loadAcl}) — a network-policy change
* restarts the runtime from the store, and must not re-admit a phone the
* person just cut off — until an ACL save for that Burrow succeeds, which
* carries them. Memory only: quitting Dormouse first undoes them.
* carries them. Memory only: stopping this service first undoes them — quitting
* Dormouse, or in VS Code closing the broker window, whose successor loads
* the stored ACL.
*/
readonly #unsavedRevocations = new Map<string, Array<{ target: RevocationTarget; at: number }>>();

Expand Down Expand Up @@ -1195,8 +1197,9 @@ export class BurrowService {
const held = this.#unsavedRevocations.get(enrollment.burrowId) ?? [];
this.#unsavedRevocations.set(enrollment.burrowId, [...held, { target, at: this.#now() }]);
const detail = error instanceof Error ? error.message : String(error);
const until = this.#kind === 'vscode' ? 'the window running remote control closes' : 'Dormouse quits';
throw new Error(
`The phone is disconnected and refused until Dormouse quits, but the revocation could not be saved: ${detail}`,
`The phone is disconnected and refused until ${until}, but the revocation could not be saved: ${detail}`,
);
}
} else {
Expand Down
11 changes: 7 additions & 4 deletions lib/src/host/remote/test-burrow-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,10 @@ const HOUR_MS = 60 * 60_000;
* first paired an hour ago and connected now; each after it paired a day
* before the last and was last seen two hours after pairing. Relative to now,
* like {@link setupQrResult}, so "Last seen" reads the same in every story.
* A caller that runs before a story pins the clock (a `parameters` literal is
* built at module load) passes its pinned `now`.
*/
export function pairedDevices(count: number): PairedDevice[] {
const now = Date.now();
export function pairedDevices(count: number, now = Date.now()): PairedDevice[] {
return Array.from({ length: count }, (_, index) => {
const pairedAt = now - HOUR_MS - index * 24 * HOUR_MS;
return {
Expand All @@ -176,7 +177,10 @@ export function pairedDevices(count: number): PairedDevice[] {
* one's pairing, the second's session that went direct and ended, a one-time
* connection, an unpaired phone refused, and the first's session open now.
*/
export function accessLogEntries(devices: PairedDevice[] = pairedDevices(2)): AccessLogEntry[] {
export function accessLogEntries(
devices: PairedDevice[] = pairedDevices(2),
now = Date.now(),
): AccessLogEntry[] {
const entries: AccessLogEntry[] = [];
for (const [index, { label: device, id: deviceId, pairedAt, lastSeenAt }] of devices.entries()) {
entries.push({ at: pairedAt, event: 'paired', device, deviceId });
Expand All @@ -192,7 +196,6 @@ export function accessLogEntries(devices: PairedDevice[] = pairedDevices(2)): Ac
{ at: lastSeenAt, event: 'ended', device, deviceId, path: 'direct', durationMs: 4 * 60_000 },
);
}
const now = Date.now();
const oneTime = 'Dormouse Pocket (browser)';
entries.push(
{ at: now - 3 * HOUR_MS, event: 'one-time-connected', device: oneTime, path: 'direct' },
Expand Down
8 changes: 5 additions & 3 deletions lib/src/stories/RemoteControlSection.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ function RemoteControlStory() {
}

/** The clock every story here reads. A setup code encodes its expiry, so a real
* clock would draw a different QR on every run. */
* clock would draw a different QR on every run. `beforeEach` pins it only
* around render: a `parameters` literal is built at module load, under the
* real clock, so a timestamp there must use `STORY_NOW` itself. */
const STORY_NOW = Date.UTC(2026, 0, 1);

const meta: Meta<typeof RemoteControlStory> = {
Expand Down Expand Up @@ -342,7 +344,7 @@ export const PairedPhonesAndAccessLog: Story = {
parameters: {
primedBurrow: {
status: enrolledStatus({ pairedClients: 2 }),
accessLog: accessLogEntries(pairedDevices(2)),
accessLog: accessLogEntries(pairedDevices(2, STORY_NOW), STORY_NOW),
},
docs: { story: { height: '620px' } },
},
Expand Down Expand Up @@ -703,7 +705,7 @@ export const OneTimeEndedNetworkNotAllowed: Story = {
oneTime: {
status: 'ended',
reason: 'network-not-allowed',
refusal: { at: Date.now(), kind: 'path-refused', end: 'remote', address: '172.58.12.9', addressSource: 'observed' },
refusal: { at: STORY_NOW, kind: 'path-refused', end: 'remote', address: '172.58.12.9', addressSource: 'observed' },
},
},
docs: { story: { height: '360px' } },
Expand Down
Loading