aa22a0e2d0d0

fix: Retain purged vault tombstones during sync (v4.10.42)

time
2026-08-29 19:43 UTC · 1 month ago
machine
koh2
parent
9784408d482d
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-vault-adapter.test.ts+11 −0src/background/sync-vault-adapter.ts+3 −22src/background/vault.tombstone-ledger.test.ts+119 −0src/background/vault.ts+154 −21
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.10.41",4+  "version": "4.10.42",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "4.10.41",5+  "version": "4.10.42",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.10.41",3+  "version": "4.10.42",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/sync-vault-adapter.test.ts+11 −0
@@ -116,6 +116,17 @@116116     expect(t[a.id]).toBeGreaterThan(0);117117   });118118 119+  it("buildTombstoneManifest retains ids after a full-entry purge", async () => {120+    const { vault, adapter } = await setupVault();121+    const entry = await vault.addEntry({ type: "login", title: "Purged", password: "pw" });122+    await vault.deleteEntry(entry.id);123+    const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!;124+125+    await vault.purgeEntry(entry.id);126+127+    expect(await adapter.buildTombstoneManifest()).toEqual({ [entry.id]: deletedAt });128+  });129+119130   it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {120131     const { vault, adapter } = await setupVault();121132     const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });

view file

src/background/sync-vault-adapter.ts+3 −22
@@ -40,7 +40,7 @@4040 import { constantTimeEqualHex } from "../shared/constant-time";4141 import {4242   applyMergedRemoteEntries,43-  getDeletedEntries,43+  getTombstoneManifest,4444   getRootKeyHex,4545   getEntries,4646   getEntryById,@@ -114,24 +114,8 @@114114     },115115 116116     async buildTombstoneManifest() {117-      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted118-      // entry leaves a {id, deletedAt} record that persists indefinitely and is119-      // sent in every sync session, so a long-lived vault's manifest grows with120-      // total deletions, not live entries. Acceptable for now (records are tiny121-      // and the trash-retention purge eventually splices the entry array).122-      // TODO(tombstone-gc): prune tombstones older than N months once every paired device123-      // Algorithm: (1) After SYNC_DONE, record the peer-ack timestamp per tombstone.124-      // (2) Once ALL paired devices have ack-ed a tombstone, splice it from the125-      // deleted-entries array. (3) Gate on all-devices to prevent resurrection126-      // from an offline peer that still holds the entry.127117       requireUnlocked();128-      const manifest: Record<string, number> = {};129-      for (const entry of getDeletedEntries()) {130-        if (typeof entry.deletedAt === "number") {131-          manifest[entry.id] = entry.deletedAt;132-        }133-      }134-      return manifest;118+      return getTombstoneManifest();135119     },136120 137121     async getEncryptedEntries(entryIds) {@@ -248,10 +232,7 @@248232         ciphertext: await encryptEntry(key, entry),249233       };250234     }251-    const tombstones: Record<string, number> = {};252-    for (const entry of getDeletedEntries()) {253-      if (typeof entry.deletedAt === "number") tombstones[entry.id] = entry.deletedAt;254-    }235+    const tombstones = getTombstoneManifest();255236     const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;256237     await replaceLockedSyncSpool({257238       ...storedIdentity,

view file

src/background/vault.tombstone-ledger.test.ts+119 −0
@@ -0,0 +1,119 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import { decrypt } from "./aes";3+import { deriveKey } from "../crypto/argon2";4+import type { StoredVault, VaultEntry } from "../shared/types";5+6+type StorageShape = Record<string, unknown>;7+8+function makeChromeStorageMock(storage: StorageShape) {9+  return {10+    storage,11+    chrome: {12+      storage: {13+        local: {14+          get: vi.fn(async (key?: string | string[]) => {15+            if (!key) return { ...storage };16+            if (typeof key === "string") return { [key]: storage[key] };17+            return key.reduce<StorageShape>((acc, k) => {18+              acc[k] = storage[k];19+              return acc;20+            }, {});21+          }),22+          set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),23+          remove: vi.fn(async (keys: string | string[]) => {24+            for (const key of Array.isArray(keys) ? keys : [keys]) delete storage[key];25+          }),26+        },27+      },28+      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },29+      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },30+    } as unknown as typeof globalThis.chrome,31+  };32+}33+34+describe("vault tombstone ledger", () => {35+  let storage: StorageShape;36+  let vault: typeof import("./vault");37+  const password = "tombstone-ledger-pass";38+39+  beforeEach(async () => {40+    vi.resetModules();41+    storage = {};42+    (globalThis as Record<string, unknown>).chrome = makeChromeStorageMock(storage).chrome;43+    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled in tests"); }));44+    vault = await import("./vault");45+    await vault.createVault(password);46+  });47+48+  async function readPayload(): Promise<{ entries: VaultEntry[]; tombstones: Record<string, number> }> {49+    const stored = storage.peach_vault_a as StoredVault;50+    const salt = Uint8Array.from(stored.salt.match(/.{1,2}/g)!.map((value) => parseInt(value, 16)));51+    const key = await deriveKey(password, salt);52+    const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");53+    return JSON.parse(plain) as { entries: VaultEntry[]; tombstones: Record<string, number> };54+  }55+56+  it("retains a purged tombstone in encrypted state and rejects the stale remote entry", async () => {57+    const entry = await vault.addEntry({ type: "login", title: "Old", password: "pw" });58+    const stale = { ...entry };59+    await vault.deleteEntry(entry.id);60+    const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!;61+62+    await vault.purgeEntry(entry.id);63+    expect(vault.getEntries().some((item) => item.id === entry.id)).toBe(false);64+    expect(vault.getTombstoneManifest()).toEqual({ [entry.id]: deletedAt });65+66+    const result = await vault.applyMergedRemoteEntries([stale], {});67+    expect(result.applied).toBe(0);68+    expect(vault.getEntries().some((item) => item.id === entry.id)).toBe(false);69+70+    const payload = await readPayload();71+    expect(payload.entries).toEqual([]);72+    expect(payload.tombstones).toEqual({ [entry.id]: deletedAt });73+74+    vault.lock();75+    await vault.unlock(password);76+    expect(vault.getTombstoneManifest()).toEqual({ [entry.id]: deletedAt });77+  });78+79+  it("retains a retention-purged tombstone and blocks a stale remote entry", async () => {80+    const entry = await vault.addEntry({ type: "login", title: "Expired", password: "pw" });81+    const stale = { ...entry };82+    await vault.deleteEntry(entry.id);83+    const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!;84+    const retentionMs = 7 * 24 * 60 * 60 * 1000;85+    const now = vi.spyOn(Date, "now").mockReturnValue(deletedAt + retentionMs + 1);86+    try {87+      expect(await vault.purgeExpiredTrash(retentionMs)).toBe(1);88+    } finally {89+      now.mockRestore();90+    }91+92+    expect(vault.getTombstoneManifest()).toEqual({ [entry.id]: deletedAt });93+    const result = await vault.applyMergedRemoteEntries([stale], {});94+    expect(result.applied).toBe(0);95+    expect(vault.getEntries().some((item) => item.id === entry.id)).toBe(false);96+  });97+98+  it("records unknown remote tombstones and lets an explicit restore supersede one", async () => {99+    const id = crypto.randomUUID();100+    const deletedAt = Date.now();101+    const tombstoneResult = await vault.applyMergedRemoteEntries([], { [id]: deletedAt });102+    expect(tombstoneResult).toMatchObject({ received: 1, applied: 1 });103+    expect(vault.getTombstoneManifest()).toEqual({ [id]: deletedAt });104+105+    const stale: VaultEntry = {106+      id,107+      type: "login",108+      title: "Stale",109+      password: "pw",110+      createdAt: deletedAt - 100,111+      modifiedAt: deletedAt,112+    };113+    expect((await vault.applyMergedRemoteEntries([stale], {})).applied).toBe(0);114+115+    await vault.restoreEntries([{ ...stale, modifiedAt: deletedAt - 1 }], "merge");116+    expect(vault.getEntryById(id)?.title).toBe("Stale");117+    expect(vault.getTombstoneManifest()[id]).toBeUndefined();118+  });119+});

view file

src/background/vault.ts+154 −21
@@ -82,6 +82,10 @@8282 // the root key and recovery words remain stable.8383 let rootKey: Uint8Array | null = null;8484 let entries: VaultEntry[] = [];85+// Tombstones outlive their full entry records so a peer that was offline86+// during a purge cannot reintroduce an old ciphertext envelope. This map is87+// serialized only inside the encrypted vault payload.88+let tombstones = new Map<string, number>();8589 let domainIndex: Map<string, VaultEntry[]> = new Map();8690 let vaultMode: "real" | "decoy" = "real";8791 // Cached post-unlock view of whether the real vault records that duress is@@ -910,27 +914,67 @@910914 911915 type VaultPayload = {912916   entries: VaultEntry[];917+  tombstones?: Record<string, number>;913918   duressConfigured?: boolean;914919 };915920 916921 type DecryptedPayload = {917922   entries: VaultEntry[];923+  tombstones: Map<string, number>;918924   duressConfigured: boolean;919925   /**920-   * True when at least one entry in the on-disk payload had a legacy shape921-   * (pre-v2.11.0 passkey type/flat fields, or pre-A-2 created/updated keys)922-   * and got rewritten by normalizeEntry. The unlock path uses this to923-   * persist the new shape immediately — without it, the migration would924-   * re-run on every unlock and old shape never disappears from disk.926+   * True when the on-disk payload needs a one-time rewrite: either an entry927+   * had a legacy shape or the payload predates the tombstone ledger. The928+   * unlock path persists the normalized shape immediately.925929    */926930   migratedFromLegacy: boolean;927931 };928932 929933 function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {930-  const envelope: VaultPayload = { entries, duressConfigured: duress };934+  const envelope: VaultPayload = {935+    entries,936+    tombstones: Object.fromEntries(tombstones),937+    duressConfigured: duress,938+  };931939   return padPlaintext(JSON.stringify(envelope), minBytes);932940 }933941 942+function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {943+  const result = new Map<string, number>();944+  if (raw && typeof raw === "object" && !Array.isArray(raw)) {945+    for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {946+      if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {947+        result.set(id, timestamp);948+      }949+    }950+  }951+  // Older payloads had no ledger. Rebuild the durable portion from any952+  // soft-deleted entries before they are eligible for a later purge.953+  for (const entry of allEntries) {954+    if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {955+      continue;956+    }957+    if ((result.get(entry.id) ?? 0) < entry.deletedAt) {958+      result.set(entry.id, entry.deletedAt);959+    }960+  }961+  return result;962+}963+964+function reconcileTombstones(): void {965+  const byId = new Map(entries.map((entry) => [entry.id, entry]));966+  for (const [id] of tombstones) {967+    if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);968+  }969+  for (const entry of entries) {970+    if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {971+      if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {972+        tombstones.set(entry.id, entry.deletedAt);973+      }974+    }975+  }976+}977+934978 function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {935979   if ("created" in raw || "updated" in raw) return true;936980   const flat = raw as Partial<VaultEntry>;@@ -950,10 +994,15 @@950994 951995   const normalized = rawEntries.map(normalizeEntry);952996   const { merged } = mergePasskeyOnlyIntoLogins(normalized);997+  const decodedTombstones = decodeTombstones(998+    Array.isArray(parsed) ? undefined : parsed.tombstones,999+    normalized,1000+  );9531001 9541002   if (Array.isArray(parsed)) {9551003     return {9561004       entries: normalized,1005+      tombstones: decodedTombstones,9571006       duressConfigured: false,9581007       migratedFromLegacy: true, // pre-A-2 array shape always needs save9591008     };@@ -961,8 +1010,10 @@9611010 9621011   return {9631012     entries: normalized,1013+    tombstones: decodedTombstones,9641014     duressConfigured: parsed.duressConfigured === true,965-    migratedFromLegacy: flatMigrationNeeded || merged,1015+    migratedFromLegacy:1016+      flatMigrationNeeded || merged || !Object.hasOwn(parsed, "tombstones"),9661017   };9671018 }9681019 @@ -1162,6 +1213,7 @@11621213   const salt = generateSalt();11631214   derivedKey = await deriveKey(password, salt);11641215   entries = [];1216+  tombstones = new Map();11651217   vaultMode = "real";11661218   duressConfigured = false;11671219   buildDomainIndexSync(entries);@@ -1263,6 +1315,7 @@12631315     nextDerivedKey = null;12641316     sessionRootKey = null;12651317     entries = nextEntries;1318+    tombstones = new Map();12661319     vaultMode = "real";12671320     duressConfigured = nextDuressConfigured;12681321     buildDomainIndexSync(entries);@@ -1434,6 +1487,7 @@14341487     void saveSessionKey(keyA, "real");14351488     vaultMode = "real";14361489     entries = payloadA.entries;1490+    tombstones = payloadA.tombstones;14371491     duressConfigured = payloadA.duressConfigured;14381492     buildDomainIndexSync(entries);14391493     await loadSettings();@@ -1483,6 +1537,7 @@14831537     void saveSessionKey(keyB, "decoy");14841538     vaultMode = "decoy";14851539     entries = payloadB.entries;1540+    tombstones = payloadB.tombstones;14861541     duressConfigured = false;14871542     buildDomainIndexSync(entries);14881543     await loadSettings();@@ -1547,6 +1602,7 @@15471602   derivedKey = key;15481603   vaultMode = entry.mode;15491604   entries = result.payload.entries;1605+  tombstones = result.payload.tombstones;15501606   duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;15511607   buildDomainIndexSync(entries);15521608   await loadSettings();@@ -1597,6 +1653,7 @@15971653   }15981654 15991655   entries = [];1656+  tombstones = new Map();16001657   domainIndex = new Map();16011658   vaultMode = "real";16021659   duressConfigured = false;@@ -1623,6 +1680,7 @@16231680     derivedKey = masterKeyBytes;16241681     vaultMode = "real";16251682     entries = payload.entries;1683+    tombstones = payload.tombstones;16261684     duressConfigured = payload.duressConfigured;16271685     buildDomainIndexSync(entries);16281686     await loadSettings();@@ -1900,6 +1958,7 @@19001958 19011959   if (mode === "replace") {19021960     entries = [];1961+    tombstones = new Map();19031962     for (const entry of incoming) {19041963       const normalized = sanitizeRestoredEntry(entry);19051964       if (!normalized) {@@ -1907,6 +1966,9 @@19071966         continue;19081967       }19091968       entries.push(normalized);1969+      if (normalized.deletedAt) {1970+        tombstones.set(normalized.id, normalized.deletedAt);1971+      }19101972       added += 1;19111973     }19121974   } else {@@ -1922,11 +1984,15 @@19221984       const existing = byId.get(normalized.id);19231985       if (!existing) {19241986         entries.push(normalized);1987+        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1988+        else tombstones.delete(normalized.id);19251989         added += 1;19261990       } else if (normalized.modifiedAt > existing.modifiedAt) {19271991         const idx = entries.findIndex((e) => e.id === existing.id);19281992         if (idx !== -1) {19291993           entries[idx] = normalized;1994+          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1995+          else tombstones.delete(normalized.id);19301996           updated += 1;19311997         }19321998       } else {@@ -2099,9 +2165,13 @@20992165   if (!derivedKey) throw new Error("vault_locked");21002166   for (const entry of entries) {21012167     if (!entry.deletedAt) {2102-      const stamp = monotonicEntryTimestamp(entry.modifiedAt);2168+      const stamp = monotonicEntryTimestamp(Math.max(2169+        entry.modifiedAt,2170+        tombstones.get(entry.id) ?? 0,2171+      ));21032172       entry.deletedAt = stamp;21042173       entry.modifiedAt = stamp;2174+      tombstones.set(entry.id, stamp);21052175     }21062176   }21072177   await persistEntries();@@ -2265,9 +2335,11 @@22652335   // modification so the deletion wins LWW against any in-flight edit, even22662336   // under clock skew (SEV-2-E).22672337   const previousModifiedAt = entry.modifiedAt;2268-  const stamp = monotonicEntryTimestamp(entry.modifiedAt);2338+  const previousTombstone = tombstones.get(id);2339+  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));22692340   entry.deletedAt = stamp;22702341   entry.modifiedAt = stamp;2342+  tombstones.set(id, stamp);22712343   // Roll the tombstone back if the disk write fails, so a quota error doesn't22722344   // leave the entry deleted in memory but alive on disk (SEV-3-A).22732345   try {@@ -2275,6 +2347,8 @@22752347   } catch (err) {22762348     delete entry.deletedAt;22772349     entry.modifiedAt = previousModifiedAt;2350+    if (previousTombstone === undefined) tombstones.delete(id);2351+    else tombstones.set(id, previousTombstone);22782352     throw err;22792353   }22802354   fireSyncTrigger("vault_change");@@ -2285,6 +2359,12 @@22852359   return entries.filter((e) => !!e.deletedAt);22862360 }22872361 2362+/** Return all durable deletion timestamps, including purged full entries. */2363+export function getTombstoneManifest(): Record<string, number> {2364+  if (!derivedKey) return {};2365+  return Object.fromEntries(tombstones);2366+}2367+22882368 /**22892369  * Returns the vault's monotonic version — the `peach_last_modified_at` value22902370  * stamped on every user-initiated write. Remote (sync) writes do NOT advance@@ -2337,6 +2417,12 @@23372417   for (const e of entries) byId.set(e.id, e);23382418 23392419   const mergeNow = Date.now();2420+  const remoteTombstoneTimes = new Map<string, number>();2421+  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2422+    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2423+      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2424+    }2425+  }23402426 23412427   for (const remote of remoteEntries) {23422428     const sanitized = sanitizeRestoredEntry(remote);@@ -2348,20 +2434,24 @@23482434     if (typeof sanitized.deletedAt === "number") {23492435       sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);23502436     }2351-    const tombstoneTs = remoteTombstones[sanitized.id];2352-    if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2437+    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2438+    const localTombstoneTs = tombstones.get(sanitized.id);2439+    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2440+    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {23532441       continue;23542442     }23552443     const local = byId.get(sanitized.id);23562444     if (!local) {23572445       entries.push(sanitized);23582446       byId.set(sanitized.id, sanitized);2447+      tombstones.delete(sanitized.id);23592448       applied += 1;23602449     } else if (sanitized.modifiedAt > local.modifiedAt) {23612450       const idx = entries.findIndex((e) => e.id === local.id);23622451       if (idx !== -1) {23632452         entries[idx] = sanitized;23642453         byId.set(sanitized.id, sanitized);2454+        tombstones.delete(sanitized.id);23652455         applied += 1;23662456         if (!local.deletedAt) conflicts += 1;23672457       }@@ -2369,16 +2459,23 @@23692459   }23702460 23712461   for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2372-    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2462+    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;23732463     // HV-1a/1c: clamp the tombstone timestamp before every comparison.2374-    const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2464+    const deletedAt = remoteTombstoneTimes.get(id)!;2465+    const knownTombstone = tombstones.get(id);2466+    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;23752467     const local = byId.get(id);2376-    if (!local) continue;2468+    if (!local) {2469+      tombstones.set(id, deletedAt);2470+      applied += 1;2471+      continue;2472+    }23772473     if (local.deletedAt && local.deletedAt >= deletedAt) continue;23782474     if (deletedAt < local.modifiedAt) continue;23792475     const idx = entries.findIndex((e) => e.id === id);23802476     if (idx !== -1) {23812477       entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2478+      tombstones.set(id, deletedAt);23822479       applied += 1;23832480     }23842481   }@@ -2394,9 +2491,23 @@23942491   if (!derivedKey) throw new Error("vault_locked");23952492   const entry = entries.find((e) => e.id === id && e.deletedAt);23962493   if (!entry) throw new Error("deleted entry not found");2494+  const previousDeletedAt = entry.deletedAt;2495+  const previousModifiedAt = entry.modifiedAt;2496+  const previousTombstone = tombstones.get(id);23972497   delete entry.deletedAt;2398-  entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2399-  await persistEntries();2498+  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2499+    entry.modifiedAt,2500+    previousTombstone ?? 0,2501+  ));2502+  tombstones.delete(id);2503+  try {2504+    await persistEntries();2505+  } catch (err) {2506+    entry.deletedAt = previousDeletedAt;2507+    entry.modifiedAt = previousModifiedAt;2508+    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2509+    throw err;2510+  }24002511   buildDomainIndexSync(entries);24012512 }24022513 @@ -2404,6 +2515,8 @@24042515   if (!derivedKey) throw new Error("vault_locked");24052516   const idx = entries.findIndex((e) => e.id === id && e.deletedAt);24062517   if (idx === -1) throw new Error("deleted entry not found");2518+  const entry = entries[idx]!;2519+  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));24072520   entries.splice(idx, 1);24082521   await persistEntries();24092522 }@@ -2413,6 +2526,11 @@24132526   if (!derivedKey || retentionMs === 0) return 0;24142527   const cutoff = Date.now() - retentionMs;24152528   const before = entries.length;2529+  for (const entry of entries) {2530+    if (entry.deletedAt && entry.deletedAt < cutoff) {2531+      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2532+    }2533+  }24162534   entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));24172535   const purged = before - entries.length;24182536   if (purged > 0) await persistEntries();@@ -2513,10 +2631,20 @@25132631 ): Promise<void> {25142632   if (!derivedKey) throw new Error("vault_locked");25152633 2516-  if (vaultMode === "decoy") {2517-    await persistSlotB();2518-  } else {2519-    await persistSlotA(reason);2634+  // Reconcile direct soft-delete callers (clear/deduplicate and legacy2635+  // migration) while restoring the previous map if storage rejects the save.2636+  const previousTombstones = tombstones;2637+  tombstones = new Map(tombstones);2638+  reconcileTombstones();2639+  try {2640+    if (vaultMode === "decoy") {2641+      await persistSlotB();2642+    } else {2643+      await persistSlotA(reason);2644+    }2645+  } catch (err) {2646+    tombstones = previousTombstones;2647+    throw err;25202648   }25212649   buildDomainIndexSync(entries);25222650   await refreshLockedSyncState();@@ -2881,7 +3009,12 @@28813009   entries = entries.map((e) => {28823010     if (!e.deletedAt && !keepIds.has(e.id)) {28833011       removed++;2884-      return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };3012+      const deletedAt = monotonicEntryTimestamp(Math.max(3013+        e.modifiedAt,3014+        tombstones.get(e.id) ?? 0,3015+      ));3016+      tombstones.set(e.id, deletedAt);3017+      return { ...e, deletedAt };28853018     }28863019     return e;28873020   });

view file