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
↓ peach-browser-ebb7bd24.tar.gzatlas at this save
files changed4 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.ts+35 −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.136",5+ "version": "3.6.137",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.136",4+ "version": "3.6.137",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.136",3+ "version": "3.6.137",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {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(