ebb7bd2456e6

fix: make vault persist atomic — restore prior slot A if a later write fails, so a failed save never leaves disk ahead of memory; eliminates phantom/duplicate entries (v3.6.137)

time
2026-05-30 07:27 UTC · 4 months ago
machine
koh2
parent
34691b7eed91
4 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.ts+35 −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.136",5+  "version": "3.6.137",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.136",4+  "version": "3.6.137",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.136",3+  "version": "3.6.137",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/vault.ts+35 −14
@@ -1919,20 +1919,33 @@19191919   };19201920   await writeSlot(SLOT_A, nextSlotA);19211921 1922-  // When duress is not configured, keep slot B size-matched with slot A by1923-  // regenerating its noise. When duress IS configured, leave slot B alone —1924-  // we don't have the decoy key to re-encrypt it. This is a known trade-off.1925-  if (!duressConfigured) {1926-    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1922+  // Slot A (the real vault) is now committed. Wrap every later write so a1923+  // mid-persist failure (e.g. a storage error after slot A landed) restores the1924+  // prior slot A before rethrowing. Without this, a "failed" save that already1925+  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/1926+  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces1927+  // as a duplicate on the next unlock (observed under storage-quota pressure).1928+  try {1929+    // When duress is not configured, keep slot B size-matched with slot A by1930+    // regenerating its noise. When duress IS configured, leave slot B alone —1931+    // we don't have the decoy key to re-encrypt it. This is a known trade-off.1932+    if (!duressConfigured) {1933+      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1934+    }1935+    // Only advance the vault version for locally-originated writes. Entries1936+    // applied from a sync peer ("sync_received") must NOT bump the counter —1937+    // otherwise applying a remote round makes us look newer than the peer and1938+    // triggers a redundant outbound sync, defeating the no-op optimisation1939+    // and risking a ping-pong between two devices (SEV-2-C).1940+    if (reason !== "sync_received") {1941+      await stampLocalVaultMutation();1942+    }1943+  } catch (err) {1944+    // Best-effort restore — the prior slot A is smaller-or-equal, so this write1945+    // fits even when the failure was a quota overflow.1946+    await writeSlot(SLOT_A, slotA).catch(() => {});1947+    throw err;19271948   }1928-  // Only advance the vault version for locally-originated writes. Entries1929-  // applied from a sync peer ("sync_received") must NOT bump the counter —1930-  // otherwise applying a remote round makes us look newer than the peer and1931-  // triggers a redundant outbound sync, defeating the no-op optimisation1932-  // and risking a ping-pong between two devices (SEV-2-C).1933-  if (reason !== "sync_received") {1934-    await stampLocalVaultMutation();1935-  }19361949 19371950   return nextSlotA;19381951 }@@ -1951,7 +1964,15 @@19511964   const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");19521965 19531966   await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1954-  await stampLocalVaultMutation();1967+  // Symmetric with persistSlotA: if the version stamp fails after the decoy1968+  // slot committed, restore the prior slot B so a failed decoy-mode save can't1969+  // leave disk ahead of the in-memory rollback.1970+  try {1971+    await stampLocalVaultMutation();1972+  } catch (err) {1973+    await writeSlot(SLOT_B, slotB).catch(() => {});1974+    throw err;1975+  }19551976 }19561977 19571978 async function persistEntries(

view file