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
↓ peach-browser-bb21a0e3.tar.gzatlas at this save
files changed8 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 −1manifest.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": {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",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": {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 }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 () => {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 asrc/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 }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 {