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
↓ peach-browser-9faf99d6.tar.gzatlas at this save
files changed7 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 −1manifest.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": {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",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",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 callsrc/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;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" },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,