27e2e647a814
fix: Reconcile paired-device revocations on startup (v4.2.15)
- time
- 2026-07-27 17:33 UTC · 3 months ago
- machine
- koh2
- agent
- codex-gpt-5
- session
- peach-sync-e2e
- parent
- 9faf99d62ad3
↓ peach-browser-27e2e647.tar.gzatlas at this save
files changed6 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 −0manifest.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": {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",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",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);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 });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 }