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: 5 additions & 3 deletions docs/specs/pocket-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Installed means `navigator.standalone === true` (iOS) or the standard `(display-

**Enable registers, and the offer counts, only the signed-in account's paired Burrows its list still names**: another account's record is never a target, since the session's Relay holds none of that account's Burrows.

**Obsolete delivery mappings are retired, durably.** A `pairing-required` transition, a re-pair that mints a new id, and an explicit **Remove** each write the old `{ burrowId, accountId, deliveryId }` (the record's account) to `PendingDeliveryDeletionV1` *before* the record forgets it (rationale), then call the idempotent deletion route; tombstones retry until a Relay answer clears them. **Only a session of the tombstone's account sends or clears it** (rationale). **This deletes the delivery row alone** — never the scope's shared `PushSubscription`, and never another Burrow's row.
**Obsolete delivery mappings are retired, durably.** A `pairing-required` transition, a re-pair that mints a new id, and an explicit **Remove** each write the old `{ burrowId, accountId, deliveryId }` (the record's account) to `PendingDeliveryDeletionV1` *before* the record forgets it (rationale), then call the idempotent deletion route; tombstones retry until a Relay answer clears them. **Only a session of the tombstone's account sends or clears it** (rationale). **This deletes the delivery row alone** — never another Burrow's row, and never the scope's shared `PushSubscription`, which only forgetting this phone drops ([What Pocket stores](#what-pocket-stores)).

Source of truth: `getPushAvailability` / `subscribeToPushInBrowser` in `lib/src/remote/client/push-subscribe.ts`; `pushNoticeState` in `lib/src/remote/pocket-app/App.tsx`; `PocketClient.subscribeToPush` / `retirePendingDeletions` in `lib/src/remote/client/pocket-client.ts`; `pushEndpointFingerprint` in `remote-lib-common/src/security/push.ts`.

Expand All @@ -133,9 +133,11 @@ Source of truth: `getPushAvailability` / `subscribeToPushInBrowser` in `lib/src/

**One module owns the IndexedDB name, its version, its upgrade, and every open** (rationale). `dormouse-pocket` is at **v4**: `known-burrows` (`KnownBurrowV1`, keyed by `burrowId`) and `pending-deletions` (`PendingDeliveryDeletionV1`, keyed `burrowId:deliveryId`); every earlier version upgrades to that shape. **A version bump is a compatibility event: it must never empty `pending-deletions` for a store already at v4**, which would discard owed deletions. **`navigator.storage.persist()` is requested best-effort**: a browser that refuses gets ordinary eviction-prone storage, which re-pairing survives ([remote-security-model.md](./remote-security-model.md) → Client static loss).

**A `KnownBurrowV1` is this Client's whole authorization state** for one Burrow. **`localStorage` holds only the `:passkey:` cache, the `:push-endpoint` digest, and the Relay session** (`dormouse-pocket:session`: token, account, credential id, expiry; rationale). **A launch holding an unexpired session opens on the Burrows list once the capability gate passes and its first read answers**, which finds out whether the Relay still honors it. **Sign out forgets the session locally; nothing revokes it on the Relay.**
**A `KnownBurrowV1` is this Client's whole authorization state** for one Burrow. **`localStorage` holds only the `:passkey:` cache, the `:push-endpoint` digest, and the Relay session** (`dormouse-pocket:session`: token, account, credential id, expiry; rationale). **A launch holding an unexpired session opens on the Burrows list once the capability gate passes and its first read answers**, which finds out whether the Relay still honors it. **Sign out forgets the session locally; nothing revokes it on the Relay.** **Sign out keeps every `KnownBurrowV1`, the `:passkey:` cache, and the push digest**, so signing in to the same account again restores access without pairing.

Source of truth: `requirePocketKeyStorage` in `lib/src/remote/client/pocket-db.ts`; `generatePocketKeyPair` in `lib/src/remote/client/pocket-private-key.ts`.
**Sign out and forget this phone asks first, then runs Remove for every record, of every account**, draining the deletion queue once; then it clears the `:passkey:` cache and the push digest, unsubscribes the scope's `PushSubscription` (rationale), and signs out, so the next launch is a first visit. **It reaches nothing on a Burrow**: each counts this phone among its paired phones until that Burrow's own Disconnect ([remote-security-model.md](./remote-security-model.md) → Revocation propagation).

Source of truth: `PocketClient.forgetThisPhone` in `lib/src/remote/client/pocket-client.ts`; `requirePocketKeyStorage` in `lib/src/remote/client/pocket-db.ts`; `generatePocketKeyPair` in `lib/src/remote/client/pocket-private-key.ts`.

## Serving the built bundle

Expand Down
2 changes: 2 additions & 0 deletions docs/specs/pocket-app.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ The phone subsequently reported `write-record / DataError` in the disposable dat

**Why the Relay session may sit in `localStorage`.** Without it every cold launch, and every relaunch of an installed app iOS evicted, cost a sign-in prompt before the Burrows list. The token is authentication-plane only: it reaches the Relay's routes and sockets for at most its 12 hours, and never stands in for a presence proof at a Burrow (remote-security-model, Presence proofs). Its exposure is the Client statics' class, which already live in the same origin's storage and are the stronger capability; script running in the Pocket origin is outside the model either way. It holds the credential id because pairing names the passkey a proof is built from, which a relaunch could not otherwise recover without a prompt.

**Why forgetting this phone also drops the browser's push subscription.** Another account's tombstone drains only under that account's session, which a leaving phone may never hold again. With the subscription gone, the push service answers that row's next delivery with a 410, and the Relay deletes it then.

**Why one module owns every IndexedDB open.** Two modules opening the same database can disagree about the version, and a connection held open across an upgrade blocks it indefinitely. Centralizing name, version, upgrade and open makes both states unreachable rather than merely unlikely.

## Serving the built bundle
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Gaps rather than accepted risks: we intend to close them.
- *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)).
- *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)); a phone can only forget its own pairings, with Pocket's Sign out and forget this phone.
- *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)).
- **The workflow audit's window can be evaded.** A pusher-set committer date hides a commit from every later window, and a branch pushed, run with repo-level secrets in scope, and deleted before the nightly fetch is in none ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)).
Expand Down
91 changes: 91 additions & 0 deletions lib/src/remote/client/pocket-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1753,6 +1753,80 @@ describe('the durable deletion queue', () => {
});
});

describe('forgetting this phone', () => {
/** A signed-in phone holding two records, a cached passkey, and a push digest. */
async function leavingPhone(routes: Record<string, RouteHandler> = {}) {
const storage = memoryStorage();
const harness = await signedIn(routes, { storage });
await seedRecord(harness.knownBurrows, 'h1');
await seedRecord(harness.knownBurrows, 'h2');
storage.setRegisteredPushEndpoint('endpoint-digest');
return { ...harness, storage };
}

it('removes every record, queues every delivery id, wipes the caches, and signs out, offline', async () => {
// No deletion route: every DELETE fails as an unreachable Relay's would.
const phone = await leavingPhone();
expect(phone.storage.knownCredentialIds()).toEqual([CREDENTIAL_ID]);

await phone.client.forgetThisPhone();

expect(phone.knownBurrows.records.size).toBe(0);
expect(
[...phone.pendingDeletions.records.values()].map(({ accountId, deliveryId }) => ({ accountId, deliveryId })),
).toEqual([
{ accountId: ACCOUNT_ID, deliveryId: 'delivery-h1' },
{ accountId: ACCOUNT_ID, deliveryId: 'delivery-h2' },
]);
expect(phone.storage.knownCredentialIds()).toEqual([]);
expect(phone.client.hasPriorUse()).toBe(false);
expect(phone.storage.getRegisteredPushEndpoint()).toBeNull();
expect(phone.client.sessionToken).toBeNull();
expect(phone.storage.getSession()).toBeNull();
});

it('retires every delivery row under the session before signing out', async () => {
const deletes: string[] = [];
const phone = await leavingPhone({
'/api/push/subscriptions/delivery-h1': () => (deletes.push('delivery-h1'), { status: 204 }),
'/api/push/subscriptions/delivery-h2': () => (deletes.push('delivery-h2'), { status: 204 }),
});

await phone.client.forgetThisPhone();

expect(deletes.sort()).toEqual(['delivery-h1', 'delivery-h2']);
expect(phone.pendingDeletions.records.size).toBe(0);
expect(phone.client.sessionToken).toBeNull();
});

it('queues another account’s delivery id for that account', async () => {
const phone = await leavingPhone({
'/api/push/subscriptions/delivery-h1': () => ({ status: 204 }),
'/api/push/subscriptions/delivery-h2': () => ({ status: 204 }),
});
await seedRecord(phone.knownBurrows, 'h3', { accountId: 'other-account' });

await phone.client.forgetThisPhone();

expect(phone.knownBurrows.records.size).toBe(0);
expect([...phone.pendingDeletions.records.values()]).toEqual([
{ burrowId: 'h3', accountId: 'other-account', deliveryId: 'delivery-h3', queuedAt: expect.any(Number) },
]);
});

it('is not what plain sign-out does', async () => {
const phone = await leavingPhone();

phone.client.signOut();

expect([...phone.knownBurrows.records.keys()]).toEqual(['h1', 'h2']);
expect(phone.pendingDeletions.records.size).toBe(0);
expect(phone.storage.knownCredentialIds()).toEqual([CREDENTIAL_ID]);
expect(phone.storage.getRegisteredPushEndpoint()).toBe('endpoint-digest');
expect(phone.client.sessionToken).toBeNull();
});
});

// --- The account plane ------------------------------------------------------

describe('the session across launches', () => {
Expand Down Expand Up @@ -2392,6 +2466,23 @@ describe('localStoragePocketStorage', () => {
expect(map.has('dormouse-pocket:session')).toBe(false);
});

it('forgets the passkey cache and the push digest in the tab and in storage', () => {
const { map, store } = fakeLocalStorage();
vi.stubGlobal('localStorage', store);
const earlierVisit = localStoragePocketStorage();
earlierVisit.setPasskeyPublicKey('cred-1', 'pk-1');
earlierVisit.setRegisteredPushEndpoint('digest');
const storage = localStoragePocketStorage();
storage.setPasskeyPublicKey('cred-2', 'pk-2');

for (const id of storage.knownCredentialIds()) storage.forgetPasskeyPublicKey(id);
storage.clearRegisteredPushEndpoint();

expect(storage.knownCredentialIds()).toEqual([]);
expect(storage.getRegisteredPushEndpoint()).toBeNull();
expect([...map.keys()]).toEqual([]);
});

it('clears the stored session only while it holds the token being cleared', () => {
const { map, store } = fakeLocalStorage();
vi.stubGlobal('localStorage', store);
Expand Down
56 changes: 45 additions & 11 deletions lib/src/remote/client/pocket-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@ export interface PocketStorage {
getPasskeyPublicKey(credentialId: string): string | null;
setPasskeyPublicKey(credentialId: string, publicKey: string): void;
/**
* Drop a cached key again. Only {@link PocketClient.setup} calls it, for a
* credential it cached moments earlier and the Relay then refused, so it
* never has an older visit's key to lose.
* Drop a cached key again: {@link PocketClient.setup} for a credential it
* cached moments earlier and the Relay then refused, and
* {@link PocketClient.forgetThisPhone} for every key this device holds.
*/
forgetPasskeyPublicKey(credentialId: string): void;
/** Credential ids this device has stored a public key for (may be empty). */
Expand All @@ -140,6 +140,8 @@ export interface PocketStorage {
*/
getRegisteredPushEndpoint(): string | null;
setRegisteredPushEndpoint(fingerprint: string): void;
/** Back to "never registered"; only {@link PocketClient.forgetThisPhone} calls it. */
clearRegisteredPushEndpoint(): void;
/**
* The Relay session, kept so a relaunch lands signed in. It reaches the
* Relay alone; every Burrow ceremony still demands its own presence proof.
Expand Down Expand Up @@ -659,18 +661,46 @@ export class PocketClient {
return this.#knownBurrows.listSummaries();
}

/** Forget one Burrow locally (Remove), then drain the deletion queue. */
async forgetBurrow(burrowId: string): Promise<void> {
await this.#removeRecord(burrowId);
await this.retirePendingDeletions();
}

/**
* Forget one Burrow locally: the tombstone is written *before* the record that
* holds the delivery id is deleted, so an unreachable Relay cannot strand a
* push row nothing can name again.
* Make this device forget every Burrow and the account, then sign out: a
* phone that is leaving. Each record goes the way {@link forgetBurrow} takes
* it, its delivery id tombstoned first, and the queue drains once at the end
* under this session, so an unreachable Relay leaves the tombstones for a
* later session of their account. Then the passkey cache and the push
* endpoint digest go, which makes the next launch a first visit.
*
* The browser's `PushSubscription` is the caller's to drop: this class
* touches no browser push API.
*/
async forgetBurrow(burrowId: string): Promise<void> {
async forgetThisPhone(): Promise<void> {
for (const { burrowId } of await this.#knownBurrows.listSummaries()) {
await this.#removeRecord(burrowId);
}
await this.retirePendingDeletions();
for (const credentialId of this.#storage.knownCredentialIds()) {
this.#storage.forgetPasskeyPublicKey(credentialId);
}
this.#storage.clearRegisteredPushEndpoint();
this.signOut();
}

/**
* Delete one record locally: the tombstone is written *before* the record
* that holds the delivery id is deleted, so an unreachable Relay cannot
* strand a push row nothing can name again.
*/
async #removeRecord(burrowId: string): Promise<void> {
const record = await this.#knownBurrows.getSummary(burrowId);
if (record?.authorization.state === 'paired') {
await this.#tombstone(record, record.authorization.deliveryId);
}
await this.#knownBurrows.delete(burrowId);
await this.retirePendingDeletions();
}

// --- Web Push ------------------------------------------------------------
Expand Down Expand Up @@ -1499,9 +1529,8 @@ export function localStoragePocketStorage(): PocketStorage {
passkeys.set(credentialId, publicKey);
write(PASSKEY_PREFIX + credentialId, publicKey);
},
// No tombstone: the only key ever forgotten was written by this same run,
// so storage holds it only if that write worked — in which case this
// removal does too.
// No tombstone in the mirror: storage that refuses this removal refuses
// the reads too, so a key it still holds is one nothing reads back.
forgetPasskeyPublicKey: (credentialId) => {
passkeys.delete(credentialId);
drop(PASSKEY_PREFIX + credentialId);
Expand All @@ -1521,6 +1550,11 @@ export function localStoragePocketStorage(): PocketStorage {
pushEndpoint = fingerprint;
write(PUSH_ENDPOINT_KEY, fingerprint);
},
// As with the passkeys, no tombstone: blocked storage reads nothing back.
clearRegisteredPushEndpoint: () => {
pushEndpoint = undefined;
drop(PUSH_ENDPOINT_KEY);
},
// No mirror: the client reads this once, at construction, and holds the
// session itself for the life of the tab.
getSession: () => parseStoredSession(read(SESSION_KEY)),
Expand Down
22 changes: 22 additions & 0 deletions lib/src/remote/client/push-subscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
hasCurrentPushSubscription,
requiresInstallForPush,
subscribeToPushInBrowser,
unsubscribeFromPushInBrowser,
} from './push-subscribe';

/**
Expand Down Expand Up @@ -353,3 +354,24 @@ describe('subscribeToPushInBrowser', () => {
expect(onReplaced).toHaveBeenCalledOnce();
});
});

describe('unsubscribeFromPushInBrowser', () => {
it('drops the scope-wide subscription', async () => {
stubBrowser({ permission: 'granted' });
const unsubscribe = vi.fn(async () => true);
getRegistration.mockResolvedValue(registration({ unsubscribe }));

await unsubscribeFromPushInBrowser();

expect(unsubscribe).toHaveBeenCalledOnce();
});

it('never throws, so a leaving phone still signs out', async () => {
stubBrowser({ permission: 'granted' });
getRegistration.mockResolvedValue(
registration({ unsubscribe: () => Promise.reject(new Error('push service unreachable')) }),
);

await expect(unsubscribeFromPushInBrowser()).resolves.toBeUndefined();
});
});
15 changes: 15 additions & 0 deletions lib/src/remote/client/push-subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,21 @@ export async function subscribeToPushInBrowser(
return { endpoint, keys: { p256dh, auth } };
}

/**
* Drop the scope's `PushSubscription`, so its address reaches this device no
* more. Best-effort and never throws: a phone that is leaving must still sign
* out, and a Relay row left pointing at a dead address is pruned on its next
* delivery.
*/
export async function unsubscribeFromPushInBrowser(): Promise<void> {
try {
const registration = await getPushServiceWorkerRegistration();
await (await registration?.pushManager.getSubscription())?.unsubscribe();
} catch {
// Nothing left to do with a subscription the browser will not drop.
}
}

/** A missing reported key mismatches, safely forcing rotation. */
function sameBytes(actual: ArrayBuffer | null, expected: Uint8Array): boolean {
if (!actual) return false;
Expand Down
Loading
Loading