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
↓ peach-browser-aa22a0e2.tar.gzatlas at this save
files changed7 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 −21manifest.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": {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",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",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" });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,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+});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 });