bb21a0e3dc56

replace snapshot system with peach_vault_history (max 10, snapshot-before-write, snapshotId restore)

time
2026-04-30 20:25 UTC · 6 months ago
machine
koh2
parent
4c3d8340d337
8 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+14 −7src/background/vault.test.ts+3 −82src/background/vault.ts+124 −78src/popup/views/Lock.tsx+3 −205src/shared/types.ts+27 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.33",4+  "version": "2.14.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.33",4+  "version": "2.14.0",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": "2.13.33",3+  "version": "2.14.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+14 −7
@@ -256,15 +256,16 @@256256         await vault.clearVaultEntries("REPLACE_ALL");257257         return { ok: true };258258       }259-      case "LIST_VAULT_SNAPSHOTS": {260-        const snapshots = await vault.listSnapshots();259+      case "GET_VAULT_HISTORY": {260+        const snapshots = await vault.getVaultHistory();261261         return { ok: true, data: snapshots };262262       }263263       case "RESTORE_VAULT_SNAPSHOT": {264-        const { index, password } = msg.payload as { index: number; password: string };265-        const ok = await vault.restoreFromSnapshot(index, password);266-        if (ok) broadcastToContentScripts({ type: "VAULT_UNLOCKED" });267-        return { ok };264+        const { snapshotId } = msg.payload as { snapshotId: string };265+        const result = await vault.restoreVaultSnapshot(snapshotId);266+        if (!result.ok) return { ok: false, error: result.error };267+        broadcastToContentScripts({ type: "VAULT_LOCKED" });268+        return { ok: true };268269       }269270       case "COUNT_DUPLICATES": {270271         return { ok: true, data: vault.countDuplicates() };@@ -903,11 +904,17 @@903904       case "DESTROY_VAULT": {904905         // Irreversible by design. Lock zeroes the derived key + in-memory entries;905906         // storage.local.clear() wipes every Peach key (slots, settings, license,906-        // onboarded flag, snapshots, sync state, declined cards, recall, etc.);907+        // onboarded flag, history, sync state, declined cards, recall, etc.);907908         // session.clear() wipes pending passkey requests + pro-notice queues.908909         // Caller (Panic.tsx) handles UI navigation back to onboarding.909910         vault.lock();910911         await storageClear();912+        // Belt-and-suspenders: storage.local.clear() already wipes913+        // peach_vault_history, but if storage.ts ever routes history to a914+        // different backend (e.g. Capacitor secure storage on Android) the915+        // explicit clear keeps the invariant that Panic leaves no recoverable916+        // vault state behind.917+        await vault.clearVaultHistory();911918         if (!Capacitor.isNativePlatform() && chrome.storage.session) {912919           await chrome.storage.session.clear();913920         }

view file

src/background/vault.test.ts+3 −82
@@ -104,10 +104,11 @@104104     expect(storage.peach_duress).toBeUndefined();105105     expect(storage.peach_decoy_vault).toBeUndefined();106106     const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));107-    // Only the two slot-neutral keys (and optionally peach_settings) may exist.107+    // Slot-neutral keys + optional settings + vault history (snapshot of the108+    // pre-setupDuress real-mode blob). No exposed duress flag.108109     for (const key of keys) {109110       expect(110-        ["peach_vault_a", "peach_vault_b", "peach_settings"]111+        ["peach_vault_a", "peach_vault_b", "peach_settings", "peach_vault_history"]111112       ).toContain(key);112113     }113114     expect(keys).toContain("peach_vault_a");@@ -319,47 +320,6 @@319320     expect(vault.getEntries()).toEqual([]);320321   });321322 322-  it("restoreFromSnapshot with correct password restores the snapshotted entries", async () => {323-    const vault = await import("./vault");324-325-    await vault.createVault("restore-pass-abcde");326-    await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });327-    await vault.addEntry({ title: "B", username: "", password: "p2", url: "" });328-    await vault.addEntry({ title: "C", username: "", password: "p3", url: "" });329-    await new Promise<void>((r) => setTimeout(r, 0));330-331-    await vault.addEntry({ title: "D", username: "", password: "p4", url: "" });332-    await vault.addEntry({ title: "E", username: "", password: "p5", url: "" });333-    await new Promise<void>((r) => setTimeout(r, 0));334-    expect(vault.getEntries()).toHaveLength(5);335-336-    const snapshots = await vault.listSnapshots();337-    const targetIndex = snapshots.findIndex((s) => s.entryCount === 3);338-    expect(targetIndex).toBeGreaterThanOrEqual(0);339-340-    expect(await vault.restoreFromSnapshot(targetIndex, "restore-pass-abcde")).toBe(true);341-    expect(vault.getEntries()).toHaveLength(3);342-  });343-344-  it("restoreFromSnapshot with wrong password returns false and writes no slot", async () => {345-    const vault = await import("./vault");346-347-    await vault.createVault("right-pass-abcde");348-    await vault.addEntry({ title: "X", username: "", password: "px", url: "" });349-    await new Promise<void>((r) => setTimeout(r, 0));350-351-    const setMock = (chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>);352-    setMock.mockClear();353-354-    expect(await vault.restoreFromSnapshot(0, "wrong-pass-fghij")).toBe(false);355-356-    const slotWrites = setMock.mock.calls.filter((args) => {357-      const payload = args[0] as Record<string, unknown>;358-      return "peach_vault_a" in payload || "peach_vault_b" in payload;359-    });360-    expect(slotWrites).toHaveLength(0);361-  });362-363323   it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {364324     const vault = await import("./vault");365325 @@ -412,26 +372,6 @@412372     expect(await vault.unlock("correct-pass-12345")).toBe(false);413373   });414374 415-  it("changePassword takes a snapshot", async () => {416-    const vault = await import("./vault");417-418-    await vault.createVault("snapshot-pass-12345");419-    await vault.addEntry({420-      title: "GitHub",421-      username: "june",422-      password: "secret123",423-      url: "https://github.com",424-    });425-    await new Promise<void>((resolve) => setTimeout(resolve, 0));426-427-    const before = await vault.listSnapshots();428-    expect(await vault.changePassword("snapshot-pass-12345", "snapshot-pass-67890")).toBe(true);429-    await new Promise<void>((resolve) => setTimeout(resolve, 0));430-431-    const after = await vault.listSnapshots();432-    expect(after).toHaveLength(before.length + 1);433-  });434-435375   it("restoreEntries replace mode replaces existing entries", async () => {436376     const vault = await import("./vault");437377 @@ -553,25 +493,6 @@553493     expect(vault.getEntries()).toEqual([]);554494     expect(vault.getDeletedEntries()).toHaveLength(3);555495     expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);556-  });557-558-  it("restoreFromSnapshot after changePassword requires the new password", async () => {559-    const vault = await import("./vault");560-561-    await vault.createVault("old-snapshot-pass");562-    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });563-    await vault.addEntry({ title: "B", username: "", password: "two", url: "" });564-    await new Promise<void>((resolve) => setTimeout(resolve, 0));565-566-    const snapshots = await vault.listSnapshots();567-    const snapshotIndex = snapshots.findIndex((snapshot) => snapshot.entryCount === 2);568-    expect(snapshotIndex).toBeGreaterThanOrEqual(0);569-570-    expect(await vault.changePassword("old-snapshot-pass", "new-snapshot-pass")).toBe(true);571-572-    expect(await vault.restoreFromSnapshot(snapshotIndex, "old-snapshot-pass")).toBe(false);573-    expect(await vault.restoreFromSnapshot(snapshotIndex, "new-snapshot-pass")).toBe(true);574-    expect(vault.getEntries()).toHaveLength(2);575496   });576497 577498   it("deduplicateEntries throws in decoy mode", async () => {

view file

src/background/vault.ts+124 −78
@@ -18,6 +18,8 @@1818   SavedIdentity,1919   StoredVault,2020   VaultEntry,21+  VaultHistory,22+  VaultSnapshot,2123 } from "../shared/types";2224 import { decrypt, encrypt, zeroKey } from "./aes";2325 import { clearAutoLock, touchAutoLock } from "./alarms";@@ -828,7 +830,7 @@828830     }829831   }830832 831-  await persistEntries();833+  await persistEntries("import");832834   return { added, updated, skipped };833835 }834836 @@ -1047,12 +1049,18 @@10471049   return entries.find((entry) => entry.id === id && !entry.deletedAt);10481050 }10491051 1050-async function persistSlotA(): Promise<StoredVault> {1052+async function persistSlotA(1053+  reason: VaultSnapshot["reason"] = "local_save",1054+): Promise<StoredVault> {10511055   if (!derivedKey) throw new Error("vault locked");10521056 10531057   const slotA = await loadSlot(SLOT_A);10541058   if (!slotA) throw new Error("missing slot A");10551059 1060+  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures1061+  // are swallowed by takeVaultSnapshot — history must never block a save.1062+  await takeVaultSnapshot(reason);1063+10561064   const plaintext = encodePayload(entries, duressConfigured);10571065   const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);10581066 @@ -1088,17 +1096,15 @@10881096   await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });10891097 }10901098 1091-async function persistEntries(): Promise<void> {1099+async function persistEntries(1100+  reason: VaultSnapshot["reason"] = "local_save",1101+): Promise<void> {10921102   if (!derivedKey) throw new Error("vault locked");10931103 10941104   if (vaultMode === "decoy") {10951105     await persistSlotB();10961106   } else {1097-    const freshSlotA = await persistSlotA();1098-    // Save a snapshot of the real vault after every successful write.1099-    // Failures are silently swallowed — snapshots must never interrupt saves.1100-    const activeCount = entries.filter((e) => !e.deletedAt).length;1101-    void takeSnapshot(freshSlotA, activeCount);1107+    await persistSlotA(reason);11021108   }11031109   buildDomainIndexSync(entries);11041110 }@@ -1435,90 +1441,132 @@14351441 }14361442 14371443 // ---------------------------------------------------------------------------1438-// Vault snapshot system — last 6 encrypted states, used for self-recovery1444+// Vault history — snapshot BEFORE every vault write so the saved blob is1445+// what is about to be lost, not what was just written. Capped at 10. The1446+// stored blobs are AES-256-GCM ciphertext under the master password, so1447+// history adds zero security surface.14391448 // ---------------------------------------------------------------------------14401449 1441-const SNAPSHOT_KEY = "peach_vault_snapshots";1442-const MAX_SNAPSHOTS = 6;1450+const HISTORY_KEY = "peach_vault_history";1451+const HISTORY_MAX = 10;14431452 1444-interface VaultSnapshot {1445-  timestamp: number;1446-  entryCount: number;1447-  slot: StoredVault;1448-}1453+/**1454+ * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE1455+ * overwriting peach_vault_a so the saved blob captures the about-to-be-lost1456+ * state. Identical-to-most-recent blobs are skipped to avoid filling history1457+ * with no-op writes.1458+ */1459+export async function takeVaultSnapshot(1460+  reason: VaultSnapshot["reason"],1461+): Promise<void> {1462+  try {1463+    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1464+    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1465+    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];14491466 1450-/** Public summary (no sensitive data) returned to the popup. */1451-export interface SnapshotSummary {1452-  index: number;1453-  timestamp: number;1454-  entryCount: number;1467+    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,1468+    // pre-createVault). createVault skips snapshots intentionally — there is1469+    // no prior state to preserve.1470+    if (!slotA) return;1471+1472+    const vault_a = JSON.stringify(slotA);1473+    const vault_b = JSON.stringify(slotB ?? slotA);1474+1475+    // Best-effort entry count: only meaningful when the in-memory state is1476+    // unlocked AND in real mode. Decoy mode would leak the decoy entry1477+    // count; locked mode has no entries to count. Both fall through to null.1478+    let entryCount: number | null = null;1479+    if (derivedKey && vaultMode === "real") {1480+      try {1481+        entryCount = entries.filter((e) => !e.deletedAt).length;1482+      } catch {1483+        entryCount = null;1484+      }1485+    }1486+1487+    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1488+    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1489+      ? existingRaw1490+      : { snapshots: [] };1491+1492+    // Deduplication: if the most recent snapshot has byte-identical blobs,1493+    // skip — saves the same vault N times in a row would otherwise burn the1494+    // entire history window with no recovery value.1495+    const latest = history.snapshots[0];1496+    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1497+      return;1498+    }1499+1500+    const snapshot: VaultSnapshot = {1501+      id: crypto.randomUUID(),1502+      savedAt: Date.now(),1503+      reason,1504+      vault_a,1505+      vault_b,1506+      entryCount,1507+    };1508+1509+    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1510+    await chrome.storage.local.set({ [HISTORY_KEY]: history });1511+  } catch {1512+    // Non-fatal — history must never block a save. A failed snapshot just1513+    // means the user loses one rung of the safety ladder for this write.1514+  }14551515 }14561516 1457-async function takeSnapshot(1458-  slot: StoredVault,1459-  entryCount: number1460-): Promise<void> {1517+/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */1518+export async function clearVaultHistory(): Promise<void> {14611519   try {1462-    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1463-    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1464-    const next: VaultSnapshot[] = [1465-      { timestamp: Date.now(), entryCount, slot },1466-      ...existing,1467-    ].slice(0, MAX_SNAPSHOTS);1468-    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1520+    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });14691521   } catch {1470-    // Non-fatal — snapshot failure must never interrupt vault saves1522+    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().14711523   }14721524 }14731525 1474-export async function listSnapshots(): Promise<SnapshotSummary[]> {1475-  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1476-  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1477-  return snapshots.map((s, i) => ({1478-    index: i,1479-    timestamp: s.timestamp,1480-    entryCount: s.entryCount,1481-  }));1526+/** Read all snapshots, newest first, for the Settings vault history view. */1527+export async function getVaultHistory(): Promise<VaultSnapshot[]> {1528+  const raw = await chrome.storage.local.get(HISTORY_KEY);1529+  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1530+  if (!history || !Array.isArray(history.snapshots)) return [];1531+  return history.snapshots;14821532 }14831533 14841534 /**1485- * Restore a snapshot by index. The password must decrypt that snapshot1486- * (i.e., the same master password used when the snapshot was taken).1487- * On success the vault is unlocked in-memory with the restored entries1488- * — no re-login required.1535+ * Replace the on-disk vault with the chosen snapshot's blobs and lock the1536+ * vault. The current state is itself snapshotted with reason 'restore' first1537+ * so the restore is reversible. After this returns, the user must unlock1538+ * with the password that matches the restored blob.14891539  */1490-export async function restoreFromSnapshot(1491-  index: number,1492-  password: string1493-): Promise<boolean> {1494-  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1495-  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1496-  const snapshot = snapshots[index];1497-  if (!snapshot) return false;1540+export async function restoreVaultSnapshot(1541+  snapshotId: string,1542+): Promise<{ ok: true } | { ok: false; error: string }> {1543+  const history = await getVaultHistory();1544+  const snapshot = history.find((s) => s.id === snapshotId);1545+  if (!snapshot) return { ok: false, error: "snapshot_not_found" };14981546 1499-  // Verify the password decrypts this snapshot1500-  const key = await deriveKey(password, unhex(snapshot.slot.salt));1501-  const payload = await tryDecryptSlot(snapshot.slot, key);1502-  if (!payload) {1503-    zeroKey(key);1504-    return false;1547+  // Snapshot the about-to-be-replaced current state so the restore is1548+  // reversible. takeVaultSnapshot is no-op when the most recent entry is1549+  // byte-identical, so a restore-of-a-restore stays clean.1550+  await takeVaultSnapshot("restore");1551+1552+  let restoredA: StoredVault;1553+  let restoredB: StoredVault;1554+  try {1555+    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1556+    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1557+  } catch {1558+    return { ok: false, error: "snapshot_corrupt" };15051559   }15061560 1507-  // Write the snapshot back to slot A. Regenerate noise for slot B.1508-  await writeSlot(SLOT_A, snapshot.slot);1509-  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));1561+  await writeSlot(SLOT_A, restoredA);1562+  await writeSlot(SLOT_B, restoredB);15101563 1511-  // Swap in-memory state to the restored vault — caller gets immediate access1512-  if (derivedKey) zeroKey(derivedKey);1513-  derivedKey = key;1514-  entries = payload.entries;1515-  duressConfigured = payload.duressConfigured;1516-  vaultMode = "real";1517-  buildDomainIndexSync(entries);1518-  await loadSettings();1519-  touchAutoLock();1564+  // Lock so the user must prove they can decrypt the restored blob. If they1565+  // restored a pre-password-change snapshot they discover it immediately at1566+  // the lock screen rather than silently holding an inaccessible vault.1567+  lock();15201568 1521-  return true;1569+  return { ok: true };15221570 }15231571 15241572 /**@@ -1555,6 +1603,10 @@15551603     ciphertext,15561604     tag,15571605   };1606+  // Snapshot the OLD-key blob before overwriting it. If the user later1607+  // restores from this snapshot they will need the OLD password — the1608+  // post-restore lock surfaces that immediately.1609+  await takeVaultSnapshot("local_save");15581610   await writeSlot(SLOT_A, freshSlotA);15591611 15601612   // Swap the in-memory key synchronously before any further await. If we@@ -1562,12 +1614,6 @@15621614   // would overwrite Slot A with old-key ciphertext, locking the user out.15631615   zeroKey(derivedKey);15641616   derivedKey = newKey;1565-1566-  // Snapshot now that in-memory key and disk are consistent. Pass the1567-  // slot content we just wrote rather than re-reading from storage so the1568-  // snapshot is immune to any microtask-coalesced read-after-write race.1569-  const activeCount = entries.filter((e) => !e.deletedAt).length;1570-  void takeSnapshot(freshSlotA, activeCount);15711617 15721618   // Regenerate noise for slot B when duress is not in use. Runs after the15731619   // key swap so a failure here is a disposable noise regeneration, not a

view file

src/popup/views/Lock.tsx+3 −205
@@ -1,4 +1,4 @@1-import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";1+import { Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";22 import { Capacitor } from "@capacitor/core";33 44 import { sendMessage } from "../App";@@ -27,9 +27,9 @@2727   onBackToOnboarding?: () => void;2828 }2929 30-// "restore" = picker, "snapshots" = vault history, "peachscript" / "file" = detail flows,30+// "restore" = picker, "peachscript" / "file" = detail flows,3131 // "mayan" = Mayan-code import (decoder not shipped yet — picker exposes a "soon" card).32-type RestoreSurface = "restore" | "snapshots" | "peachscript" | "file" | "mayan" | null;32+type RestoreSurface = "restore" | "peachscript" | "file" | "mayan" | null;3333 3434 export function LockView(props: LockProps) {3535   const [password, setPassword] = createSignal("");@@ -155,7 +155,6 @@155155           onPickPeachScript={() => setRestoreSurface("peachscript")}156156           onPickMayan={() => setRestoreSurface("mayan")}157157           onPickCSV={() => setRestoreSurface("file")}158-          onPickSnapshots={() => setRestoreSurface("snapshots")}159158         />160159       </Match>161160 @@ -164,14 +163,6 @@164163         <MayanComingSoonView onBack={() => setRestoreSurface("restore")} />165164       </Match>166165 167-      {/* ── Vault snapshot history ────────────────────────────────── */}168-      <Match when={restoreSurface() === "snapshots"}>169-        <SnapshotRestoreView170-          onBack={() => setRestoreSurface("restore")}171-          onRestored={props.onUnlock}172-        />173-      </Match>174-175166       {/* ── PeachScript restore ───────────────────────────────────── */}176167       <Match when={restoreSurface() === "peachscript"}>177168         <ImportView@@ -426,7 +417,6 @@426417   onPickPeachScript: () => void;427418   onPickMayan: () => void;428419   onPickCSV: () => void;429-  onPickSnapshots: () => void;430420 }) {431421   return (432422     <section@@ -493,14 +483,6 @@493483         </button>494484       </div>495485 496-      <div class="peach-restore-footer">497-        <button498-          class="peach-restore-footer-link"499-          onClick={props.onPickSnapshots}500-        >501-          or revert to a local snapshot →502-        </button>503-      </div>504486     </section>505487   );506488 }@@ -550,190 +532,6 @@550532           ‹ back to restore551533         </button>552534       </div>553-    </section>554-  );555-}556-557-// ── Vault snapshot restore ─────────────────────────────────────────────────558-559-interface SnapshotSummary {560-  index: number;561-  timestamp: number;562-  entryCount: number;563-}564-565-function SnapshotRestoreView(props: {566-  onBack: () => void;567-  onRestored: () => void | Promise<void>;568-}) {569-  const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);570-  const [loading, setLoading] = createSignal(true);571-  const [selected, setSelected] = createSignal<number | null>(null);572-  const [password, setPassword] = createSignal("");573-  const [confirming, setConfirming] = createSignal(false);574-  const [restoring, setRestoring] = createSignal(false);575-  const [error, setError] = createSignal("");576-577-  onMount(async () => {578-    const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });579-    setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);580-    setLoading(false);581-  });582-583-  function formatAge(ts: number): string {584-    const diff = Date.now() - ts;585-    const mins = Math.floor(diff / 60_000);586-    if (mins < 2) return "just now";587-    if (mins < 60) return `${mins} minutes ago`;588-    const hours = Math.floor(mins / 60);589-    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;590-    const days = Math.floor(hours / 24);591-    return `${days} day${days === 1 ? "" : "s"} ago`;592-  }593-594-  function selectSnapshot(index: number) {595-    setSelected(index);596-    setConfirming(false);597-    setPassword("");598-    setError("");599-  }600-601-  async function handleRestore() {602-    const idx = selected();603-    if (idx === null || !password()) return;604-    setRestoring(true);605-    setError("");606-    const res = await sendMessage({607-      type: "RESTORE_VAULT_SNAPSHOT",608-      payload: { index: idx, password: password() },609-    });610-    if ((res as { ok: boolean }).ok) {611-      await props.onRestored();612-    } else {613-      setError("Incorrect password, or this snapshot cannot be decrypted.");614-      setRestoring(false);615-    }616-  }617-618-  const selectedSnap = () => {619-    const idx = selected();620-    return idx !== null ? (snapshots()[idx] ?? null) : null;621-  };622-623-  return (624-    <section625-      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"626-      data-motion-phase="enter"627-    >628-      <header class="peach-screen-header lock-screen-header">629-        <PeachWordmark class="peach-wordmark-wrap" />630-        <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>631-          ‹ back632-        </button>633-      </header>634-635-      <div class="peach-lock-hero">636-        <span class="peach-lock-eyebrow">vault history</span>637-        <h1 class="peach-onboarding-headline">638-          revert to639-          <br />640-          a snapshot.641-        </h1>642-      </div>643-644-      <section class="peach-lock-panel peach-snapshot-panel">645-        <Show when={loading()}>646-          <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>647-        </Show>648-649-        <Show when={!loading() && snapshots().length === 0}>650-          <p class="peach-hint" style="line-height:1.6;">651-            No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.652-          </p>653-        </Show>654-655-        <Show when={!loading() && snapshots().length > 0}>656-          <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>657-          <div class="peach-snapshot-list">658-            <For each={snapshots()}>659-              {(snap) => (660-                <button661-                  class="peach-snapshot-row"662-                  classList={{ "is-selected": selected() === snap.index }}663-                  onClick={() => selectSnapshot(snap.index)}664-                  disabled={restoring()}665-                >666-                  <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>667-                  <span class="peach-snapshot-count">668-                    {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}669-                  </span>670-                </button>671-              )}672-            </For>673-          </div>674-675-          <Show when={selected() !== null}>676-            <div class="peach-snapshot-confirm">677-              <label class="peach-label" for="snapshot-pw">master password</label>678-              <input679-                id="snapshot-pw"680-                class="peach-underline-input peach-unlock-input"681-                type="password"682-                placeholder="••••••••••••"683-                autocomplete="current-password"684-                value={password()}685-                disabled={restoring()}686-                onInput={(e) => setPassword((e.target as HTMLInputElement).value)}687-                onKeyDown={(e) => {688-                  if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();689-                }}690-              />691-692-              <Show when={error()}>693-                <p class="peach-hint peach-hint-error">{error()}</p>694-              </Show>695-696-              <Show697-                when={confirming()}698-                fallback={699-                  <button700-                    class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"701-                    onClick={() => setConfirming(true)}702-                    disabled={!password().trim() || restoring()}703-                  >704-                    restore this version →705-                  </button>706-                }707-              >708-                <div class="peach-snapshot-warning">709-                  <svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="rgba(220,80,60,0.9)" stroke-width="1.4" stroke-linecap="round">710-                    <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />711-                    <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />712-                  </svg>713-                  This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.714-                </div>715-                <div class="peach-snapshot-confirm-row">716-                  <button717-                    class="peach-btn peach-btn--accent peach-btn--sm"718-                    style="flex:1"719-                    onClick={() => void handleRestore()}720-                    disabled={restoring() || !password().trim()}721-                  >722-                    {restoring() ? "restoring…" : "yes, restore"}723-                  </button>724-                  <button725-                    class="peach-btn peach-btn--ghost peach-btn--sm"726-                    onClick={() => setConfirming(false)}727-                    disabled={restoring()}728-                  >729-                    cancel730-                  </button>731-                </div>732-              </Show>733-            </div>734-          </Show>735-        </Show>736-      </section>737535     </section>738536   );739537 }

view file

src/shared/types.ts+27 −1
@@ -264,7 +264,7 @@264264   | "RESTORE_ENTRY"265265   | "PURGE_ENTRY"266266   | "PURGE_EXPIRED_TRASH"267-  | "LIST_VAULT_SNAPSHOTS"267+  | "GET_VAULT_HISTORY"268268   | "RESTORE_VAULT_SNAPSHOT"269269   | "COUNT_DUPLICATES"270270   | "DEDUPLICATE_ENTRIES"@@ -382,6 +382,32 @@382382   allowedSymbols?: string;383383   /** Characters the site explicitly disallows. Filtered from every pool before generation. */384384   forbiddenChars?: string;385+}386+387+/**388+ * A single vault history snapshot. The `vault_a` and `vault_b` fields are the389+ * raw encrypted blobs from chrome.storage.local — already AES-256-GCM390+ * protected by the master password, so storing them in history adds no391+ * security surface.392+ *393+ * Snapshots are taken BEFORE every vault write so the saved blob represents394+ * what is about to be lost, not what was just written. The reason field395+ * documents the trigger so the UI can render "saved" / "synced" / "imported"396+ * / "restored" labels next to each row.397+ */398+export interface VaultSnapshot {399+  id: string;400+  savedAt: number;401+  reason: "local_save" | "sync_received" | "import" | "restore";402+  vault_a: string;403+  vault_b: string;404+  /** Active entry count when the snapshot was taken; null if vault was locked. */405+  entryCount: number | null;406+}407+408+export interface VaultHistory {409+  /** Newest first; capped at HISTORY_MAX (10). */410+  snapshots: VaultSnapshot[];385411 }386412 387413 export interface Message {

view file