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
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.test.ts+24 −0src/background/vault.ts+38 −14
manifest.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": {

view file

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",

view file

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": {

view file

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 });

view file

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   });

view file