vault.ts

49.4 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 === "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 ("passkey" in patch) {1145    // Passkey sub-object replaces all the legacy flat passkey fields. A1146    // patch with `passkey: undefined` removes the credential from the entry1147    // (used by "remove passkey" without deleting the whole entry).1148    nextPatch.passkey = patch.passkey;1149  }11501151  if ("tags" in patch) {1152    nextPatch.tags = patch.tags;1153  }11541155  if ("cardholderName" in patch) {1156    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1157  }1158  if ("cardNumber" in patch) {1159    const n = patch.cardNumber ?? "";1160    nextPatch.cardNumber = n || undefined;1161  }1162  if ("expMonth" in patch) {1163    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1164  }1165  if ("expYear" in patch) {1166    nextPatch.expYear = patch.expYear?.trim() || undefined;1167  }1168  if ("cvc" in patch) {1169    nextPatch.cvc = patch.cvc?.trim() || undefined;1170  }1171  if ("cardNickname" in patch) {1172    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1173  }1174  if ("network" in patch) {1175    nextPatch.network = patch.network;1176  }1177  if ("last4" in patch) {1178    nextPatch.last4 = patch.last4?.trim() || undefined;1179  }11801181  return nextPatch;1182}11831184function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1185  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1186  const modifiedAt =1187    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;11881189  const base: VaultEntry = {1190    id: entry.id,1191    type: "type" in entry ? entry.type : "login",1192    title: entry.title,1193    username: entry.username,1194    password: entry.password,1195    url: entry.url,1196    totp: entry.totp,1197    notes: entry.notes,1198    breach: entry.breach,1199    canary: "canary" in entry ? entry.canary : undefined,1200    passkey: "passkey" in entry ? entry.passkey : undefined,1201    createdAt,1202    modifiedAt,1203    deletedAt: entry.deletedAt,1204    tags: "tags" in entry ? entry.tags : undefined,1205    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1206    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1207    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1208    expYear: "expYear" in entry ? entry.expYear : undefined,1209    cvc: "cvc" in entry ? entry.cvc : undefined,1210    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1211    network: "network" in entry ? entry.network : undefined,1212    last4: "last4" in entry ? entry.last4 : undefined,1213  };12141215  return migrateLegacyPasskey(base, entry);1216}12171218/**1219 * v2.11.0 read migration. Pre-v2.11.0 passkey entries used `type:"passkey"`1220 * with flat top-level fields (rpId, credentialId, etc.). This converts them1221 * to the new shape — `type:"login"` with a `passkey` sub-object — so all1222 * downstream consumers can rely on the new model. The result is rewritten1223 * to disk on next vault save (hasLegacyShape detects the un-migrated case).1224 */1225function migrateLegacyPasskey(1226  base: VaultEntry,1227  raw: VaultEntry | LegacyVaultEntry1228): VaultEntry {1229  if (base.passkey) return base;1230  const flat = raw as Partial<VaultEntry>;1231  const isPasskeyType = base.type === "passkey";1232  const hasFlatPasskeyFields =1233    typeof flat.rpId === "string" &&1234    typeof flat.credentialId === "string" &&1235    typeof flat.publicKeyCose === "string" &&1236    typeof flat.userHandle === "string" &&1237    typeof flat.userName === "string";1238  if (!isPasskeyType && !hasFlatPasskeyFields) return base;1239  if (!hasFlatPasskeyFields) return base;12401241  const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1242  base.passkey = {1243    rpId: flat.rpId!,1244    rpName: flat.rpName ?? flat.rpId!,1245    credentialId: flat.credentialId!,1246    publicKeyCose: flat.publicKeyCose!,1247    privateKeyJwk: flat.privateKeyJwk,1248    userHandle: flat.userHandle!,1249    userName: flat.userName!,1250    userDisplayName: flat.userDisplayName ?? flat.userName!,1251    algorithm,1252    signCount: flat.signCount ?? 0,1253    aaguid: flat.aaguid ?? "",1254    backupEligible: flat.backupEligible === true,1255    backupState: flat.backupState === true,1256    lastUsedAt: flat.lastUsedAt,1257    browser: flat.browser,1258    transports: flat.transports,1259    createdAt: base.createdAt,1260  };1261  // Promote to "login" so the unified Entry view renders. Username gets1262  // populated from the passkey's userName so the entry is searchable in the1263  // existing autofill flow without a separate code path.1264  if (isPasskeyType) base.type = "login";1265  if (!base.username) base.username = flat.userName;1266  if (!base.url) base.url = `https://${flat.rpId}`;1267  return base;1268}126912701271// ---------------------------------------------------------------------------1272// Deduplication1273// ---------------------------------------------------------------------------12741275function entryDupeKey(e: VaultEntry): string {1276  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1277  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins1278  // on different sites that happen to share a username are still distinct;1279  // two passkey credentials for the same RP+user are duplicates regardless1280  // of any login fields they may also carry.1281  if (e.passkey) {1282    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1283  }1284  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1285}12861287/** Returns the number of duplicate entries (those that would be removed). */1288export function countDuplicates(): number {1289  const best = new Map<string, VaultEntry>();1290  let dupeCount = 0;1291  for (const e of entries.filter((e) => !e.deletedAt)) {1292    const key = entryDupeKey(e);1293    const existing = best.get(key);1294    if (!existing) {1295      best.set(key, e);1296    } else {1297      dupeCount++;1298      // Keep the more recently modified one in the map1299      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1300        best.set(key, e);1301      }1302    }1303  }1304  return dupeCount;1305}13061307/**1308 * Soft-deletes duplicate entries, keeping the most recently modified copy of1309 * each group. Returns the number of entries moved to trash.1310 */1311export async function deduplicateEntries(): Promise<number> {1312  if (!derivedKey) throw new Error("vault locked");1313  // Dedup reshapes slot B permanently under the decoy key, which is1314  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1315  if (vaultMode === "decoy") {1316    throw new Error("deduplicate disabled in decoy mode");1317  }13181319  const best = new Map<string, VaultEntry>();1320  for (const e of entries.filter((en) => !en.deletedAt)) {1321    const key = entryDupeKey(e);1322    const existing = best.get(key);1323    if (!existing) {1324      best.set(key, e);1325    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1326      best.set(key, e);1327    }1328  }13291330  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1331  const now = Date.now();1332  let removed = 0;1333  entries = entries.map((e) => {1334    if (!e.deletedAt && !keepIds.has(e.id)) {1335      removed++;1336      return { ...e, deletedAt: now };1337    }1338    return e;1339  });13401341  if (removed > 0) await persistEntries();1342  return removed;1343}13441345// ---------------------------------------------------------------------------1346// Vault snapshot system — last 6 encrypted states, used for self-recovery1347// ---------------------------------------------------------------------------13481349const SNAPSHOT_KEY = "peach_vault_snapshots";1350const MAX_SNAPSHOTS = 6;13511352interface VaultSnapshot {1353  timestamp: number;1354  entryCount: number;1355  slot: StoredVault;1356}13571358/** Public summary (no sensitive data) returned to the popup. */1359export interface SnapshotSummary {1360  index: number;1361  timestamp: number;1362  entryCount: number;1363}13641365async function takeSnapshot(1366  slot: StoredVault,1367  entryCount: number1368): Promise<void> {1369  try {1370    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1371    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1372    const next: VaultSnapshot[] = [1373      { timestamp: Date.now(), entryCount, slot },1374      ...existing,1375    ].slice(0, MAX_SNAPSHOTS);1376    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1377  } catch {1378    // Non-fatal — snapshot failure must never interrupt vault saves1379  }1380}13811382export async function listSnapshots(): Promise<SnapshotSummary[]> {1383  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1384  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1385  return snapshots.map((s, i) => ({1386    index: i,1387    timestamp: s.timestamp,1388    entryCount: s.entryCount,1389  }));1390}13911392/**1393 * Restore a snapshot by index. The password must decrypt that snapshot1394 * (i.e., the same master password used when the snapshot was taken).1395 * On success the vault is unlocked in-memory with the restored entries1396 * — no re-login required.1397 */1398export async function restoreFromSnapshot(1399  index: number,1400  password: string1401): Promise<boolean> {1402  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1403  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1404  const snapshot = snapshots[index];1405  if (!snapshot) return false;14061407  // Verify the password decrypts this snapshot1408  const key = await deriveKey(password, unhex(snapshot.slot.salt));1409  const payload = await tryDecryptSlot(snapshot.slot, key);1410  if (!payload) {1411    zeroKey(key);1412    return false;1413  }14141415  // Write the snapshot back to slot A. Regenerate noise for slot B.1416  await writeSlot(SLOT_A, snapshot.slot);1417  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));14181419  // Swap in-memory state to the restored vault — caller gets immediate access1420  if (derivedKey) zeroKey(derivedKey);1421  derivedKey = key;1422  entries = payload.entries;1423  duressConfigured = payload.duressConfigured;1424  vaultMode = "real";1425  buildDomainIndexSync(entries);1426  await loadSettings();1427  touchAutoLock();14281429  return true;1430}14311432/**1433 * Change the master password. Re-encrypts slot A with a fresh salt + new1434 * derived key. Slot B (duress or noise) is left untouched when duress is1435 * configured — it uses an independent key derived from the duress password —1436 * and regenerated as noise when duress is not configured.1437 */1438export async function changePassword(1439  currentPassword: string,1440  newPassword: string1441): Promise<boolean> {1442  if (!derivedKey || vaultMode !== "real") return false;14431444  // Verify current password before allowing any change1445  const valid = await verifyMainPassword(currentPassword);1446  if (!valid) return false;14471448  const slotA = await loadSlot(SLOT_A);1449  if (!slotA) return false;14501451  // Derive fresh key from new password + new random salt1452  const newSalt = generateSalt();1453  const newKey = await deriveKey(newPassword, newSalt);14541455  // Re-encrypt the in-memory entries with the new key1456  const plaintext = encodePayload(entries, duressConfigured);1457  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);14581459  const freshSlotA: StoredVault = {1460    ...slotA,1461    salt: hex(newSalt),1462    nonce,1463    ciphertext,1464    tag,1465  };1466  await writeSlot(SLOT_A, freshSlotA);14671468  // Swap the in-memory key synchronously before any further await. If we1469  // threw between the Slot A write and this swap, the next persistEntries()1470  // would overwrite Slot A with old-key ciphertext, locking the user out.1471  zeroKey(derivedKey);1472  derivedKey = newKey;14731474  // Snapshot now that in-memory key and disk are consistent. Pass the1475  // slot content we just wrote rather than re-reading from storage so the1476  // snapshot is immune to any microtask-coalesced read-after-write race.1477  const activeCount = entries.filter((e) => !e.deletedAt).length;1478  void takeSnapshot(freshSlotA, activeCount);14791480  // Regenerate noise for slot B when duress is not in use. Runs after the1481  // key swap so a failure here is a disposable noise regeneration, not a1482  // lockout.1483  if (!duressConfigured) {1484    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1485  }14861487  return true;1488}