27e2e647a814

fix: Reconcile paired-device revocations on startup (v4.2.15)

- Preserve only newer SAS-confirmed direct re-pairs - Remove stale or vouched pairs while keeping revocations terminal - Add startup migration coverage for all overlap outcomes

time
2026-07-27 17:33 UTC · 3 months ago
machine
koh2
agent
codex-gpt-5
session
peach-sync-e2e
parent
9faf99d62ad3
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/entry.ts+2 −0src/background/paired-devices.test.ts+67 −0src/background/paired-devices.ts+44 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.14",4+  "version": "4.2.15",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.14",4+  "version": "4.2.15",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.2.14",3+  "version": "4.2.15",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/entry.ts+2 −0
@@ -10,6 +10,7 @@1010 import { denyAllPendingPasskeyRequests } from "./passkey";1111 import { hydratePersistedSyncState, setSyncRunner, triggerSyncIfReachable } from "./sync-trigger";1212 import { productionSyncRunner } from "./sync-runner";13+import { repairPairedRevocationOverlaps } from "./paired-devices";1314 1415 // Keep alive for MV3 service worker1516 chrome.runtime.onInstalled.addListener(() => {});@@ -78,6 +79,7 @@7879   // guard inside triggerSyncIfReachable handles the case where another path7980   // (e.g. a fresh pairing completing on the same wake) is already running.8081   void restored82+    .then(() => repairPairedRevocationOverlaps())8183     .then(() => hydratePersistedSyncState())8284     .then(() => triggerSyncIfReachable({ reason: "startup" }))8385     .catch(() => undefined);

view file

src/background/paired-devices.test.ts+67 −0
@@ -13,6 +13,7 @@1313   isDeviceRevoked,1414   listDeviceRevocations,1515   mergeDeviceRevocations,16+  repairPairedRevocationOverlaps,1617   removePairedDeviceById,1718   revokeDeviceById,1819   setLastKnownIp,@@ -253,6 +254,72 @@253254 254255       expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);255256       expect(await listDeviceRevocations()).toHaveLength(0);257+    });258+259+    it("startup repair keeps a newer direct pair and clears its older revocation", async () => {260+      const now = Date.now();261+      await mergeDeviceRevocations([{262+        deviceId: SAMPLE.deviceId,263+        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,264+        revokedAt: now - 1_000,265+        revokedByDeviceId: "this-device",266+      }]);267+      await upsertPairedDevice({268+        ...SAMPLE,269+        pairedAt: now,270+        trustSource: "direct",271+      });272+273+      expect(await repairPairedRevocationOverlaps()).toEqual({274+        revocationsCleared: 1,275+        pairedRowsRemoved: 0,276+      });277+      expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();278+      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);279+    });280+281+    it("startup repair removes a direct pair older than its revocation", async () => {282+      const now = Date.now();283+      await upsertPairedDevice({284+        ...SAMPLE,285+        pairedAt: now - 2_000,286+        trustSource: "direct",287+      });288+      await mergeDeviceRevocations([{289+        deviceId: SAMPLE.deviceId,290+        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,291+        revokedAt: now - 1_000,292+        revokedByDeviceId: "this-device",293+      }]);294+295+      expect(await repairPairedRevocationOverlaps()).toEqual({296+        revocationsCleared: 0,297+        pairedRowsRemoved: 1,298+      });299+      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();300+      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);301+    });302+303+    it("startup repair never lets a newer vouched pair lift a revocation", async () => {304+      const now = Date.now();305+      await mergeDeviceRevocations([{306+        deviceId: SAMPLE.deviceId,307+        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,308+        revokedAt: now - 1_000,309+        revokedByDeviceId: "this-device",310+      }]);311+      await upsertPairedDevice({312+        ...SAMPLE,313+        pairedAt: now,314+        trustSource: "vouched",315+      });316+317+      expect(await repairPairedRevocationOverlaps()).toEqual({318+        revocationsCleared: 0,319+        pairedRowsRemoved: 1,320+      });321+      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();322+      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);256323     });257324   });258325 });

view file

src/background/paired-devices.ts+44 −0
@@ -243,6 +243,50 @@243243   await storeRevocations(filtered);244244 }245245 246+/**247+ * Repair the invalid paired+revoked overlap produced by older direct-pair248+ * handlers.249+ *250+ * A newer direct, SAS-confirmed pair is the only state allowed to lift the251+ * older revocation. Every other overlap resolves in favour of the revocation,252+ * including vouched devices and pairs that predate the removal.253+ */254+export async function repairPairedRevocationOverlaps(): Promise<{255+  revocationsCleared: number;256+  pairedRowsRemoved: number;257+}> {258+  const devices = await listPairedDevices();259+  const revocations = await listDeviceRevocations();260+  const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));261+  const revocationIdsToClear = new Set<string>();262+  const pairedIdsToRemove = new Set<string>();263+264+  for (const device of devices) {265+    const revocation = revocationById.get(device.deviceId);266+    if (!revocation) continue;267+    if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {268+      revocationIdsToClear.add(device.deviceId);269+    } else {270+      pairedIdsToRemove.add(device.deviceId);271+    }272+  }273+274+  if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {275+    return { revocationsCleared: 0, pairedRowsRemoved: 0 };276+  }277+278+  await chrome.storage.local.set({279+    [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),280+    [REVOCATIONS_KEY]: revocations.filter(281+      (record) => !revocationIdsToClear.has(record.deviceId),282+    ),283+  });284+  return {285+    revocationsCleared: revocationIdsToClear.size,286+    pairedRowsRemoved: pairedIdsToRemove.size,287+  };288+}289+246290 async function storeRevocations(records: DeviceRevocation[]): Promise<void> {247291   await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });248292 }

view file