081ae8b38113

fix: preserve terminal revocations against peer floods and retain local revoke capacity (PB-26-110, v5.0.40)

time
2026-09-01 22:24 UTC · 1 month ago
machine
koh2
agent
claude-opus-5
session
2026-09-01 security assessment remediation
parent
65263d57b9a4
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/paired-devices.test.ts+20 −0src/background/paired-devices.ts+13 −3
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.39",4+  "version": "5.0.40",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.0.39",5+  "version": "5.0.40",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

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

view file

src/background/paired-devices.test.ts+20 −0
@@ -238,6 +238,26 @@238238       expect(await listDeviceRevocations()).toHaveLength(2);239239     });240240 241+    it("a peer flood cannot evict an older revocation from another issuer", async () => {242+      await mergeDeviceRevocations([{243+        deviceId: "genuine",244+        longTermPublicKeyHex: "a".repeat(64),245+        revokedAt: 1,246+        revokedByDeviceId: "honest-issuer",247+      }]);248+      const flood = Array.from({ length: 600 }, (_, index) => ({249+        deviceId: `junk-${index}`,250+        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),251+        revokedAt: index + 2,252+        revokedByDeviceId: "flooding-issuer",253+      }));254+      const admitted = await mergeDeviceRevocations(flood);255+      expect(admitted).toHaveLength(499);256+      expect(await listDeviceRevocations()).toHaveLength(500);257+      expect(await isDeviceRevoked("genuine")).toBe(true);258+      expect(await isDeviceRevoked("junk-599")).toBe(false);259+    });260+241261     it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {242262       await mergeDeviceRevocations([243263         { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },

view file

src/background/paired-devices.ts+13 −3
@@ -206,7 +206,13 @@206206     revokedAt: options.now ?? Date.now(),207207     revokedByDeviceId,208208   };209-  await storeRevocations([...existing, record]);209+  const removeIndex = existing.length >= MAX_REVOCATIONS210+    ? Math.max(0, existing.findIndex((r) => r.revokedByDeviceId !== revokedByDeviceId))211+    : -1;212+  const retained = removeIndex >= 0213+    ? existing.filter((_, index) => index !== removeIndex)214+    : existing;215+  await storeRevocations([...retained, record]);210216   await removePairedDeviceById(deviceId);211217   return record;212218 }@@ -231,8 +237,12 @@231237     // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).232238     .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));233239   if (fresh.length === 0) return [];234-  await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));235-  return fresh.map((r) => r.deviceId);240+  // Peer manifests may add records but never evict terminal records already241+  // held locally. A full ledger drops only newly learned records; the local242+  // revoke path remains responsible for administrative revocations.243+  const admitted = fresh.slice(0, Math.max(0, MAX_REVOCATIONS - existing.length));244+  if (admitted.length > 0) await storeRevocations([...existing, ...admitted]);245+  return admitted.map((r) => r.deviceId);236246 }237247 238248 /**

view file