vault.ts

50.3 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import type {15  AccentColorName,16  LegacyVaultEntry,17  ManualSummonRecord,18  SavedIdentity,19  StoredVault,20  VaultEntry,21} from "../shared/types";22import { decrypt, encrypt, zeroKey } from "./aes";23import { clearAutoLock, touchAutoLock } from "./alarms";2425type EntryDraft = Omit<26  VaultEntry,27  "id" | "createdAt" | "modifiedAt" | "deletedAt"28>;2930// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B31// either holds the decoy vault (when duress is configured) or random noise32// that looks identical at rest. No external indicator is stored — duress33// configuration is recorded only inside the encrypted payload of slot A.34const SLOT_A = "peach_vault_a";35const SLOT_B = "peach_vault_b";3637// Legacy keys, migrated away on first access.38const LEGACY_MAIN = "peach_vault";39const LEGACY_DECOY = "peach_decoy_vault";40const LEGACY_DURESS_FLAG = "peach_duress";4142// Plaintext is padded to the next multiple of this size before encryption.43// This coarsens ciphertext-length leakage into 1 KiB buckets.44const PAD_BLOCK = 1024;4546let derivedKey: Uint8Array | null = null;47let entries: VaultEntry[] = [];48let domainIndex: Map<string, VaultEntry[]> = new Map();49let vaultMode: "real" | "decoy" = "real";50// Cached post-unlock view of whether the real vault records that duress is51// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock52// this stays false because we never see slot A's plaintext.53let duressConfigured = false;5455let migrationRan = false;5657// ---------------------------------------------------------------------------58// Settings (persisted inside the encrypted vault store as a sidecar key)59// ---------------------------------------------------------------------------6061export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";62export type TextSize = "small" | "medium" | "large";6364export interface VaultSettings {65  accentColor?: AccentColorName;66  theme?: "dark" | "light";67  autoLockMinutes?: number;68  clipboardClearSeconds?: number;69  lockOnClose?: boolean;70  breachMonitoring?: boolean;71  passkeyProviderEnabled?: boolean;72  trashRetentionDays?: number;73  manualSummonDomains?: Record<string, ManualSummonRecord>;74  declinedCardFingerprints?: string[];75  /**76   * Display font for headlines, brand wordmark, and narrative copy. Default77   * is "cormorant-italic" — the bold italic serif that ships as Peach's78   * voice. Non-italic options exist for users who prefer upright type.79   */80  displayFont?: DisplayFont;81  /**82   * Text size scale for body copy and entry titles. Stops at "large" by83   * design — XL clips dense screens (Vault, Health) inside the fixed84   * 330×480 popup surface.85   */86  textSize?: TextSize;87  /**88   * When true, all entrance/exit transitions and ambient animations are89   * collapsed to instant. Honoured alongside the user's OS-level90   * `prefers-reduced-motion`.91   */92  reduceMotion?: boolean;93  /**94   * User overrides for keyboard shortcuts. Keys are ShortcutAction names95   * (see src/shared/shortcuts.ts). Missing actions fall through to96   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the97   * canonical lookup. Storing only the deltas keeps the vault payload98   * small and lets default updates land for users who never rebind.99   */100  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;101}102103let settings: VaultSettings = {};104105export function getSettings(): VaultSettings {106  return settings;107}108109export async function ensureSettingsLoaded(): Promise<VaultSettings> {110  await loadSettings();111  return settings;112}113114export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {115  await loadSettings();116  settings = { ...settings, ...patch };117  await persistSettings();118}119120async function persistSettings(): Promise<void> {121  try {122    await chrome.storage.local.set({ peach_settings: settings });123  } catch (err) {124    throw mapStorageError(err);125  }126}127128function mapStorageError(err: unknown): Error {129  const message = err instanceof Error ? err.message : String(err);130  if (/QUOTA_BYTES|quota/i.test(message)) {131    return new Error("STORAGE_QUOTA_EXCEEDED");132  }133  return err instanceof Error ? err : new Error(message);134}135136async function loadSettings(): Promise<void> {137  const result = await chrome.storage.local.get("peach_settings");138  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};139}140141// ---------------------------------------------------------------------------142// HIBP breach check (k-anonymity model)143// ---------------------------------------------------------------------------144145export async function checkBreach(password: string): Promise<number> {146  const encoder = new TextEncoder();147  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));148  const hashHex = Array.from(new Uint8Array(hashBuffer))149    .map((b) => b.toString(16).padStart(2, "0"))150    .join("")151    .toUpperCase();152153  const prefix = hashHex.slice(0, 5);154  const suffix = hashHex.slice(5);155156  try {157    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);158    if (!res.ok) return 0;159    const text = await res.text();160    const match = text.split("\r\n").find((l) => l.startsWith(suffix));161    return match ? parseInt(match.split(":")[1], 10) : 0;162  } catch {163    return 0; // Network unavailable — don't block164  }165}166167export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {168  const fingerprint = await cardFingerprint(last4, expMonth, expYear);169  await loadSettings();170  settings = {171    ...settings,172    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],173  };174  await persistSettings();175}176177export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {178  await loadSettings();179  const fingerprint = await cardFingerprint(last4, expMonth, expYear);180  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);181}182183async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {184  const encoder = new TextEncoder();185  const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));186  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");187}188189function hex(buf: Uint8Array): string {190  return Array.from(buf)191    .map((b) => b.toString(16).padStart(2, "0"))192    .join("");193}194195function unhex(s: string): Uint8Array {196  const out = new Uint8Array(s.length / 2);197  for (let i = 0; i < s.length; i += 2) {198    out[i / 2] = parseInt(s.slice(i, i + 2), 16);199  }200  return out;201}202203// ---------------------------------------------------------------------------204// Storage schema migration205// ---------------------------------------------------------------------------206207async function loadSlot(key: string): Promise<StoredVault | null> {208  const result = await chrome.storage.local.get(key);209  return (result as Record<string, StoredVault | undefined>)[key] ?? null;210}211212async function writeSlot(key: string, vault: StoredVault): Promise<void> {213  try {214    await chrome.storage.local.set({ [key]: vault });215  } catch (err) {216    throw mapStorageError(err);217  }218}219220/**221 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault222 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +223 * peach_vault_b). Runs at most once per session; safe to call repeatedly.224 */225async function ensureMigrated(): Promise<void> {226  if (migrationRan) return;227  migrationRan = true;228229  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];230  const raw = await chrome.storage.local.get(keys);231  const slotAExists = raw[SLOT_A] !== undefined;232  const slotBExists = raw[SLOT_B] !== undefined;233  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;234  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;235236  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.237  if (!slotAExists && !legacyMain) return;238239  // Already migrated.240  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;241242  if (legacyMain && !slotAExists) {243    await writeSlot(SLOT_A, legacyMain);244  }245246  // If slot B isn't set, populate it: use legacy decoy when present, else noise.247  if (!slotBExists) {248    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;249    if (legacyDecoy) {250      await writeSlot(SLOT_B, legacyDecoy);251    } else if (target) {252      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));253    }254  }255256  const toRemove: string[] = [];257  if (legacyMain) toRemove.push(LEGACY_MAIN);258  if (legacyDecoy) toRemove.push(LEGACY_DECOY);259  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);260  if (toRemove.length > 0) {261    await chrome.storage.local.remove(toRemove);262  }263}264265// ---------------------------------------------------------------------------266// Padding + noise helpers267// ---------------------------------------------------------------------------268269/**270 * Pads the plaintext string with trailing spaces so that the encoded byte271 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing272 * whitespace after a complete document, so the padding is transparent on273 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.274 */275function padPlaintext(json: string): string {276  const encoded = new TextEncoder().encode(json);277  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);278  const padLen = target - encoded.length;279  return json + " ".repeat(padLen);280}281282/**283 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The284 * ciphertext length matches the given byte count. Because GCM ciphertext is285 * pseudo-random, this blob is indistinguishable at rest from a real286 * encrypted vault without the key.287 *288 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto289 * spec hard limit). Chunk the fill so vaults larger than that — common once290 * the user has dozens of entries or any passkey — don't throw.291 */292const CSPRNG_CHUNK = 65_536;293294function fillRandom(buf: Uint8Array): Uint8Array {295  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {296    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);297    const chunk = new Uint8Array(end - offset);298    crypto.getRandomValues(chunk);299    buf.set(chunk, offset);300  }301  return buf;302}303304function generateNoiseBlob(ciphertextBytes: number): StoredVault {305  const salt = crypto.getRandomValues(new Uint8Array(16));306  const nonce = crypto.getRandomValues(new Uint8Array(12));307  const cipher = fillRandom(new Uint8Array(ciphertextBytes));308  const tag = crypto.getRandomValues(new Uint8Array(16));309  return {310    version: 1,311    salt: hex(salt),312    nonce: hex(nonce),313    ciphertext: hex(cipher),314    tag: hex(tag),315  };316}317318// ---------------------------------------------------------------------------319// Encrypted plaintext envelope320// ---------------------------------------------------------------------------321322type VaultPayload = {323  entries: VaultEntry[];324  duressConfigured?: boolean;325};326327type DecryptedPayload = {328  entries: VaultEntry[];329  duressConfigured: boolean;330  /**331   * True when at least one entry in the on-disk payload had a legacy shape332   * (pre-v2.11.0 passkey type/flat fields, or pre-A-2 created/updated keys)333   * and got rewritten by normalizeEntry. The unlock path uses this to334   * persist the new shape immediately — without it, the migration would335   * re-run on every unlock and old shape never disappears from disk.336   */337  migratedFromLegacy: boolean;338};339340function encodePayload(entries: VaultEntry[], duress: boolean): string {341  const envelope: VaultPayload = { entries, duressConfigured: duress };342  return padPlaintext(JSON.stringify(envelope));343}344345function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {346  if ("created" in raw || "updated" in raw) return true;347  const flat = raw as Partial<VaultEntry>;348  if (flat.type === "passkey") return true;349  // Flat rpId without a passkey sub-object means pre-v2.11.0 schema.350  if (flat.rpId !== undefined && flat.passkey === undefined) return true;351  return false;352}353354function decodePayload(plain: string): DecryptedPayload {355  const parsed = JSON.parse(plain) as356    | Array<VaultEntry | LegacyVaultEntry>357    | VaultPayload;358359  const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];360  const migratedFromLegacy = rawEntries.some(rawNeedsMigration);361362  if (Array.isArray(parsed)) {363    return {364      entries: parsed.map(normalizeEntry),365      duressConfigured: false,366      migratedFromLegacy: true, // pre-A-2 array shape always needs save367    };368  }369370  return {371    entries: rawEntries.map(normalizeEntry),372    duressConfigured: parsed.duressConfigured === true,373    migratedFromLegacy,374  };375}376377async function tryDecryptSlot(378  vault: StoredVault,379  key: Uint8Array380): Promise<DecryptedPayload | null> {381  try {382    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);383    return decodePayload(plain);384  } catch {385    return null;386  }387}388389// ---------------------------------------------------------------------------390// Core operations391// ---------------------------------------------------------------------------392393function buildDomainIndexSync(all: VaultEntry[]): void {394  domainIndex = new Map();395396  for (const entry of all) {397    if (entry.deletedAt) continue;398    if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;399400    // An entry can match multiple sites via `additionalUrls`. Index each401    // hostname so `queryDomain` returns the entry regardless of which alias402    // the user is currently visiting. Dedupe per-entry so a single entry403    // never appears twice in one bucket.404    const domains = new Set<string>();405    const primary = extractDomain(entry.url ?? "");406    if (primary) domains.add(primary);407    for (const extra of entry.additionalUrls ?? []) {408      const d = extractDomain(extra);409      if (d) domains.add(d);410    }411    if (domains.size === 0) continue;412413    for (const domain of domains) {414      const bucket = domainIndex.get(domain) ?? [];415      bucket.push(entry);416      domainIndex.set(domain, bucket);417    }418  }419}420421export async function createVault(password: string): Promise<void> {422  await ensureMigrated();423424  const salt = generateSalt();425  derivedKey = await deriveKey(password, salt);426  entries = [];427  vaultMode = "real";428  duressConfigured = false;429  buildDomainIndexSync(entries);430431  const plaintext = encodePayload(entries, duressConfigured);432  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);433434  const slotA: StoredVault = {435    version: 1,436    salt: hex(salt),437    nonce,438    ciphertext,439    tag,440  };441  await writeSlot(SLOT_A, slotA);442443  // Matching-size noise blob for slot B, so a fresh install shows two444  // similarly-shaped records regardless of whether duress is ever set up.445  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));446447  touchAutoLock();448}449450export async function hasVault(): Promise<boolean> {451  await ensureMigrated();452  return (await loadSlot(SLOT_A)) !== null;453}454455/**456 * Returns whether the real vault has recorded that a duress passphrase is457 * configured. Reliable only after a real-mode unlock — before unlock or in458 * decoy mode, returns false because we cannot read slot A's plaintext.459 */460export async function isDuressEnabled(): Promise<boolean> {461  return vaultMode === "real" && duressConfigured;462}463464export async function hasDecoyVault(): Promise<boolean> {465  return isDuressEnabled();466}467468export async function setupDuress(duressPassword: string): Promise<void> {469  if (!derivedKey || vaultMode !== "real") {470    throw new Error("vault must be unlocked in real mode");471  }472  await ensureMigrated();473474  // Encrypt the decoy payload (empty entries) under the duress key. Pad to475  // at least the current slot A ciphertext size so the two slots look the476  // same at rest at setup time.477  const slotA = await loadSlot(SLOT_A);478  if (!slotA) throw new Error("missing slot A");479480  const decoySalt = generateSalt();481  const decoyKey = await deriveKey(duressPassword, decoySalt);482  try {483    // Pad the decoy plaintext to match slot A's ciphertext length (minus the484    // 16-byte tag) so encrypted sizes align exactly.485    const targetBytes = slotA.ciphertext.length / 2;486    let plaintext = encodePayload([], false);487    const encoded = new TextEncoder().encode(plaintext);488    if (encoded.length < targetBytes) {489      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);490    }491    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);492    await writeSlot(SLOT_B, {493      version: 1,494      salt: hex(decoySalt),495      nonce,496      ciphertext,497      tag,498    });499  } finally {500    zeroKey(decoyKey);501  }502503  // Re-encrypt slot A with the duressConfigured flag set to true. No external504  // flag is written — the bit lives inside the encrypted envelope.505  duressConfigured = true;506  await persistSlotA();507}508509export async function disableDuress(): Promise<void> {510  if (!derivedKey || vaultMode !== "real") {511    throw new Error("vault must be unlocked in real mode");512  }513  await ensureMigrated();514515  // Overwrite slot B with fresh noise that matches slot A's size. The slot516  // remains occupied so its mere existence does not signal that duress was517  // ever configured.518  const slotA = await loadSlot(SLOT_A);519  if (slotA) {520    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));521  }522523  duressConfigured = false;524  await persistSlotA();525}526527export async function verifyMainPassword(password: string): Promise<boolean> {528  await ensureMigrated();529  const slotA = await loadSlot(SLOT_A);530  if (!slotA) return false;531  const key = await deriveKey(password, unhex(slotA.salt));532  try {533    return (await tryDecryptSlot(slotA, key)) !== null;534  } finally {535    zeroKey(key);536  }537}538539export async function unlock(password: string): Promise<boolean> {540  await ensureMigrated();541542  // Load both slots in parallel. Both always exist post-migration; the543  // parallel load and the parallel derivation below keep wall-clock time544  // constant regardless of which slot's passphrase (real or decoy) the545  // caller supplied, so a timing side channel cannot distinguish the546  // duress path from the real-unlock path (DUR-002).547  const [slotA, slotB] = await Promise.all([548    loadSlot(SLOT_A),549    loadSlot(SLOT_B),550  ]);551552  // Derive both keys in parallel, unconditionally.553  const [keyA, keyB] = await Promise.all([554    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),555    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),556  ]);557558  // Attempt both decrypts in parallel.559  const [payloadA, payloadB] = await Promise.all([560    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),561    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),562  ]);563564  if (payloadA && keyA) {565    derivedKey = keyA;566    vaultMode = "real";567    entries = payloadA.entries;568    duressConfigured = payloadA.duressConfigured;569    buildDomainIndexSync(entries);570    await loadSettings();571    touchAutoLock();572    if (keyB) zeroKey(keyB);573    // If the payload had a pre-v2.11.0 shape (legacy passkey type, flat574    // passkey fields, or pre-A-2 created/updated keys), rewrite it now so575    // the next save uses the new format and migration doesn't re-run forever.576    if (payloadA.migratedFromLegacy) {577      await persistSlotA();578    }579    return true;580  }581582  if (payloadB && keyB) {583    derivedKey = keyB;584    vaultMode = "decoy";585    entries = payloadB.entries;586    duressConfigured = false;587    buildDomainIndexSync(entries);588    await loadSettings();589    touchAutoLock();590    if (keyA) zeroKey(keyA);591    return true;592  }593594  if (keyA) zeroKey(keyA);595  if (keyB) zeroKey(keyB);596  return false;597}598599export function lock(): void {600  if (derivedKey) {601    zeroKey(derivedKey);602    derivedKey = null;603  }604605  entries = [];606  domainIndex = new Map();607  vaultMode = "real";608  duressConfigured = false;609  clearAutoLock();610}611612export function isUnlocked(): boolean {613  return derivedKey !== null;614}615616export function getEntries(): VaultEntry[] {617  if (!derivedKey) throw new Error("vault locked");618  return entries.filter((entry) => !entry.deletedAt);619}620621const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;622623export function getIdentities(): SavedIdentity[] {624  if (!derivedKey) throw new Error("vault locked");625  const active = entries.filter((e) => !e.deletedAt && e.type === "login");626627  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();628  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();629630  for (const entry of active) {631    const value = entry.username?.trim();632    if (!value) continue;633    const map = EMAIL_RE.test(value) ? emailMap : usernameMap;634    const existing = map.get(value);635    const domain = extractDomain(entry.url ?? "") ?? "";636    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;637    if (!existing || modifiedAt > existing.lastUsed) {638      map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });639    } else {640      map.set(value, { ...existing, count: existing.count + 1 });641    }642  }643644  const toIdentity = (type: "email" | "username") =>645    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({646      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,647    });648649  return [650    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),651    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),652  ];653}654655export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {656  if (!derivedKey) throw new Error("vault locked");657658  const now = Date.now();659  const added: VaultEntry[] = [];660  for (const draft of drafts) {661    try {662      const sanitized = sanitizeEntryDraft(draft);663      const nextEntry: VaultEntry = {664        ...sanitized,665        id: crypto.randomUUID(),666        createdAt: now,667        modifiedAt: now,668      };669      entries.push(nextEntry);670      added.push(nextEntry);671    } catch {672      // Skip invalid drafts silently — caller can see diff between input and output length673    }674  }675676  if (added.length > 0) {677    await persistEntries();678  }679680  return added;681}682683/**684 * Restore a set of entries in full fidelity — preserves id, createdAt,685 * modifiedAt, and every other field unlike `batchAddEntries` which686 * assigns fresh identity. Used by restore paths where the backup is the687 * authoritative source of truth. `mode` controls how entries already in688 * the vault are treated.689 */690export async function restoreEntries(691  incoming: VaultEntry[],692  mode: "replace" | "merge"693): Promise<{ added: number; updated: number; skipped: number }> {694  if (!derivedKey) throw new Error("vault locked");695696  // A replace with zero incoming entries is almost certainly a caller bug697  // (e.g. an import that silently dropped everything). Refuse rather than698  // wipe the vault.699  if (mode === "replace" && incoming.length === 0) {700    throw new Error("refuse to replace with empty entry set");701  }702703  let added = 0;704  let updated = 0;705  let skipped = 0;706707  if (mode === "replace") {708    entries = [];709    for (const entry of incoming) {710      const normalized = sanitizeRestoredEntry(entry);711      if (!normalized) {712        skipped += 1;713        continue;714      }715      entries.push(normalized);716      added += 1;717    }718  } else {719    const byId = new Map<string, VaultEntry>();720    for (const e of entries) byId.set(e.id, e);721722    for (const entry of incoming) {723      const normalized = sanitizeRestoredEntry(entry);724      if (!normalized) {725        skipped += 1;726        continue;727      }728      const existing = byId.get(normalized.id);729      if (!existing) {730        entries.push(normalized);731        added += 1;732      } else if (normalized.modifiedAt > existing.modifiedAt) {733        const idx = entries.findIndex((e) => e.id === existing.id);734        if (idx !== -1) {735          entries[idx] = normalized;736          updated += 1;737        }738      } else {739        skipped += 1;740      }741    }742  }743744  await persistEntries();745  return { added, updated, skipped };746}747748/**749 * Validate + normalise a VaultEntry coming from a backup. Accepts750 * anything structurally sound; keeps all metadata verbatim. Returns751 * null for values the live vault shouldn't accept (e.g., missing title).752 */753function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {754  if (!entry || typeof entry !== "object") return null;755  const title = typeof entry.title === "string" ? entry.title.trim() : "";756  if (!title) return null;757  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();758  const now = Date.now();759  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;760  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;761762  // Migrate legacy passkey shape on import (same logic as on-disk read).763  // Imports from pre-v2.11.0 vault backups still carry type:"passkey" with764  // flat fields; bring them into the new shape immediately.765  const migrated: VaultEntry = migrateLegacyPasskey(766    {767      ...entry,768      id,769      title,770      type: entry.type ?? "login",771      createdAt,772      modifiedAt,773    },774    entry775  );776777  // Login entries from backup must carry a password (or be passkey-only,778  // in which case the passkey sub-object replaces the password requirement).779  if (780    migrated.type === "login" &&781    !migrated.passkey &&782    typeof migrated.password !== "string"783  ) {784    return null;785  }786  if (migrated.passkey && !migrated.passkey.credentialId) {787    return null;788  }789790  return migrated;791}792793// Soft-deletes every non-deleted entry in one call. The literal string794// argument is a tripwire against accidental calls — any caller that would795// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this796// to a boolean.797export async function clearVaultEntries(798  confirmation: "REPLACE_ALL"799): Promise<void> {800  if (confirmation !== "REPLACE_ALL") {801    throw new Error("clearVaultEntries requires explicit confirmation");802  }803  if (!derivedKey) throw new Error("vault locked");804  for (const entry of entries) {805    if (!entry.deletedAt) {806      entry.deletedAt = Date.now();807      entry.modifiedAt = Date.now();808    }809  }810  await persistEntries();811}812813export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {814  if (!derivedKey) throw new Error("vault locked");815816  const sanitized = sanitizeEntryDraft(entry);817  const now = Date.now();818  const nextEntry: VaultEntry = {819    ...sanitized,820    id: crypto.randomUUID(),821    createdAt: now,822    modifiedAt: now,823    // Stamp passwordChangedAt at creation so age tracking is accurate from824    // day one. Non-login entries carry the same stamp harmlessly.825    passwordChangedAt: sanitized.password ? now : undefined,826  };827828  entries.push(nextEntry);829  await persistEntries();830831  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)832  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {833    checkBreach(nextEntry.password).then(async (count) => {834      if (count > 0) {835        const idx = entries.findIndex((e) => e.id === nextEntry.id);836        if (idx !== -1) {837          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };838          await persistEntries();839        }840      }841    });842  }843844  return nextEntry;845}846847export async function updateEntry(848  id: string,849  patch: Partial<VaultEntry>850): Promise<void> {851  if (!derivedKey) throw new Error("vault locked");852853  const idx = entries.findIndex((entry) => entry.id === id);854  if (idx === -1 || entries[idx].deletedAt) {855    throw new Error("entry not found");856  }857858  const current = entries[idx];859  const sanitizedPatch = sanitizeEntryPatch(patch);860  // Refresh passwordChangedAt only when the password value actually changes —861  // a metadata-only update (title, tags, etc.) must not reset the age clock862  // that drives the Pro health rotation recommendation.863  const passwordChanged =864    "password" in patch &&865    typeof sanitizedPatch.password === "string" &&866    sanitizedPatch.password !== current.password;867  entries[idx] = {868    ...current,869    ...sanitizedPatch,870    modifiedAt: Date.now(),871    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),872  };873874  // sanitizeEntryPatch maps an empty password to `undefined`. For login875  // entries that would silently produce a passwordless record — roll back876  // the merge and refuse.877  if (entries[idx].type === "login" && !entries[idx].password) {878    entries[idx] = current;879    throw new Error("password is required for login entries");880  }881882  // Card entries must retain their card number883  if (entries[idx].type === "card" && !entries[idx].cardNumber) {884    entries[idx] = current;885    throw new Error("card number is required for card entries");886  }887888  await persistEntries();889890  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)891  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {892    const password = entries[idx].password!;893    checkBreach(password).then(async (count) => {894      const i = entries.findIndex((e) => e.id === id);895      if (i !== -1) {896        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };897        await persistEntries();898      }899    });900  }901}902903export async function deleteEntry(id: string): Promise<void> {904  if (!derivedKey) throw new Error("vault locked");905906  const entry = entries.find((item) => item.id === id);907  if (!entry || entry.deletedAt) {908    throw new Error("entry not found");909  }910911  entry.deletedAt = Date.now();912  entry.modifiedAt = Date.now();913  await persistEntries();914}915916export function getDeletedEntries(): VaultEntry[] {917  if (!derivedKey) return [];918  return entries.filter((e) => !!e.deletedAt);919}920921export async function restoreEntry(id: string): Promise<void> {922  if (!derivedKey) throw new Error("vault locked");923  const entry = entries.find((e) => e.id === id && e.deletedAt);924  if (!entry) throw new Error("deleted entry not found");925  delete entry.deletedAt;926  entry.modifiedAt = Date.now();927  await persistEntries();928  buildDomainIndexSync(entries);929}930931export async function purgeEntry(id: string): Promise<void> {932  if (!derivedKey) throw new Error("vault locked");933  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);934  if (idx === -1) throw new Error("deleted entry not found");935  entries.splice(idx, 1);936  await persistEntries();937}938939/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */940export async function purgeExpiredTrash(retentionMs: number): Promise<number> {941  if (!derivedKey || retentionMs === 0) return 0;942  const cutoff = Date.now() - retentionMs;943  const before = entries.length;944  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));945  const purged = before - entries.length;946  if (purged > 0) await persistEntries();947  return purged;948}949950export function queryDomain(domain: string): VaultEntry[] {951  return domainIndex.get(domain) ?? [];952}953954export function getEntryById(id: string): VaultEntry | undefined {955  return entries.find((entry) => entry.id === id && !entry.deletedAt);956}957958async function persistSlotA(): Promise<StoredVault> {959  if (!derivedKey) throw new Error("vault locked");960961  const slotA = await loadSlot(SLOT_A);962  if (!slotA) throw new Error("missing slot A");963964  const plaintext = encodePayload(entries, duressConfigured);965  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);966967  const nextSlotA: StoredVault = {968    ...slotA,969    nonce,970    ciphertext,971    tag,972  };973  await writeSlot(SLOT_A, nextSlotA);974975  // When duress is not configured, keep slot B size-matched with slot A by976  // regenerating its noise. When duress IS configured, leave slot B alone —977  // we don't have the decoy key to re-encrypt it. This is a known trade-off.978  if (!duressConfigured) {979    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));980  }981982  return nextSlotA;983}984985async function persistSlotB(): Promise<void> {986  if (!derivedKey) throw new Error("vault locked");987988  const slotB = await loadSlot(SLOT_B);989  if (!slotB) throw new Error("missing slot B");990991  // Decoy plaintext always reports duressConfigured=false — the decoy user992  // sees a clean slate.993  const plaintext = encodePayload(entries, false);994  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);995996  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });997}998999async function persistEntries(): Promise<void> {1000  if (!derivedKey) throw new Error("vault locked");10011002  if (vaultMode === "decoy") {1003    await persistSlotB();1004  } else {1005    const freshSlotA = await persistSlotA();1006    // Save a snapshot of the real vault after every successful write.1007    // Failures are silently swallowed — snapshots must never interrupt saves.1008    const activeCount = entries.filter((e) => !e.deletedAt).length;1009    void takeSnapshot(freshSlotA, activeCount);1010  }1011  buildDomainIndexSync(entries);1012}10131014function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1015  if (!Array.isArray(values)) return undefined;1016  const seen = new Set<string>();1017  const cleaned: string[] = [];1018  for (const value of values) {1019    const trimmed = typeof value === "string" ? value.trim() : "";1020    if (!trimmed) continue;1021    const key = trimmed.toLowerCase();1022    if (seen.has(key)) continue;1023    seen.add(key);1024    cleaned.push(trimmed);1025  }1026  return cleaned.length > 0 ? cleaned : undefined;1027}10281029function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1030  const title = entry.title.trim();1031  // Accept legacy `type:"passkey"` drafts but rewrite to "login" — the new1032  // model is "any entry, optionally with a passkey sub-object." Imports and1033  // any in-flight callers from before v2.11.0 keep working without throwing.1034  let type: VaultEntry["type"] = entry.type ?? "login";1035  if (type === "passkey") type = "login";10361037  if (!title) throw new Error("title is required");1038  // A login entry needs either a password or a passkey credential to be useful.1039  // Passkey-only entries (passkey present, no password) are valid.1040  if (type === "login" && !entry.password?.trim() && !entry.passkey) {1041    throw new Error("password or passkey required");1042  }1043  if (entry.passkey) {1044    const p = entry.passkey;1045    if (1046      !p.rpId ||1047      !p.credentialId ||1048      !p.publicKeyCose ||1049      !p.userHandle ||1050      !p.userName ||1051      (p.algorithm !== -7 && p.algorithm !== -8)1052    ) {1053      throw new Error("invalid passkey credential");1054    }1055  }1056  if (type === "card") {1057    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1058      throw new Error("cardholder name and card number are required");1059    }1060  }10611062  return {1063    type,1064    title,1065    username: type === "login" ? entry.username?.trim() ?? "" : undefined,1066    password: type === "login" ? entry.password : undefined,1067    url: type === "login" ? entry.url?.trim() ?? "" : undefined,1068    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1069    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1070    notes: entry.notes?.trim() || undefined,1071    breach: type === "login" ? entry.breach : undefined,1072    canary: type === "login" ? entry.canary : undefined,1073    tags: entry.tags,1074    passkey: entry.passkey,1075    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1076    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1077    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1078    expYear: type === "card" ? entry.expYear?.trim() : undefined,1079    cvc: type === "card" ? entry.cvc?.trim() : undefined,1080    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1081    network: type === "card" ? entry.network : undefined,1082    last4: type === "card" ? entry.last4?.trim() : undefined,1083  };1084}10851086function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1087  const nextPatch: Partial<VaultEntry> = {};10881089  if ("type" in patch) {1090    nextPatch.type = patch.type;1091  }10921093  if ("title" in patch) {1094    const title = patch.title?.trim() ?? "";1095    if (!title) throw new Error("title is required");1096    nextPatch.title = title;1097  }10981099  if ("url" in patch) {1100    nextPatch.url = patch.url?.trim() ?? "";1101  }11021103  if ("additionalUrls" in patch) {1104    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1105    const seen = new Set<string>();1106    const cleaned: string[] = [];1107    for (const value of incoming) {1108      const trimmed = typeof value === "string" ? value.trim() : "";1109      if (!trimmed) continue;1110      const key = trimmed.toLowerCase();1111      if (seen.has(key)) continue;1112      seen.add(key);1113      cleaned.push(trimmed);1114    }1115    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1116  }11171118  if ("username" in patch) {1119    nextPatch.username = patch.username?.trim() ?? "";1120  }11211122  if ("password" in patch) {1123    const password = patch.password ?? "";1124    // Allow clearing a password (e.g. on passkey-only entries)1125    nextPatch.password = password || undefined;1126  }11271128  if ("totp" in patch) {1129    nextPatch.totp = patch.totp?.trim() || undefined;1130  }11311132  if ("notes" in patch) {1133    nextPatch.notes = patch.notes?.trim() || undefined;1134  }11351136  if ("breach" in patch) {1137    nextPatch.breach = patch.breach;1138  }11391140  if ("canary" in patch) {1141    nextPatch.canary = patch.canary;1142  }11431144  if ("rpId" in patch) {1145    nextPatch.rpId = patch.rpId?.trim();1146  }11471148  if ("rpName" in patch) {1149    nextPatch.rpName = patch.rpName?.trim();1150  }11511152  if ("credentialId" in patch) {1153    nextPatch.credentialId = patch.credentialId?.trim();1154  }11551156  if ("publicKeyCose" in patch) {1157    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1158  }11591160  if ("privateKeyJwk" in patch) {1161    nextPatch.privateKeyJwk = patch.privateKeyJwk;1162  }11631164  if ("userHandle" in patch) {1165    nextPatch.userHandle = patch.userHandle?.trim();1166  }11671168  if ("userName" in patch) {1169    nextPatch.userName = patch.userName?.trim();1170  }11711172  if ("userDisplayName" in patch) {1173    nextPatch.userDisplayName = patch.userDisplayName?.trim();1174  }11751176  if ("algorithm" in patch) {1177    nextPatch.algorithm = patch.algorithm;1178  }11791180  if ("signCount" in patch) {1181    nextPatch.signCount = patch.signCount;1182  }11831184  if ("aaguid" in patch) {1185    nextPatch.aaguid = patch.aaguid?.trim();1186  }11871188  if ("backupEligible" in patch) {1189    nextPatch.backupEligible = patch.backupEligible;1190  }11911192  if ("backupState" in patch) {1193    nextPatch.backupState = patch.backupState;1194  }11951196  if ("lastUsedAt" in patch) {1197    nextPatch.lastUsedAt = patch.lastUsedAt;1198  }11991200  if ("tags" in patch) {1201    nextPatch.tags = patch.tags;1202  }12031204  if ("cardholderName" in patch) {1205    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1206  }1207  if ("cardNumber" in patch) {1208    const n = patch.cardNumber ?? "";1209    nextPatch.cardNumber = n || undefined;1210  }1211  if ("expMonth" in patch) {1212    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1213  }1214  if ("expYear" in patch) {1215    nextPatch.expYear = patch.expYear?.trim() || undefined;1216  }1217  if ("cvc" in patch) {1218    nextPatch.cvc = patch.cvc?.trim() || undefined;1219  }1220  if ("cardNickname" in patch) {1221    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1222  }1223  if ("network" in patch) {1224    nextPatch.network = patch.network;1225  }1226  if ("last4" in patch) {1227    nextPatch.last4 = patch.last4?.trim() || undefined;1228  }12291230  return nextPatch;1231}12321233function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1234  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1235  const modifiedAt =1236    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12371238  const base: VaultEntry = {1239    id: entry.id,1240    type: "type" in entry ? entry.type : "login",1241    title: entry.title,1242    username: entry.username,1243    password: entry.password,1244    url: entry.url,1245    totp: entry.totp,1246    notes: entry.notes,1247    breach: entry.breach,1248    canary: "canary" in entry ? entry.canary : undefined,1249    passkey: "passkey" in entry ? entry.passkey : undefined,1250    createdAt,1251    modifiedAt,1252    deletedAt: entry.deletedAt,1253    tags: "tags" in entry ? entry.tags : undefined,1254    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1255    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1256    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1257    expYear: "expYear" in entry ? entry.expYear : undefined,1258    cvc: "cvc" in entry ? entry.cvc : undefined,1259    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1260    network: "network" in entry ? entry.network : undefined,1261    last4: "last4" in entry ? entry.last4 : undefined,1262  };12631264  return migrateLegacyPasskey(base, entry);1265}12661267/**1268 * v2.11.0 read migration. Pre-v2.11.0 passkey entries used `type:"passkey"`1269 * with flat top-level fields (rpId, credentialId, etc.). This converts them1270 * to the new shape — `type:"login"` with a `passkey` sub-object — so all1271 * downstream consumers can rely on the new model. The result is rewritten1272 * to disk on next vault save (hasLegacyShape detects the un-migrated case).1273 */1274function migrateLegacyPasskey(1275  base: VaultEntry,1276  raw: VaultEntry | LegacyVaultEntry1277): VaultEntry {1278  if (base.passkey) return base;1279  const flat = raw as Partial<VaultEntry>;1280  const isPasskeyType = base.type === "passkey";1281  const hasFlatPasskeyFields =1282    typeof flat.rpId === "string" &&1283    typeof flat.credentialId === "string" &&1284    typeof flat.publicKeyCose === "string" &&1285    typeof flat.userHandle === "string" &&1286    typeof flat.userName === "string";1287  if (!isPasskeyType && !hasFlatPasskeyFields) return base;1288  if (!hasFlatPasskeyFields) return base;12891290  const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1291  base.passkey = {1292    rpId: flat.rpId!,1293    rpName: flat.rpName ?? flat.rpId!,1294    credentialId: flat.credentialId!,1295    publicKeyCose: flat.publicKeyCose!,1296    privateKeyJwk: flat.privateKeyJwk,1297    userHandle: flat.userHandle!,1298    userName: flat.userName!,1299    userDisplayName: flat.userDisplayName ?? flat.userName!,1300    algorithm,1301    signCount: flat.signCount ?? 0,1302    aaguid: flat.aaguid ?? "",1303    backupEligible: flat.backupEligible === true,1304    backupState: flat.backupState === true,1305    lastUsedAt: flat.lastUsedAt,1306    browser: flat.browser,1307    transports: flat.transports,1308    createdAt: base.createdAt,1309  };1310  // Promote to "login" so the unified Entry view renders. Username gets1311  // populated from the passkey's userName so the entry is searchable in the1312  // existing autofill flow without a separate code path.1313  if (isPasskeyType) base.type = "login";1314  if (!base.username) base.username = flat.userName;1315  if (!base.url) base.url = `https://${flat.rpId}`;1316  return base;1317}131813191320// ---------------------------------------------------------------------------1321// Deduplication1322// ---------------------------------------------------------------------------13231324function entryDupeKey(e: VaultEntry): string {1325  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1326  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins1327  // on different sites that happen to share a username are still distinct;1328  // two passkey credentials for the same RP+user are duplicates regardless1329  // of any login fields they may also carry.1330  if (e.passkey) {1331    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1332  }1333  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1334}13351336/** Returns the number of duplicate entries (those that would be removed). */1337export function countDuplicates(): number {1338  const best = new Map<string, VaultEntry>();1339  let dupeCount = 0;1340  for (const e of entries.filter((e) => !e.deletedAt)) {1341    const key = entryDupeKey(e);1342    const existing = best.get(key);1343    if (!existing) {1344      best.set(key, e);1345    } else {1346      dupeCount++;1347      // Keep the more recently modified one in the map1348      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1349        best.set(key, e);1350      }1351    }1352  }1353  return dupeCount;1354}13551356/**1357 * Soft-deletes duplicate entries, keeping the most recently modified copy of1358 * each group. Returns the number of entries moved to trash.1359 */1360export async function deduplicateEntries(): Promise<number> {1361  if (!derivedKey) throw new Error("vault locked");1362  // Dedup reshapes slot B permanently under the decoy key, which is1363  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1364  if (vaultMode === "decoy") {1365    throw new Error("deduplicate disabled in decoy mode");1366  }13671368  const best = new Map<string, VaultEntry>();1369  for (const e of entries.filter((en) => !en.deletedAt)) {1370    const key = entryDupeKey(e);1371    const existing = best.get(key);1372    if (!existing) {1373      best.set(key, e);1374    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1375      best.set(key, e);1376    }1377  }13781379  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1380  const now = Date.now();1381  let removed = 0;1382  entries = entries.map((e) => {1383    if (!e.deletedAt && !keepIds.has(e.id)) {1384      removed++;1385      return { ...e, deletedAt: now };1386    }1387    return e;1388  });13891390  if (removed > 0) await persistEntries();1391  return removed;1392}13931394// ---------------------------------------------------------------------------1395// Vault snapshot system — last 6 encrypted states, used for self-recovery1396// ---------------------------------------------------------------------------13971398const SNAPSHOT_KEY = "peach_vault_snapshots";1399const MAX_SNAPSHOTS = 6;14001401interface VaultSnapshot {1402  timestamp: number;1403  entryCount: number;1404  slot: StoredVault;1405}14061407/** Public summary (no sensitive data) returned to the popup. */1408export interface SnapshotSummary {1409  index: number;1410  timestamp: number;1411  entryCount: number;1412}14131414async function takeSnapshot(1415  slot: StoredVault,1416  entryCount: number1417): Promise<void> {1418  try {1419    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1420    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1421    const next: VaultSnapshot[] = [1422      { timestamp: Date.now(), entryCount, slot },1423      ...existing,1424    ].slice(0, MAX_SNAPSHOTS);1425    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1426  } catch {1427    // Non-fatal — snapshot failure must never interrupt vault saves1428  }1429}14301431export async function listSnapshots(): Promise<SnapshotSummary[]> {1432  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1433  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1434  return snapshots.map((s, i) => ({1435    index: i,1436    timestamp: s.timestamp,1437    entryCount: s.entryCount,1438  }));1439}14401441/**1442 * Restore a snapshot by index. The password must decrypt that snapshot1443 * (i.e., the same master password used when the snapshot was taken).1444 * On success the vault is unlocked in-memory with the restored entries1445 * — no re-login required.1446 */1447export async function restoreFromSnapshot(1448  index: number,1449  password: string1450): Promise<boolean> {1451  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1452  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1453  const snapshot = snapshots[index];1454  if (!snapshot) return false;14551456  // Verify the password decrypts this snapshot1457  const key = await deriveKey(password, unhex(snapshot.slot.salt));1458  const payload = await tryDecryptSlot(snapshot.slot, key);1459  if (!payload) {1460    zeroKey(key);1461    return false;1462  }14631464  // Write the snapshot back to slot A. Regenerate noise for slot B.1465  await writeSlot(SLOT_A, snapshot.slot);1466  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));14671468  // Swap in-memory state to the restored vault — caller gets immediate access1469  if (derivedKey) zeroKey(derivedKey);1470  derivedKey = key;1471  entries = payload.entries;1472  duressConfigured = payload.duressConfigured;1473  vaultMode = "real";1474  buildDomainIndexSync(entries);1475  await loadSettings();1476  touchAutoLock();14771478  return true;1479}14801481/**1482 * Change the master password. Re-encrypts slot A with a fresh salt + new1483 * derived key. Slot B (duress or noise) is left untouched when duress is1484 * configured — it uses an independent key derived from the duress password —1485 * and regenerated as noise when duress is not configured.1486 */1487export async function changePassword(1488  currentPassword: string,1489  newPassword: string1490): Promise<boolean> {1491  if (!derivedKey || vaultMode !== "real") return false;14921493  // Verify current password before allowing any change1494  const valid = await verifyMainPassword(currentPassword);1495  if (!valid) return false;14961497  const slotA = await loadSlot(SLOT_A);1498  if (!slotA) return false;14991500  // Derive fresh key from new password + new random salt1501  const newSalt = generateSalt();1502  const newKey = await deriveKey(newPassword, newSalt);15031504  // Re-encrypt the in-memory entries with the new key1505  const plaintext = encodePayload(entries, duressConfigured);1506  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);15071508  const freshSlotA: StoredVault = {1509    ...slotA,1510    salt: hex(newSalt),1511    nonce,1512    ciphertext,1513    tag,1514  };1515  await writeSlot(SLOT_A, freshSlotA);15161517  // Swap the in-memory key synchronously before any further await. If we1518  // threw between the Slot A write and this swap, the next persistEntries()1519  // would overwrite Slot A with old-key ciphertext, locking the user out.1520  zeroKey(derivedKey);1521  derivedKey = newKey;15221523  // Snapshot now that in-memory key and disk are consistent. Pass the1524  // slot content we just wrote rather than re-reading from storage so the1525  // snapshot is immune to any microtask-coalesced read-after-write race.1526  const activeCount = entries.filter((e) => !e.deletedAt).length;1527  void takeSnapshot(freshSlotA, activeCount);15281529  // Regenerate noise for slot B when duress is not in use. Runs after the1530  // key swap so a failure here is a disposable noise regeneration, not a1531  // lockout.1532  if (!duressConfigured) {1533    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1534  }15351536  return true;1537}