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
4 files src/background/vault-history.test.ts+16 −8src/background/vault.ts+19 −1src/popup/views/Settings.tsx+1 −1src/shared/types.ts+6 −1
src/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 () => {

view file

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       }

view file

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                         ]

view file

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 

view file