c3b41306469d

fix: COR-003 stamp peach_last_modified_at on local vault mutations (v2.45.4)

time
2026-05-15 21:05 UTC · 5 months ago
machine
koh2
parent
45f6eddadb59
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.test.ts+72 −1src/background/vault.ts+21 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.45.3",4+  "version": "2.45.4",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.45.3",4+  "version": "2.45.4",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.45.3",3+  "version": "2.45.4",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/vault.test.ts+72 −1
@@ -108,7 +108,13 @@108108     // pre-setupDuress real-mode blob). No exposed duress flag.109109     for (const key of keys) {110110       expect(111-        ["peach_vault_a", "peach_vault_b", "peach_settings", "peach_vault_history"]111+        [112+          "peach_vault_a",113+          "peach_vault_b",114+          "peach_settings",115+          "peach_vault_history",116+          "peach_last_modified_at",117+        ]112118       ).toContain(key);113119     }114120     expect(keys).toContain("peach_vault_a");@@ -289,6 +295,71 @@289295 290296     vault.lock();291297     expect(await vault.unlock("old-pass-abc123")).toBe(false);298+  });299+300+  it("stamps peach_last_modified_at on createVault", async () => {301+    const vault = await import("./vault");302+    const before = Date.now();303+    await new Promise((resolve) => setTimeout(resolve, 1));304+305+    await vault.createVault("stamp-create-pass");306+307+    expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);308+  });309+310+  it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {311+    const vault = await import("./vault");312+    await vault.createVault("stamp-entry-pass");313+314+    let previous = Number(storage.peach_last_modified_at);315+    await new Promise((resolve) => setTimeout(resolve, 1));316+    const entry = await vault.addEntry({317+      title: "Stamp",318+      username: "",319+      password: "p",320+      url: "",321+    });322+    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);323+324+    previous = Number(storage.peach_last_modified_at);325+    await new Promise((resolve) => setTimeout(resolve, 1));326+    await vault.updateEntry(entry.id, { title: "Stamped" });327+    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);328+329+    previous = Number(storage.peach_last_modified_at);330+    await new Promise((resolve) => setTimeout(resolve, 1));331+    await vault.deleteEntry(entry.id);332+    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);333+  });334+335+  it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {336+    const vault = await import("./vault");337+    await vault.createVault("stamp-real-pass");338+339+    let previous = Number(storage.peach_last_modified_at);340+    await new Promise((resolve) => setTimeout(resolve, 1));341+    await vault.setupDuress("stamp-decoy-pass");342+    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);343+344+    previous = Number(storage.peach_last_modified_at);345+    await new Promise((resolve) => setTimeout(resolve, 1));346+    expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);347+    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);348+  });349+350+  it("applyIncomingVault preserves the remote last-modified timestamp", async () => {351+    const vault = await import("./vault");352+    await vault.createVault("remote-pass");353+    const blob = JSON.stringify({354+      a: storage.peach_vault_a,355+      b: storage.peach_vault_b,356+    });357+    storage.peach_last_modified_at = "1000";358+    const remoteTimestamp = 2000;359+360+    expect(await vault.applyIncomingVault(blob, remoteTimestamp)).toBe(true);361+362+    expect(storage.peach_last_modified_at).toBe(String(remoteTimestamp));292363   });293364 294365   it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {

view file

src/background/vault.ts+21 −0
@@ -367,6 +367,7 @@367367   if (!duressConfigured) {368368     await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));369369   }370+  await stampLocalVaultMutation();370371 }371372 372373 // ---------------------------------------------------------------------------@@ -439,6 +440,7 @@439440     recovery_tag: wrappedTag,440441   };441442   await writeSlot(SLOT_A, nextSlotA);443+  await stampLocalVaultMutation();442444 }443445 444446 /**@@ -466,6 +468,12 @@466468   }467469 }468470 471+async function stampLocalVaultMutation(): Promise<void> {472+  await chrome.storage.local.set({473+    peach_last_modified_at: String(Date.now()),474+  });475+}476+469477 /**470478  * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault471479  * + peach_duress flag) to the slot-neutral schema (peach_vault_a +@@ -488,8 +496,10 @@488496   // Already migrated.489497   if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;490498 499+  let didMigrate = false;491500   if (legacyMain && !slotAExists) {492501     await writeSlot(SLOT_A, legacyMain);502+    didMigrate = true;493503   }494504 495505   // If slot B isn't set, populate it: use legacy decoy when present, else noise.@@ -497,8 +507,10 @@497507     const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;498508     if (legacyDecoy) {499509       await writeSlot(SLOT_B, legacyDecoy);510+      didMigrate = true;500511     } else if (target) {501512       await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));513+      didMigrate = true;502514     }503515   }504516 @@ -508,7 +520,11 @@508520   if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);509521   if (toRemove.length > 0) {510522     await chrome.storage.local.remove(toRemove);523+    didMigrate = true;511524   }525+  if (didMigrate) {526+    await stampLocalVaultMutation();527+  }512528 }513529 514530 // ---------------------------------------------------------------------------@@ -793,6 +809,7 @@793809   // Matching-size noise blob for slot B, so a fresh install shows two794810   // similarly-shaped records regardless of whether duress is ever set up.795811   await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));812+  await stampLocalVaultMutation();796813 797814   touchAutoLock();798815 }@@ -1484,6 +1501,7 @@14841501   if (!duressConfigured) {14851502     await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));14861503   }1504+  await stampLocalVaultMutation();14871505 14881506   return nextSlotA;14891507 }@@ -1500,6 +1518,7 @@15001518   const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);15011519 15021520   await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1521+  await stampLocalVaultMutation();15031522 }15041523 15051524 async function persistEntries(@@ -1970,6 +1989,7 @@19701989 19711990   await writeSlot(SLOT_A, restoredA);19721991   await writeSlot(SLOT_B, restoredB);1992+  await stampLocalVaultMutation();19731993 19741994   // Lock so the user must prove they can decrypt the restored blob. If they19751995   // restored a pre-password-change snapshot they discover it immediately at@@ -2052,6 +2072,7 @@20522072   if (!duressConfigured) {20532073     await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));20542074   }2075+  await stampLocalVaultMutation();20552076 20562077   return true;20572078 }

view file