483a5e0d75c5
fix: PCH-01-023 coarsen vault-history snapshot entryCount to a size bucket — plaintext field no longer leaks exact vault size or per-write deltas; UI reads 'up to N' (v3.8.1)
- time
- 2026-06-10 20:22 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- pch-01 fix integration
- parent
- 00ca18f952ce
↓ peach-browser-483a5e0d.tar.gzatlas at this save
files changed4 files
src/background/vault-history.test.ts+16 −8src/background/vault.ts+19 −1src/popup/views/Settings.tsx+1 −1src/shared/types.ts+6 −1src/background/vault-history.test.ts+16 −8
@@ -126,21 +126,29 @@126126 expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);127127 });128128 129- it("entryCount reflects current in-memory active count at snapshot time", async () => {129+ it("entryCount is coarsened to a size bucket, not the exact count (PCH-01-023)", async () => {130130 const vault = await import("./vault");131131 await vault.createVault("count-pass-12345");132132 await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });133- // Most recent snapshot was taken before the addEntry write fired its134- // persist, capturing the pre-add state of slotA but counting the135- // post-add in-memory entries — see spec Part 1.1.136133 await vault.addEntry({ title: "B", username: "", password: "p2", url: "" });137134 138135 const snaps = readHistory().snapshots;139136 expect(snaps).toHaveLength(2);140- // Newest snapshot was taken at the persistSlotA call inside the second141- // addEntry, when in-memory has 2 active entries.142- expect(snaps[0]!.entryCount).toBe(2);143- expect(snaps[1]!.entryCount).toBe(1);137+ // PCH-01-023: 1 and 2 active entries both fall in the smallest bucket (10),138+ // so the exact count and the per-write delta are no longer leaked.139+ expect(snaps[0]!.entryCount).toBe(10);140+ expect(snaps[1]!.entryCount).toBe(10);141+ });142+143+ it("entryCount bucket ceiling rounds up across a boundary (PCH-01-023)", async () => {144+ const vault = await import("./vault");145+ await vault.createVault("bucket-pass-12345");146+ // 11 active entries → just over the 10-bucket → reported as 25, never 11.147+ for (let i = 0; i < 11; i++) {148+ await vault.addEntry({ title: `E${i}`, username: "", password: `p${i}`, url: "" });149+ }150+ const snaps = readHistory().snapshots;151+ expect(snaps[0]!.entryCount).toBe(25);144152 });145153 146154 it("dedup: takeVaultSnapshot with no intervening slot write is a no-op the second time", async () => {src/background/vault.ts+19 −1
@@ -2607,6 +2607,21 @@26072607 const HISTORY_KEY = "peach_vault_history";26082608 const HISTORY_MAX = 5;26092609 2610+// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the2611+// plaintext snapshot field cannot reveal exact vault size or single-entry change2612+// deltas. Buckets widen as the vault grows so absolute leakage stays roughly2613+// proportional rather than exact.2614+function bucketEntryCount(n: number): number {2615+ if (n <= 0) return 0;2616+ if (n <= 10) return 10;2617+ if (n <= 25) return 25;2618+ if (n <= 50) return 50;2619+ if (n <= 100) return 100;2620+ if (n <= 250) return 250;2621+ if (n <= 500) return 500;2622+ return Math.ceil(n / 500) * 500;2623+}2624+26102625 /**26112626 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE26122627 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost@@ -2632,10 +2647,13 @@26322647 // Best-effort entry count: only meaningful when the in-memory state is26332648 // unlocked AND in real mode. Decoy mode would leak the decoy entry26342649 // count; locked mode has no entries to count. Both fall through to null.2650+ // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage2651+ // .local sits next to the AES-GCM blobs in plaintext, so an exact count would2652+ // leak vault size and per-write deltas (the metadata padding hides this).26352653 let entryCount: number | null = null;26362654 if (derivedKey && vaultMode === "real") {26372655 try {2638- entryCount = entries.filter((e) => !e.deletedAt).length;2656+ entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);26392657 } catch {26402658 entryCount = null;26412659 }src/popup/views/Settings.tsx+1 −1
@@ -1355,7 +1355,7 @@13551355 <span class="peach-history-sub">13561356 {[13571357 snap.entryCount !== null1358- ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1358+ ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`13591359 : null,13601360 reasonLabel(snap.reason),13611361 ]src/shared/types.ts+6 −1
@@ -476,7 +476,12 @@476476 reason: "local_save" | "sync_received" | "import" | "restore";477477 vault_a: string;478478 vault_b: string;479- /** Active entry count when the snapshot was taken; null if vault was locked. */479+ /**480+ * Coarse size bucket (ceiling) of active entries when the snapshot was taken;481+ * null if vault was locked or in decoy mode. PCH-01-023: bucketed, not exact,482+ * so the plaintext value cannot leak vault size or per-write deltas. UI should483+ * render it as "up to N", never as an exact count.484+ */480485 entryCount: number | null;481486 }482487