Skip to content

Commit e915cd5

Browse files
authored
Add "Sign out and forget this phone" to Pocket (#1251)
2 parents 4d64f88 + 966c3b0 commit e915cd5

12 files changed

Lines changed: 357 additions & 27 deletions

‎docs/specs/pocket-app.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ Installed means `navigator.standalone === true` (iOS) or the standard `(display-
119119

120120
**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.
121121

122-
**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.
122+
**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)).
123123

124124
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`.
125125

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

134134
**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).
135135

136-
**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.**
136+
**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.
137137

138-
Source of truth: `requirePocketKeyStorage` in `lib/src/remote/client/pocket-db.ts`; `generatePocketKeyPair` in `lib/src/remote/client/pocket-private-key.ts`.
138+
**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).
139+
140+
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`.
139141

140142
## Serving the built bundle
141143

‎docs/specs/pocket-app.rationale.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ The phone subsequently reported `write-record / DataError` in the disposable dat
7878

7979
**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.
8080

81+
**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.
82+
8183
**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.
8284

8385
## Serving the built bundle

‎docs/specs/security.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Gaps rather than accepted risks: we intend to close them.
7676
- *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)).
7777
- *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)).
7878
- *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)).
79-
- *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)).
79+
- *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.
8080
- *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)).
8181
- **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)).
8282
- **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)).

‎lib/src/remote/client/pocket-client.test.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1753,6 +1753,80 @@ describe('the durable deletion queue', () => {
17531753
});
17541754
});
17551755

1756+
describe('forgetting this phone', () => {
1757+
/** A signed-in phone holding two records, a cached passkey, and a push digest. */
1758+
async function leavingPhone(routes: Record<string, RouteHandler> = {}) {
1759+
const storage = memoryStorage();
1760+
const harness = await signedIn(routes, { storage });
1761+
await seedRecord(harness.knownBurrows, 'h1');
1762+
await seedRecord(harness.knownBurrows, 'h2');
1763+
storage.setRegisteredPushEndpoint('endpoint-digest');
1764+
return { ...harness, storage };
1765+
}
1766+
1767+
it('removes every record, queues every delivery id, wipes the caches, and signs out, offline', async () => {
1768+
// No deletion route: every DELETE fails as an unreachable Relay's would.
1769+
const phone = await leavingPhone();
1770+
expect(phone.storage.knownCredentialIds()).toEqual([CREDENTIAL_ID]);
1771+
1772+
await phone.client.forgetThisPhone();
1773+
1774+
expect(phone.knownBurrows.records.size).toBe(0);
1775+
expect(
1776+
[...phone.pendingDeletions.records.values()].map(({ accountId, deliveryId }) => ({ accountId, deliveryId })),
1777+
).toEqual([
1778+
{ accountId: ACCOUNT_ID, deliveryId: 'delivery-h1' },
1779+
{ accountId: ACCOUNT_ID, deliveryId: 'delivery-h2' },
1780+
]);
1781+
expect(phone.storage.knownCredentialIds()).toEqual([]);
1782+
expect(phone.client.hasPriorUse()).toBe(false);
1783+
expect(phone.storage.getRegisteredPushEndpoint()).toBeNull();
1784+
expect(phone.client.sessionToken).toBeNull();
1785+
expect(phone.storage.getSession()).toBeNull();
1786+
});
1787+
1788+
it('retires every delivery row under the session before signing out', async () => {
1789+
const deletes: string[] = [];
1790+
const phone = await leavingPhone({
1791+
'/api/push/subscriptions/delivery-h1': () => (deletes.push('delivery-h1'), { status: 204 }),
1792+
'/api/push/subscriptions/delivery-h2': () => (deletes.push('delivery-h2'), { status: 204 }),
1793+
});
1794+
1795+
await phone.client.forgetThisPhone();
1796+
1797+
expect(deletes.sort()).toEqual(['delivery-h1', 'delivery-h2']);
1798+
expect(phone.pendingDeletions.records.size).toBe(0);
1799+
expect(phone.client.sessionToken).toBeNull();
1800+
});
1801+
1802+
it('queues another account’s delivery id for that account', async () => {
1803+
const phone = await leavingPhone({
1804+
'/api/push/subscriptions/delivery-h1': () => ({ status: 204 }),
1805+
'/api/push/subscriptions/delivery-h2': () => ({ status: 204 }),
1806+
});
1807+
await seedRecord(phone.knownBurrows, 'h3', { accountId: 'other-account' });
1808+
1809+
await phone.client.forgetThisPhone();
1810+
1811+
expect(phone.knownBurrows.records.size).toBe(0);
1812+
expect([...phone.pendingDeletions.records.values()]).toEqual([
1813+
{ burrowId: 'h3', accountId: 'other-account', deliveryId: 'delivery-h3', queuedAt: expect.any(Number) },
1814+
]);
1815+
});
1816+
1817+
it('is not what plain sign-out does', async () => {
1818+
const phone = await leavingPhone();
1819+
1820+
phone.client.signOut();
1821+
1822+
expect([...phone.knownBurrows.records.keys()]).toEqual(['h1', 'h2']);
1823+
expect(phone.pendingDeletions.records.size).toBe(0);
1824+
expect(phone.storage.knownCredentialIds()).toEqual([CREDENTIAL_ID]);
1825+
expect(phone.storage.getRegisteredPushEndpoint()).toBe('endpoint-digest');
1826+
expect(phone.client.sessionToken).toBeNull();
1827+
});
1828+
});
1829+
17561830
// --- The account plane ------------------------------------------------------
17571831

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

2469+
it('forgets the passkey cache and the push digest in the tab and in storage', () => {
2470+
const { map, store } = fakeLocalStorage();
2471+
vi.stubGlobal('localStorage', store);
2472+
const earlierVisit = localStoragePocketStorage();
2473+
earlierVisit.setPasskeyPublicKey('cred-1', 'pk-1');
2474+
earlierVisit.setRegisteredPushEndpoint('digest');
2475+
const storage = localStoragePocketStorage();
2476+
storage.setPasskeyPublicKey('cred-2', 'pk-2');
2477+
2478+
for (const id of storage.knownCredentialIds()) storage.forgetPasskeyPublicKey(id);
2479+
storage.clearRegisteredPushEndpoint();
2480+
2481+
expect(storage.knownCredentialIds()).toEqual([]);
2482+
expect(storage.getRegisteredPushEndpoint()).toBeNull();
2483+
expect([...map.keys()]).toEqual([]);
2484+
});
2485+
23952486
it('clears the stored session only while it holds the token being cleared', () => {
23962487
const { map, store } = fakeLocalStorage();
23972488
vi.stubGlobal('localStorage', store);

‎lib/src/remote/client/pocket-client.ts‎

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,9 @@ export interface PocketStorage {
125125
getPasskeyPublicKey(credentialId: string): string | null;
126126
setPasskeyPublicKey(credentialId: string, publicKey: string): void;
127127
/**
128-
* Drop a cached key again. Only {@link PocketClient.setup} calls it, for a
129-
* credential it cached moments earlier and the Relay then refused, so it
130-
* never has an older visit's key to lose.
128+
* Drop a cached key again: {@link PocketClient.setup} for a credential it
129+
* cached moments earlier and the Relay then refused, and
130+
* {@link PocketClient.forgetThisPhone} for every key this device holds.
131131
*/
132132
forgetPasskeyPublicKey(credentialId: string): void;
133133
/** Credential ids this device has stored a public key for (may be empty). */
@@ -140,6 +140,8 @@ export interface PocketStorage {
140140
*/
141141
getRegisteredPushEndpoint(): string | null;
142142
setRegisteredPushEndpoint(fingerprint: string): void;
143+
/** Back to "never registered"; only {@link PocketClient.forgetThisPhone} calls it. */
144+
clearRegisteredPushEndpoint(): void;
143145
/**
144146
* The Relay session, kept so a relaunch lands signed in. It reaches the
145147
* Relay alone; every Burrow ceremony still demands its own presence proof.
@@ -659,18 +661,46 @@ export class PocketClient {
659661
return this.#knownBurrows.listSummaries();
660662
}
661663

664+
/** Forget one Burrow locally (Remove), then drain the deletion queue. */
665+
async forgetBurrow(burrowId: string): Promise<void> {
666+
await this.#removeRecord(burrowId);
667+
await this.retirePendingDeletions();
668+
}
669+
662670
/**
663-
* Forget one Burrow locally: the tombstone is written *before* the record that
664-
* holds the delivery id is deleted, so an unreachable Relay cannot strand a
665-
* push row nothing can name again.
671+
* Make this device forget every Burrow and the account, then sign out: a
672+
* phone that is leaving. Each record goes the way {@link forgetBurrow} takes
673+
* it, its delivery id tombstoned first, and the queue drains once at the end
674+
* under this session, so an unreachable Relay leaves the tombstones for a
675+
* later session of their account. Then the passkey cache and the push
676+
* endpoint digest go, which makes the next launch a first visit.
677+
*
678+
* The browser's `PushSubscription` is the caller's to drop: this class
679+
* touches no browser push API.
666680
*/
667-
async forgetBurrow(burrowId: string): Promise<void> {
681+
async forgetThisPhone(): Promise<void> {
682+
for (const { burrowId } of await this.#knownBurrows.listSummaries()) {
683+
await this.#removeRecord(burrowId);
684+
}
685+
await this.retirePendingDeletions();
686+
for (const credentialId of this.#storage.knownCredentialIds()) {
687+
this.#storage.forgetPasskeyPublicKey(credentialId);
688+
}
689+
this.#storage.clearRegisteredPushEndpoint();
690+
this.signOut();
691+
}
692+
693+
/**
694+
* Delete one record locally: the tombstone is written *before* the record
695+
* that holds the delivery id is deleted, so an unreachable Relay cannot
696+
* strand a push row nothing can name again.
697+
*/
698+
async #removeRecord(burrowId: string): Promise<void> {
668699
const record = await this.#knownBurrows.getSummary(burrowId);
669700
if (record?.authorization.state === 'paired') {
670701
await this.#tombstone(record, record.authorization.deliveryId);
671702
}
672703
await this.#knownBurrows.delete(burrowId);
673-
await this.retirePendingDeletions();
674704
}
675705

676706
// --- Web Push ------------------------------------------------------------
@@ -1499,9 +1529,8 @@ export function localStoragePocketStorage(): PocketStorage {
14991529
passkeys.set(credentialId, publicKey);
15001530
write(PASSKEY_PREFIX + credentialId, publicKey);
15011531
},
1502-
// No tombstone: the only key ever forgotten was written by this same run,
1503-
// so storage holds it only if that write worked — in which case this
1504-
// removal does too.
1532+
// No tombstone in the mirror: storage that refuses this removal refuses
1533+
// the reads too, so a key it still holds is one nothing reads back.
15051534
forgetPasskeyPublicKey: (credentialId) => {
15061535
passkeys.delete(credentialId);
15071536
drop(PASSKEY_PREFIX + credentialId);
@@ -1521,6 +1550,11 @@ export function localStoragePocketStorage(): PocketStorage {
15211550
pushEndpoint = fingerprint;
15221551
write(PUSH_ENDPOINT_KEY, fingerprint);
15231552
},
1553+
// As with the passkeys, no tombstone: blocked storage reads nothing back.
1554+
clearRegisteredPushEndpoint: () => {
1555+
pushEndpoint = undefined;
1556+
drop(PUSH_ENDPOINT_KEY);
1557+
},
15241558
// No mirror: the client reads this once, at construction, and holds the
15251559
// session itself for the life of the tab.
15261560
getSession: () => parseStoredSession(read(SESSION_KEY)),

‎lib/src/remote/client/push-subscribe.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
hasCurrentPushSubscription,
1313
requiresInstallForPush,
1414
subscribeToPushInBrowser,
15+
unsubscribeFromPushInBrowser,
1516
} from './push-subscribe';
1617

1718
/**
@@ -353,3 +354,24 @@ describe('subscribeToPushInBrowser', () => {
353354
expect(onReplaced).toHaveBeenCalledOnce();
354355
});
355356
});
357+
358+
describe('unsubscribeFromPushInBrowser', () => {
359+
it('drops the scope-wide subscription', async () => {
360+
stubBrowser({ permission: 'granted' });
361+
const unsubscribe = vi.fn(async () => true);
362+
getRegistration.mockResolvedValue(registration({ unsubscribe }));
363+
364+
await unsubscribeFromPushInBrowser();
365+
366+
expect(unsubscribe).toHaveBeenCalledOnce();
367+
});
368+
369+
it('never throws, so a leaving phone still signs out', async () => {
370+
stubBrowser({ permission: 'granted' });
371+
getRegistration.mockResolvedValue(
372+
registration({ unsubscribe: () => Promise.reject(new Error('push service unreachable')) }),
373+
);
374+
375+
await expect(unsubscribeFromPushInBrowser()).resolves.toBeUndefined();
376+
});
377+
});

‎lib/src/remote/client/push-subscribe.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,21 @@ export async function subscribeToPushInBrowser(
135135
return { endpoint, keys: { p256dh, auth } };
136136
}
137137

138+
/**
139+
* Drop the scope's `PushSubscription`, so its address reaches this device no
140+
* more. Best-effort and never throws: a phone that is leaving must still sign
141+
* out, and a Relay row left pointing at a dead address is pruned on its next
142+
* delivery.
143+
*/
144+
export async function unsubscribeFromPushInBrowser(): Promise<void> {
145+
try {
146+
const registration = await getPushServiceWorkerRegistration();
147+
await (await registration?.pushManager.getSubscription())?.unsubscribe();
148+
} catch {
149+
// Nothing left to do with a subscription the browser will not drop.
150+
}
151+
}
152+
138153
/** A missing reported key mismatches, safely forcing rotation. */
139154
function sameBytes(actual: ArrayBuffer | null, expected: Uint8Array): boolean {
140155
if (!actual) return false;

0 commit comments

Comments
 (0)