cd181fe97194

evict revocations from the largest-holding issuer so one flood cannot starve another's records (PB-26-110, v5.0.43)

time
2026-09-02 04:05 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
assessment remediation corrections
parent
f0ba500e86ed
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/paired-devices.test.ts+23 −7src/background/paired-devices.ts+45 −17
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.42",4+  "version": "5.0.43",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.42",5+  "version": "5.0.43",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.42",3+  "version": "5.0.43",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/paired-devices.test.ts+23 −7
@@ -238,21 +238,33 @@238238       expect(await listDeviceRevocations()).toHaveLength(2);239239     });240240 241-    it("peer input cannot evict any record when the ledger is full", async () => {241+    // PB-26-110: the ledger must stay bounded without letting an early flood242+    // consume it permanently. A later revocation from a DIFFERENT issuer is243+    // the record that matters — it has to be admitted even when the ledger is244+    // already full of another issuer's junk.245+    it("a later revocation from a different issuer is still admitted on a full ledger", async () => {242246       await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({243247         deviceId: `terminal-${index}`,244248         longTermPublicKeyHex: index.toString(16).padStart(64, "0"),245249         revokedAt: index + 1,246250         revokedByDeviceId: "issuer-a",247251       })));252+248253       expect(await mergeDeviceRevocations([{249254         deviceId: "peer-extra",250255         longTermPublicKeyHex: "f".repeat(64),251256         revokedAt: 999,252257         revokedByDeviceId: "issuer-b",253-      }])).toEqual([]);254-      expect(await listDeviceRevocations()).toHaveLength(500);255-      expect(await isDeviceRevoked("terminal-0")).toBe(true);258+      }])).toEqual(["peer-extra"]);259+260+      const records = await listDeviceRevocations();261+      expect(records).toHaveLength(500);262+      // The genuine record is admitted and still blocks re-vouching.263+      expect(await isDeviceRevoked("peer-extra")).toBe(true);264+      // Room was made by evicting the over-represented issuer's own record,265+      // not another issuer's.266+      expect(records.filter((r) => r.revokedByDeviceId === "issuer-b")).toHaveLength(1);267+      expect(await isDeviceRevoked("terminal-0")).toBe(false);256268     });257269 258270     it("a same-issuer local flood retains the bounded ledger", async () => {@@ -299,10 +311,14 @@299311         revokedByDeviceId: "flooding-issuer",300312       }));301313       const admitted = await mergeDeviceRevocations(flood);302-      expect(admitted).toHaveLength(499);303-      expect(await listDeviceRevocations()).toHaveLength(500);314+      expect(admitted).toHaveLength(600);315+      const records = await listDeviceRevocations();316+      expect(records).toHaveLength(500);304317       expect(await isDeviceRevoked("genuine")).toBe(true);305-      expect(await isDeviceRevoked("junk-599")).toBe(false);318+      // The flooder is the over-represented issuer, so its own oldest records319+      // are what get dropped — the newest junk survives, the oldest does not.320+      expect(await isDeviceRevoked("junk-599")).toBe(true);321+      expect(await isDeviceRevoked("junk-0")).toBe(false);306322     });307323 308324     it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {

view file

src/background/paired-devices.ts+45 −17
@@ -206,16 +206,7 @@206206     revokedAt: options.now ?? Date.now(),207207     revokedByDeviceId,208208   };209-  const removeIndex = existing.length >= MAX_REVOCATIONS210-    ? (() => {211-        const otherIssuer = existing.findIndex((r) => r.revokedByDeviceId !== revokedByDeviceId);212-        return otherIssuer >= 0 ? otherIssuer : 0;213-      })()214-    : -1;215-  const retained = removeIndex >= 0216-    ? existing.filter((_, index) => index !== removeIndex)217-    : existing;218-  await storeRevocations([...retained, record]);209+  await storeRevocations([...existing, record]);219210   await removePairedDeviceById(deviceId);220211   return record;221212 }@@ -240,12 +231,8 @@240231     // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).241232     .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));242233   if (fresh.length === 0) return [];243-  // Peer manifests may add records but never evict terminal records already244-  // held locally. A full ledger drops only newly learned records; the local245-  // revoke path remains responsible for administrative revocations.246-  const admitted = fresh.slice(0, Math.max(0, MAX_REVOCATIONS - existing.length));247-  if (admitted.length > 0) await storeRevocations([...existing, ...admitted]);248-  return admitted.map((r) => r.deviceId);234+  await storeRevocations([...existing, ...fresh]);235+  return fresh.map((r) => r.deviceId);249236 }250237 251238 /**@@ -293,7 +280,48 @@293280 }294281 295282 async function storeRevocations(records: DeviceRevocation[]): Promise<void> {296-  await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });283+  await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });284+}285+286+/**287+ * PB-26-110: bound the ledger by evicting from whichever issuer currently holds288+ * the most records, oldest first.289+ *290+ * Evicting globally-oldest-first let one early flood consume the whole cap and291+ * permanently starve every other issuer, including genuine revocations, because292+ * declining to evict at all is not a bounded option. Largest-holder-first keeps293+ * the bound while guaranteeing a flood can only ever displace its own records:294+ * once an issuer holds more than anyone else, everything it adds costs it one of295+ * its own oldest.296+ *297+ * ponytail: O(evictions x issuers) rescan of the per-issuer lists. Fine at a298+ * 500-record cap; switch to a live-count map if the cap ever grows by orders of299+ * magnitude.300+ */301+function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {302+  if (records.length <= MAX_REVOCATIONS) return records;303+  const byIssuer = new Map<string, DeviceRevocation[]>();304+  for (const record of records) {305+    const key = record.revokedByDeviceId ?? "";306+    const list = byIssuer.get(key);307+    if (list) list.push(record);308+    else byIssuer.set(key, [record]);309+  }310+  const dropped = new Set<DeviceRevocation>();311+  for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {312+    let victim: DeviceRevocation[] | undefined;313+    for (const list of byIssuer.values()) {314+      const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);315+      if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {316+        victim = list;317+      }318+    }319+    if (!victim) break;320+    const oldest = victim.find((r) => !dropped.has(r));321+    if (!oldest) break;322+    dropped.add(oldest);323+  }324+  return records.filter((r) => !dropped.has(r));297325 }298326 299327 function isDeviceRevocation(x: unknown): x is DeviceRevocation {

view file