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
↓ peach-browser-c3b41306.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.test.ts+72 −1src/background/vault.ts+21 −0manifest.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": {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",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": {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 () => {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 }