9faf99d62ad3

fix: Repair stale paired-device removal (v4.2.14)\n\n- Remove overlapping paired rows even when a terminal revocation already exists\n- Clear old revocations after a fresh SAS-confirmed direct re-pair\n- Add regressions for both live-state shapes

time
2026-07-27 17:17 UTC · 3 months ago
machine
koh2
agent
codex-gpt-5
session
peach-sync-e2e
parent
f1785b58565a
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+33 −0src/background/messages.ts+6 −0src/background/paired-devices.test.ts+12 −0src/background/paired-devices.ts+8 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.13",4+  "version": "4.2.14",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.13",4+  "version": "4.2.14",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.13",3+  "version": "4.2.14",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.test.ts+33 −0
@@ -1187,6 +1187,39 @@11871187     expect(res).toMatchObject({ ok: false, error: "invalid_payload" });11881188   });11891189 1190+  it("a fresh direct re-pair clears the old revocation before restoring the row", async () => {1191+    const vault = await import("./vault");1192+    await vault.createVault("correct-pass-12345");1193+    const {1194+      findPairedDeviceById,1195+      isDeviceRevoked,1196+      upsertPairedDevice,1197+    } = await import("./paired-devices");1198+    const phone = {1199+      deviceId: "phone-1",1200+      deviceName: "June's Phone",1201+      deviceType: "android" as const,1202+      longTermPublicKeyHex: "a".repeat(64),1203+      signingPublicKeyHex: "b".repeat(64),1204+      pairedAt: 1_700_000_000_000,1205+      trustSource: "direct" as const,1206+    };1207+    await upsertPairedDevice(phone);1208+    await dispatch({1209+      type: "UNPAIR_DEVICE",1210+      payload: { deviceId: phone.deviceId },1211+    });1212+    expect(await isDeviceRevoked(phone.deviceId, phone.longTermPublicKeyHex)).toBe(true);1213+1214+    await expect(dispatch({1215+      type: "COMPLETE_PAIRING",1216+      payload: { paired: phone },1217+    })).resolves.toMatchObject({ ok: true });1218+1219+    expect(await isDeviceRevoked(phone.deviceId, phone.longTermPublicKeyHex)).toBe(false);1220+    expect(await findPairedDeviceById(phone.deviceId)).toMatchObject(phone);1221+  });1222+11901223   it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {11911224     // The cross-manifest refusal is exercised end-to-end in sync-vault-adapter.test.ts;11921225     // here we just pin the storage-level invariant the UNPAIR_DEVICE call

view file

src/background/messages.ts+6 −0
@@ -73,6 +73,7 @@7373   setDeviceName as setDeviceNameV3,7474 } from "./device-identity";7575 import {76+  clearDeviceRevocation,7677   isDeviceRevoked,7778   listPairedDevices as listPairedDevicesV3,7879   revokeDeviceById as revokeDeviceByIdV3,@@ -1401,6 +1402,11 @@14011402         ) {14021403           return { ok: false, error: "invalid_paired_device" };14031404         }1405+        // A fresh SAS-confirmed direct pairing is the one operation allowed to1406+        // lift an old terminal revocation. Without this, re-pairing recreated1407+        // an impossible state where the same device was both paired and1408+        // revoked, and a later Remove became a permanent no-op.1409+        await clearDeviceRevocation(candidate.deviceId);14041410         await upsertPairedDeviceV3(candidate as unknown as import("../shared/sync-types").PairedDevice);14051411         // Newly paired device → kick a sync so it gets its first round14061412         // without waiting for the next 10-minute alarm tick. Fire-and-forget;

view file

src/background/paired-devices.test.ts+12 −0
@@ -199,6 +199,18 @@199199       expect(await listDeviceRevocations()).toHaveLength(1);200200     });201201 202+    it("an existing revocation still removes a stale re-paired row", async () => {203+      await upsertPairedDevice(SAMPLE);204+      await revokeDeviceById(SAMPLE.deviceId, "this-device");205+      // Reproduce the real corrupt overlap created by older direct-pair206+      // handlers: the row returned but its terminal revocation remained.207+      await upsertPairedDevice(SAMPLE);208+209+      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();210+      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();211+      expect(await listDeviceRevocations()).toHaveLength(1);212+    });213+202214     it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {203215       const first = await mergeDeviceRevocations([204216         { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },

view file

src/background/paired-devices.ts+8 −1
@@ -183,7 +183,14 @@183183 ): Promise<DeviceRevocation | null> {184184   const device = await findPairedDeviceById(deviceId);185185   const existing = await listDeviceRevocations();186-  if (existing.some((r) => r.deviceId === deviceId)) return null;186+  if (existing.some((r) => r.deviceId === deviceId)) {187+    // A direct re-pair in older builds could restore the paired row without188+    // clearing its terminal revocation. "Remove" then returned early here and189+    // left the row visible forever. Idempotency applies to the revocation190+    // record, not to removing any stale live row that overlaps it.191+    if (device) await removePairedDeviceById(deviceId);192+    return null;193+  }187194 188195   const record: DeviceRevocation = {189196     deviceId,

view file