b0d0f5e59a28
fix(sync): per-entry modifiedAt/deletedAt monotonic — clock-skew no longer discards local edits in LWW merge (v3.6.113)
- time
- 2026-05-28 20:11 UTC · 5 months ago
- machine
- koh2
- parent
- b5de3f75e43a
↓ peach-browser-b0d0f5e5.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.test.ts+24 −0src/background/vault.ts+38 −14manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "3.6.112",5+ "version": "3.6.113",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "3.6.112",4+ "version": "3.6.113",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": "3.6.112",3+ "version": "3.6.113",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/vault.test.ts+24 −0
@@ -969,4 +969,28 @@969969 970970 expect(await vault.getVaultVersion()).toBeGreaterThan(before);971971 });972+973+ it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {974+ const vault = await import("./vault");975+ await vault.createVault("monotonic-pass-aaaaa");976+ const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });977+ const before = vault.getEntryById(e1.id)!.modifiedAt;978+979+ // Simulate the clock jumping 10s backwards (NTP step / manual change).980+ const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);981+ await vault.updateEntry(e1.id, { password: "p2" });982+ const after = vault.getEntryById(e1.id)!.modifiedAt;983+ expect(after).toBeGreaterThan(before);984+ spy.mockRestore();985+ });986+987+ it("deletedAt is strictly greater than the entry's modifiedAt", async () => {988+ const vault = await import("./vault");989+ await vault.createVault("monotonic-pass-bbbbb");990+ const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });991+ const modAt = vault.getEntryById(e.id)!.modifiedAt;992+ await vault.deleteEntry(e.id);993+ const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;994+ expect(tomb.deletedAt!).toBeGreaterThan(modAt);995+ });972996 });src/background/vault.ts+38 −14
@@ -533,6 +533,24 @@533533 }534534 }535535 536+// Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the537+// sync layer's last-write-wins merge against wall-clock regressions (NTP step,538+// manual clock change, DST glitch): a backwards Date.now() must never produce539+// an entry timestamp that's <= the entry's existing modifiedAt, or a genuine540+// local edit would lose to a stale remote copy. The returned value is always541+// strictly greater than both the entry's current modifiedAt and the previous542+// call's result (SEV-2-E).543+let lastEntryTimestamp = 0;544+function monotonicEntryTimestamp(currentModifiedAt?: number): number {545+ const base = Math.max(546+ Date.now(),547+ (currentModifiedAt ?? 0) + 1,548+ lastEntryTimestamp + 1,549+ );550+ lastEntryTimestamp = base;551+ return base;552+}553+536554 async function stampLocalVaultMutation(): Promise<void> {537555 const raw = await chrome.storage.local.get("peach_last_modified_at");538556 const previous = Number(@@ -1393,7 +1411,7 @@13931411 ...sanitized,13941412 id: crypto.randomUUID(),13951413 createdAt: now,1396- modifiedAt: now,1414+ modifiedAt: monotonicEntryTimestamp(),13971415 };13981416 entries.push(nextEntry);13991417 added.push(nextEntry);@@ -1532,8 +1550,9 @@15321550 if (!derivedKey) throw new Error("vault_locked");15331551 for (const entry of entries) {15341552 if (!entry.deletedAt) {1535- entry.deletedAt = Date.now();1536- entry.modifiedAt = Date.now();1553+ const stamp = monotonicEntryTimestamp(entry.modifiedAt);1554+ entry.deletedAt = stamp;1555+ entry.modifiedAt = stamp;15371556 }15381557 }15391558 await persistEntries();@@ -1544,14 +1563,15 @@15441563 15451564 const sanitized = sanitizeEntryDraft(entry);15461565 const now = Date.now();1566+ const stamp = monotonicEntryTimestamp();15471567 const nextEntry: VaultEntry = {15481568 ...sanitized,15491569 id: crypto.randomUUID(),15501570 createdAt: now,1551- modifiedAt: now,1571+ modifiedAt: stamp,15521572 // Stamp passwordChangedAt at creation so age tracking is accurate from15531573 // day one. Non-login entries carry the same stamp harmlessly.1554- passwordChangedAt: sanitized.password ? now : undefined,1574+ passwordChangedAt: sanitized.password ? stamp : undefined,15551575 };15561576 15571577 // Clone-and-replace so a failed persist rolls back the in-memory state@@ -1572,7 +1592,7 @@15721592 if (count > 0) {15731593 const idx = entries.findIndex((e) => e.id === nextEntry.id);15741594 if (idx !== -1) {1575- entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1595+ entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };15761596 await persistEntries();15771597 }15781598 }@@ -1604,11 +1624,12 @@16041624 "password" in patch &&16051625 typeof sanitizedPatch.password === "string" &&16061626 sanitizedPatch.password !== current.password;1627+ const stamp = monotonicEntryTimestamp(current.modifiedAt);16071628 entries[idx] = {16081629 ...current,16091630 ...sanitizedPatch,1610- modifiedAt: Date.now(),1611- ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1631+ modifiedAt: stamp,1632+ ...(passwordChanged ? { passwordChangedAt: stamp } : {}),16121633 };16131634 16141635 @@ -1631,7 +1652,7 @@16311652 checkBreach(password).then(async (count) => {16321653 const i = entries.findIndex((e) => e.id === id);16331654 if (i !== -1 && count > 0) {1634- entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1655+ entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };16351656 await persistEntries();16361657 }16371658 });@@ -1649,8 +1670,12 @@16491670 throw new Error("entry_not_found");16501671 }16511672 1652- entry.deletedAt = Date.now();1653- entry.modifiedAt = Date.now();1673+ // Tombstone timestamp must be strictly greater than the entry's last1674+ // modification so the deletion wins LWW against any in-flight edit, even1675+ // under clock skew (SEV-2-E).1676+ const stamp = monotonicEntryTimestamp(entry.modifiedAt);1677+ entry.deletedAt = stamp;1678+ entry.modifiedAt = stamp;16541679 await persistEntries();16551680 fireSyncTrigger("vault_change");16561681 }@@ -1784,7 +1809,7 @@17841809 const entry = entries.find((e) => e.id === id && e.deletedAt);17851810 if (!entry) throw new Error("deleted entry not found");17861811 delete entry.deletedAt;1787- entry.modifiedAt = Date.now();1812+ entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);17881813 await persistEntries();17891814 buildDomainIndexSync(entries);17901815 }@@ -2215,12 +2240,11 @@22152240 }22162241 22172242 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2218- const now = Date.now();22192243 let removed = 0;22202244 entries = entries.map((e) => {22212245 if (!e.deletedAt && !keepIds.has(e.id)) {22222246 removed++;2223- return { ...e, deletedAt: now };2247+ return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };22242248 }22252249 return e;22262250 });