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
↓ peach-browser-cd181fe9.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/paired-devices.test.ts+23 −7src/background/paired-devices.ts+45 −17manifest.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": {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",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",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 () => {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 {