vault.ts

48.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};331332function encodePayload(entries: VaultEntry[], duress: boolean): string {333  const envelope: VaultPayload = { entries, duressConfigured: duress };334  return padPlaintext(JSON.stringify(envelope));335}336337function decodePayload(plain: string): DecryptedPayload {338  const parsed = JSON.parse(plain) as339    | Array<VaultEntry | LegacyVaultEntry>340    | VaultPayload;341342  if (Array.isArray(parsed)) {343    // Pre-A-2 plaintext: bare array of entries, no duress flag.344    return {345      entries: parsed.map(normalizeEntry),346      duressConfigured: false,347    };348  }349350  return {351    entries: (parsed.entries ?? []).map(normalizeEntry),352    duressConfigured: parsed.duressConfigured === true,353  };354}355356async function tryDecryptSlot(357  vault: StoredVault,358  key: Uint8Array359): Promise<DecryptedPayload | null> {360  try {361    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);362    return decodePayload(plain);363  } catch {364    return null;365  }366}367368// ---------------------------------------------------------------------------369// Core operations370// ---------------------------------------------------------------------------371372function buildDomainIndexSync(all: VaultEntry[]): void {373  domainIndex = new Map();374375  for (const entry of all) {376    if (entry.deletedAt) continue;377    if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;378379    // An entry can match multiple sites via `additionalUrls`. Index each380    // hostname so `queryDomain` returns the entry regardless of which alias381    // the user is currently visiting. Dedupe per-entry so a single entry382    // never appears twice in one bucket.383    const domains = new Set<string>();384    const primary = extractDomain(entry.url ?? "");385    if (primary) domains.add(primary);386    for (const extra of entry.additionalUrls ?? []) {387      const d = extractDomain(extra);388      if (d) domains.add(d);389    }390    if (domains.size === 0) continue;391392    for (const domain of domains) {393      const bucket = domainIndex.get(domain) ?? [];394      bucket.push(entry);395      domainIndex.set(domain, bucket);396    }397  }398}399400export async function createVault(password: string): Promise<void> {401  await ensureMigrated();402403  const salt = generateSalt();404  derivedKey = await deriveKey(password, salt);405  entries = [];406  vaultMode = "real";407  duressConfigured = false;408  buildDomainIndexSync(entries);409410  const plaintext = encodePayload(entries, duressConfigured);411  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);412413  const slotA: StoredVault = {414    version: 1,415    salt: hex(salt),416    nonce,417    ciphertext,418    tag,419  };420  await writeSlot(SLOT_A, slotA);421422  // Matching-size noise blob for slot B, so a fresh install shows two423  // similarly-shaped records regardless of whether duress is ever set up.424  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));425426  touchAutoLock();427}428429export async function hasVault(): Promise<boolean> {430  await ensureMigrated();431  return (await loadSlot(SLOT_A)) !== null;432}433434/**435 * Returns whether the real vault has recorded that a duress passphrase is436 * configured. Reliable only after a real-mode unlock — before unlock or in437 * decoy mode, returns false because we cannot read slot A's plaintext.438 */439export async function isDuressEnabled(): Promise<boolean> {440  return vaultMode === "real" && duressConfigured;441}442443export async function hasDecoyVault(): Promise<boolean> {444  return isDuressEnabled();445}446447export async function setupDuress(duressPassword: string): Promise<void> {448  if (!derivedKey || vaultMode !== "real") {449    throw new Error("vault must be unlocked in real mode");450  }451  await ensureMigrated();452453  // Encrypt the decoy payload (empty entries) under the duress key. Pad to454  // at least the current slot A ciphertext size so the two slots look the455  // same at rest at setup time.456  const slotA = await loadSlot(SLOT_A);457  if (!slotA) throw new Error("missing slot A");458459  const decoySalt = generateSalt();460  const decoyKey = await deriveKey(duressPassword, decoySalt);461  try {462    // Pad the decoy plaintext to match slot A's ciphertext length (minus the463    // 16-byte tag) so encrypted sizes align exactly.464    const targetBytes = slotA.ciphertext.length / 2;465    let plaintext = encodePayload([], false);466    const encoded = new TextEncoder().encode(plaintext);467    if (encoded.length < targetBytes) {468      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);469    }470    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);471    await writeSlot(SLOT_B, {472      version: 1,473      salt: hex(decoySalt),474      nonce,475      ciphertext,476      tag,477    });478  } finally {479    zeroKey(decoyKey);480  }481482  // Re-encrypt slot A with the duressConfigured flag set to true. No external483  // flag is written — the bit lives inside the encrypted envelope.484  duressConfigured = true;485  await persistSlotA();486}487488export async function disableDuress(): Promise<void> {489  if (!derivedKey || vaultMode !== "real") {490    throw new Error("vault must be unlocked in real mode");491  }492  await ensureMigrated();493494  // Overwrite slot B with fresh noise that matches slot A's size. The slot495  // remains occupied so its mere existence does not signal that duress was496  // ever configured.497  const slotA = await loadSlot(SLOT_A);498  if (slotA) {499    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));500  }501502  duressConfigured = false;503  await persistSlotA();504}505506export async function verifyMainPassword(password: string): Promise<boolean> {507  await ensureMigrated();508  const slotA = await loadSlot(SLOT_A);509  if (!slotA) return false;510  const key = await deriveKey(password, unhex(slotA.salt));511  try {512    return (await tryDecryptSlot(slotA, key)) !== null;513  } finally {514    zeroKey(key);515  }516}517518export async function unlock(password: string): Promise<boolean> {519  await ensureMigrated();520521  // Load both slots in parallel. Both always exist post-migration; the522  // parallel load and the parallel derivation below keep wall-clock time523  // constant regardless of which slot's passphrase (real or decoy) the524  // caller supplied, so a timing side channel cannot distinguish the525  // duress path from the real-unlock path (DUR-002).526  const [slotA, slotB] = await Promise.all([527    loadSlot(SLOT_A),528    loadSlot(SLOT_B),529  ]);530531  // Derive both keys in parallel, unconditionally.532  const [keyA, keyB] = await Promise.all([533    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),534    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),535  ]);536537  // Attempt both decrypts in parallel.538  const [payloadA, payloadB] = await Promise.all([539    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),540    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),541  ]);542543  if (payloadA && keyA) {544    derivedKey = keyA;545    vaultMode = "real";546    entries = payloadA.entries;547    duressConfigured = payloadA.duressConfigured;548    buildDomainIndexSync(entries);549    await loadSettings();550    touchAutoLock();551    if (keyB) zeroKey(keyB);552    // If the payload came from the pre-envelope (legacy) shape, rewrite553    // it immediately so the next save uses the new format.554    if (hasLegacyShape(payloadA.entries)) {555      await persistSlotA();556    }557    return true;558  }559560  if (payloadB && keyB) {561    derivedKey = keyB;562    vaultMode = "decoy";563    entries = payloadB.entries;564    duressConfigured = false;565    buildDomainIndexSync(entries);566    await loadSettings();567    touchAutoLock();568    if (keyA) zeroKey(keyA);569    return true;570  }571572  if (keyA) zeroKey(keyA);573  if (keyB) zeroKey(keyB);574  return false;575}576577export function lock(): void {578  if (derivedKey) {579    zeroKey(derivedKey);580    derivedKey = null;581  }582583  entries = [];584  domainIndex = new Map();585  vaultMode = "real";586  duressConfigured = false;587  clearAutoLock();588}589590export function isUnlocked(): boolean {591  return derivedKey !== null;592}593594export function getEntries(): VaultEntry[] {595  if (!derivedKey) throw new Error("vault locked");596  return entries.filter((entry) => !entry.deletedAt);597}598599const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;600601export function getIdentities(): SavedIdentity[] {602  if (!derivedKey) throw new Error("vault locked");603  const active = entries.filter((e) => !e.deletedAt && e.type === "login");604605  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();606  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();607608  for (const entry of active) {609    const value = entry.username?.trim();610    if (!value) continue;611    const map = EMAIL_RE.test(value) ? emailMap : usernameMap;612    const existing = map.get(value);613    const domain = extractDomain(entry.url ?? "") ?? "";614    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;615    if (!existing || modifiedAt > existing.lastUsed) {616      map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });617    } else {618      map.set(value, { ...existing, count: existing.count + 1 });619    }620  }621622  const toIdentity = (type: "email" | "username") =>623    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({624      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,625    });626627  return [628    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),629    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),630  ];631}632633export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {634  if (!derivedKey) throw new Error("vault locked");635636  const now = Date.now();637  const added: VaultEntry[] = [];638  for (const draft of drafts) {639    try {640      const sanitized = sanitizeEntryDraft(draft);641      const nextEntry: VaultEntry = {642        ...sanitized,643        id: crypto.randomUUID(),644        createdAt: now,645        modifiedAt: now,646      };647      entries.push(nextEntry);648      added.push(nextEntry);649    } catch {650      // Skip invalid drafts silently — caller can see diff between input and output length651    }652  }653654  if (added.length > 0) {655    await persistEntries();656  }657658  return added;659}660661/**662 * Restore a set of entries in full fidelity — preserves id, createdAt,663 * modifiedAt, and every other field unlike `batchAddEntries` which664 * assigns fresh identity. Used by restore paths where the backup is the665 * authoritative source of truth. `mode` controls how entries already in666 * the vault are treated.667 */668export async function restoreEntries(669  incoming: VaultEntry[],670  mode: "replace" | "merge"671): Promise<{ added: number; updated: number; skipped: number }> {672  if (!derivedKey) throw new Error("vault locked");673674  // A replace with zero incoming entries is almost certainly a caller bug675  // (e.g. an import that silently dropped everything). Refuse rather than676  // wipe the vault.677  if (mode === "replace" && incoming.length === 0) {678    throw new Error("refuse to replace with empty entry set");679  }680681  let added = 0;682  let updated = 0;683  let skipped = 0;684685  if (mode === "replace") {686    entries = [];687    for (const entry of incoming) {688      const normalized = sanitizeRestoredEntry(entry);689      if (!normalized) {690        skipped += 1;691        continue;692      }693      entries.push(normalized);694      added += 1;695    }696  } else {697    const byId = new Map<string, VaultEntry>();698    for (const e of entries) byId.set(e.id, e);699700    for (const entry of incoming) {701      const normalized = sanitizeRestoredEntry(entry);702      if (!normalized) {703        skipped += 1;704        continue;705      }706      const existing = byId.get(normalized.id);707      if (!existing) {708        entries.push(normalized);709        added += 1;710      } else if (normalized.modifiedAt > existing.modifiedAt) {711        const idx = entries.findIndex((e) => e.id === existing.id);712        if (idx !== -1) {713          entries[idx] = normalized;714          updated += 1;715        }716      } else {717        skipped += 1;718      }719    }720  }721722  await persistEntries();723  return { added, updated, skipped };724}725726/**727 * Validate + normalise a VaultEntry coming from a backup. Accepts728 * anything structurally sound; keeps all metadata verbatim. Returns729 * null for values the live vault shouldn't accept (e.g., missing title).730 */731function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {732  if (!entry || typeof entry !== "object") return null;733  const title = typeof entry.title === "string" ? entry.title.trim() : "";734  if (!title) return null;735  const type = entry.type ?? "login";736  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();737  const now = Date.now();738  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;739  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;740741  if (type === "login" && typeof entry.password !== "string") {742    return null;743  }744  if (745    type === "passkey" &&746    (typeof entry.rpId !== "string" ||747      typeof entry.credentialId !== "string" ||748      !entry.privateKeyJwk)749  ) {750    return null;751  }752753  return {754    ...entry,755    id,756    title,757    type,758    createdAt,759    modifiedAt,760  };761}762763// Soft-deletes every non-deleted entry in one call. The literal string764// argument is a tripwire against accidental calls — any caller that would765// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this766// to a boolean.767export async function clearVaultEntries(768  confirmation: "REPLACE_ALL"769): Promise<void> {770  if (confirmation !== "REPLACE_ALL") {771    throw new Error("clearVaultEntries requires explicit confirmation");772  }773  if (!derivedKey) throw new Error("vault locked");774  for (const entry of entries) {775    if (!entry.deletedAt) {776      entry.deletedAt = Date.now();777      entry.modifiedAt = Date.now();778    }779  }780  await persistEntries();781}782783export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {784  if (!derivedKey) throw new Error("vault locked");785786  const sanitized = sanitizeEntryDraft(entry);787  const now = Date.now();788  const nextEntry: VaultEntry = {789    ...sanitized,790    id: crypto.randomUUID(),791    createdAt: now,792    modifiedAt: now,793    // Stamp passwordChangedAt at creation so age tracking is accurate from794    // day one. Non-login entries carry the same stamp harmlessly.795    passwordChangedAt: sanitized.password ? now : undefined,796  };797798  entries.push(nextEntry);799  await persistEntries();800801  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)802  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {803    checkBreach(nextEntry.password).then(async (count) => {804      if (count > 0) {805        const idx = entries.findIndex((e) => e.id === nextEntry.id);806        if (idx !== -1) {807          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };808          await persistEntries();809        }810      }811    });812  }813814  return nextEntry;815}816817export async function updateEntry(818  id: string,819  patch: Partial<VaultEntry>820): Promise<void> {821  if (!derivedKey) throw new Error("vault locked");822823  const idx = entries.findIndex((entry) => entry.id === id);824  if (idx === -1 || entries[idx].deletedAt) {825    throw new Error("entry not found");826  }827828  const current = entries[idx];829  const sanitizedPatch = sanitizeEntryPatch(patch);830  // Refresh passwordChangedAt only when the password value actually changes —831  // a metadata-only update (title, tags, etc.) must not reset the age clock832  // that drives the Pro health rotation recommendation.833  const passwordChanged =834    "password" in patch &&835    typeof sanitizedPatch.password === "string" &&836    sanitizedPatch.password !== current.password;837  entries[idx] = {838    ...current,839    ...sanitizedPatch,840    modifiedAt: Date.now(),841    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),842  };843844  // sanitizeEntryPatch maps an empty password to `undefined`. For login845  // entries that would silently produce a passwordless record — roll back846  // the merge and refuse.847  if (entries[idx].type === "login" && !entries[idx].password) {848    entries[idx] = current;849    throw new Error("password is required for login entries");850  }851852  // Card entries must retain their card number853  if (entries[idx].type === "card" && !entries[idx].cardNumber) {854    entries[idx] = current;855    throw new Error("card number is required for card entries");856  }857858  await persistEntries();859860  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)861  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {862    const password = entries[idx].password!;863    checkBreach(password).then(async (count) => {864      const i = entries.findIndex((e) => e.id === id);865      if (i !== -1) {866        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };867        await persistEntries();868      }869    });870  }871}872873export async function deleteEntry(id: string): Promise<void> {874  if (!derivedKey) throw new Error("vault locked");875876  const entry = entries.find((item) => item.id === id);877  if (!entry || entry.deletedAt) {878    throw new Error("entry not found");879  }880881  entry.deletedAt = Date.now();882  entry.modifiedAt = Date.now();883  await persistEntries();884}885886export function getDeletedEntries(): VaultEntry[] {887  if (!derivedKey) return [];888  return entries.filter((e) => !!e.deletedAt);889}890891export async function restoreEntry(id: string): Promise<void> {892  if (!derivedKey) throw new Error("vault locked");893  const entry = entries.find((e) => e.id === id && e.deletedAt);894  if (!entry) throw new Error("deleted entry not found");895  delete entry.deletedAt;896  entry.modifiedAt = Date.now();897  await persistEntries();898  buildDomainIndexSync(entries);899}900901export async function purgeEntry(id: string): Promise<void> {902  if (!derivedKey) throw new Error("vault locked");903  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);904  if (idx === -1) throw new Error("deleted entry not found");905  entries.splice(idx, 1);906  await persistEntries();907}908909/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */910export async function purgeExpiredTrash(retentionMs: number): Promise<number> {911  if (!derivedKey || retentionMs === 0) return 0;912  const cutoff = Date.now() - retentionMs;913  const before = entries.length;914  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));915  const purged = before - entries.length;916  if (purged > 0) await persistEntries();917  return purged;918}919920export function queryDomain(domain: string): VaultEntry[] {921  return domainIndex.get(domain) ?? [];922}923924export function getEntryById(id: string): VaultEntry | undefined {925  return entries.find((entry) => entry.id === id && !entry.deletedAt);926}927928async function persistSlotA(): Promise<StoredVault> {929  if (!derivedKey) throw new Error("vault locked");930931  const slotA = await loadSlot(SLOT_A);932  if (!slotA) throw new Error("missing slot A");933934  const plaintext = encodePayload(entries, duressConfigured);935  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);936937  const nextSlotA: StoredVault = {938    ...slotA,939    nonce,940    ciphertext,941    tag,942  };943  await writeSlot(SLOT_A, nextSlotA);944945  // When duress is not configured, keep slot B size-matched with slot A by946  // regenerating its noise. When duress IS configured, leave slot B alone —947  // we don't have the decoy key to re-encrypt it. This is a known trade-off.948  if (!duressConfigured) {949    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));950  }951952  return nextSlotA;953}954955async function persistSlotB(): Promise<void> {956  if (!derivedKey) throw new Error("vault locked");957958  const slotB = await loadSlot(SLOT_B);959  if (!slotB) throw new Error("missing slot B");960961  // Decoy plaintext always reports duressConfigured=false — the decoy user962  // sees a clean slate.963  const plaintext = encodePayload(entries, false);964  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);965966  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });967}968969async function persistEntries(): Promise<void> {970  if (!derivedKey) throw new Error("vault locked");971972  if (vaultMode === "decoy") {973    await persistSlotB();974  } else {975    const freshSlotA = await persistSlotA();976    // Save a snapshot of the real vault after every successful write.977    // Failures are silently swallowed — snapshots must never interrupt saves.978    const activeCount = entries.filter((e) => !e.deletedAt).length;979    void takeSnapshot(freshSlotA, activeCount);980  }981  buildDomainIndexSync(entries);982}983984function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {985  if (!Array.isArray(values)) return undefined;986  const seen = new Set<string>();987  const cleaned: string[] = [];988  for (const value of values) {989    const trimmed = typeof value === "string" ? value.trim() : "";990    if (!trimmed) continue;991    const key = trimmed.toLowerCase();992    if (seen.has(key)) continue;993    seen.add(key);994    cleaned.push(trimmed);995  }996  return cleaned.length > 0 ? cleaned : undefined;997}998999function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1000  const title = entry.title.trim();1001  const type = entry.type ?? "login";10021003  if (!title) throw new Error("title is required");1004  if (type === "login" && !entry.password?.trim()) {1005    throw new Error("password is required");1006  }1007  if (type === "passkey") {1008    if (1009      !entry.rpId?.trim() ||1010      !entry.rpName?.trim() ||1011      !entry.credentialId?.trim() ||1012      !entry.publicKeyCose?.trim() ||1013      !entry.privateKeyJwk ||1014      !entry.userHandle?.trim() ||1015      !entry.userName?.trim() ||1016      !entry.userDisplayName?.trim() ||1017      (entry.algorithm !== -7 && entry.algorithm !== -8)1018    ) {1019      throw new Error("invalid passkey entry");1020    }1021  }1022  if (type === "card") {1023    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1024      throw new Error("cardholder name and card number are required");1025    }1026  }10271028  return {1029    type,1030    title,1031    username: type === "login" ? entry.username?.trim() ?? "" : undefined,1032    password: type === "login" ? entry.password : undefined,1033    url: type === "login" ? entry.url?.trim() ?? "" : undefined,1034    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1035    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1036    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,1037    breach: type === "login" ? entry.breach : undefined,1038    canary: type === "login" ? entry.canary : undefined,1039    tags: entry.tags,1040    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,1041    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,1042    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,1043    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,1044    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,1045    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,1046    userName: type === "passkey" ? entry.userName?.trim() : undefined,1047    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,1048    algorithm: type === "passkey" ? entry.algorithm : undefined,1049    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,1050    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,1051    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,1052    backupState: type === "passkey" ? entry.backupState === true : undefined,1053    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,1054    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1055    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1056    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1057    expYear: type === "card" ? entry.expYear?.trim() : undefined,1058    cvc: type === "card" ? entry.cvc?.trim() : undefined,1059    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1060    network: type === "card" ? entry.network : undefined,1061    last4: type === "card" ? entry.last4?.trim() : undefined,1062  };1063}10641065function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1066  const nextPatch: Partial<VaultEntry> = {};10671068  if ("type" in patch) {1069    nextPatch.type = patch.type;1070  }10711072  if ("title" in patch) {1073    const title = patch.title?.trim() ?? "";1074    if (!title) throw new Error("title is required");1075    nextPatch.title = title;1076  }10771078  if ("url" in patch) {1079    nextPatch.url = patch.url?.trim() ?? "";1080  }10811082  if ("additionalUrls" in patch) {1083    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1084    const seen = new Set<string>();1085    const cleaned: string[] = [];1086    for (const value of incoming) {1087      const trimmed = typeof value === "string" ? value.trim() : "";1088      if (!trimmed) continue;1089      const key = trimmed.toLowerCase();1090      if (seen.has(key)) continue;1091      seen.add(key);1092      cleaned.push(trimmed);1093    }1094    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1095  }10961097  if ("username" in patch) {1098    nextPatch.username = patch.username?.trim() ?? "";1099  }11001101  if ("password" in patch) {1102    const password = patch.password ?? "";1103    // Allow clearing a password (e.g. on passkey-only entries)1104    nextPatch.password = password || undefined;1105  }11061107  if ("totp" in patch) {1108    nextPatch.totp = patch.totp?.trim() || undefined;1109  }11101111  if ("notes" in patch) {1112    nextPatch.notes = patch.notes?.trim() || undefined;1113  }11141115  if ("breach" in patch) {1116    nextPatch.breach = patch.breach;1117  }11181119  if ("canary" in patch) {1120    nextPatch.canary = patch.canary;1121  }11221123  if ("rpId" in patch) {1124    nextPatch.rpId = patch.rpId?.trim();1125  }11261127  if ("rpName" in patch) {1128    nextPatch.rpName = patch.rpName?.trim();1129  }11301131  if ("credentialId" in patch) {1132    nextPatch.credentialId = patch.credentialId?.trim();1133  }11341135  if ("publicKeyCose" in patch) {1136    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1137  }11381139  if ("privateKeyJwk" in patch) {1140    nextPatch.privateKeyJwk = patch.privateKeyJwk;1141  }11421143  if ("userHandle" in patch) {1144    nextPatch.userHandle = patch.userHandle?.trim();1145  }11461147  if ("userName" in patch) {1148    nextPatch.userName = patch.userName?.trim();1149  }11501151  if ("userDisplayName" in patch) {1152    nextPatch.userDisplayName = patch.userDisplayName?.trim();1153  }11541155  if ("algorithm" in patch) {1156    nextPatch.algorithm = patch.algorithm;1157  }11581159  if ("signCount" in patch) {1160    nextPatch.signCount = patch.signCount;1161  }11621163  if ("aaguid" in patch) {1164    nextPatch.aaguid = patch.aaguid?.trim();1165  }11661167  if ("backupEligible" in patch) {1168    nextPatch.backupEligible = patch.backupEligible;1169  }11701171  if ("backupState" in patch) {1172    nextPatch.backupState = patch.backupState;1173  }11741175  if ("lastUsedAt" in patch) {1176    nextPatch.lastUsedAt = patch.lastUsedAt;1177  }11781179  if ("tags" in patch) {1180    nextPatch.tags = patch.tags;1181  }11821183  if ("cardholderName" in patch) {1184    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1185  }1186  if ("cardNumber" in patch) {1187    const n = patch.cardNumber ?? "";1188    nextPatch.cardNumber = n || undefined;1189  }1190  if ("expMonth" in patch) {1191    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1192  }1193  if ("expYear" in patch) {1194    nextPatch.expYear = patch.expYear?.trim() || undefined;1195  }1196  if ("cvc" in patch) {1197    nextPatch.cvc = patch.cvc?.trim() || undefined;1198  }1199  if ("cardNickname" in patch) {1200    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1201  }1202  if ("network" in patch) {1203    nextPatch.network = patch.network;1204  }1205  if ("last4" in patch) {1206    nextPatch.last4 = patch.last4?.trim() || undefined;1207  }12081209  return nextPatch;1210}12111212function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1213  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1214  const modifiedAt =1215    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12161217  return {1218    id: entry.id,1219    type: "type" in entry ? entry.type : "login",1220    title: entry.title,1221    username: entry.username,1222    password: entry.password,1223    url: entry.url,1224    totp: entry.totp,1225    notes: entry.notes,1226    breach: entry.breach,1227    canary: "canary" in entry ? entry.canary : undefined,1228    rpId: "rpId" in entry ? entry.rpId : undefined,1229    rpName: "rpName" in entry ? entry.rpName : undefined,1230    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1231    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1232    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1233    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1234    userName: "userName" in entry ? entry.userName : undefined,1235    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1236    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1237    signCount: "signCount" in entry ? entry.signCount : undefined,1238    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1239    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1240    backupState: "backupState" in entry ? entry.backupState : undefined,1241    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1242    createdAt,1243    modifiedAt,1244    deletedAt: entry.deletedAt,1245    tags: "tags" in entry ? entry.tags : undefined,1246    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1247    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1248    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1249    expYear: "expYear" in entry ? entry.expYear : undefined,1250    cvc: "cvc" in entry ? entry.cvc : undefined,1251    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1252    network: "network" in entry ? entry.network : undefined,1253    last4: "last4" in entry ? entry.last4 : undefined,1254  };1255}12561257function hasLegacyShape(parsed: VaultEntry[]): boolean {1258  return parsed.some(1259    (entry) => "created" in entry || "updated" in entry1260  );1261}12621263// ---------------------------------------------------------------------------1264// Deduplication1265// ---------------------------------------------------------------------------12661267function entryDupeKey(e: VaultEntry): string {1268  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1269  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1270  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1271}12721273/** Returns the number of duplicate entries (those that would be removed). */1274export function countDuplicates(): number {1275  const best = new Map<string, VaultEntry>();1276  let dupeCount = 0;1277  for (const e of entries.filter((e) => !e.deletedAt)) {1278    const key = entryDupeKey(e);1279    const existing = best.get(key);1280    if (!existing) {1281      best.set(key, e);1282    } else {1283      dupeCount++;1284      // Keep the more recently modified one in the map1285      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1286        best.set(key, e);1287      }1288    }1289  }1290  return dupeCount;1291}12921293/**1294 * Soft-deletes duplicate entries, keeping the most recently modified copy of1295 * each group. Returns the number of entries moved to trash.1296 */1297export async function deduplicateEntries(): Promise<number> {1298  if (!derivedKey) throw new Error("vault locked");1299  // Dedup reshapes slot B permanently under the decoy key, which is1300  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1301  if (vaultMode === "decoy") {1302    throw new Error("deduplicate disabled in decoy mode");1303  }13041305  const best = new Map<string, VaultEntry>();1306  for (const e of entries.filter((en) => !en.deletedAt)) {1307    const key = entryDupeKey(e);1308    const existing = best.get(key);1309    if (!existing) {1310      best.set(key, e);1311    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1312      best.set(key, e);1313    }1314  }13151316  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1317  const now = Date.now();1318  let removed = 0;1319  entries = entries.map((e) => {1320    if (!e.deletedAt && !keepIds.has(e.id)) {1321      removed++;1322      return { ...e, deletedAt: now };1323    }1324    return e;1325  });13261327  if (removed > 0) await persistEntries();1328  return removed;1329}13301331// ---------------------------------------------------------------------------1332// Vault snapshot system — last 6 encrypted states, used for self-recovery1333// ---------------------------------------------------------------------------13341335const SNAPSHOT_KEY = "peach_vault_snapshots";1336const MAX_SNAPSHOTS = 6;13371338interface VaultSnapshot {1339  timestamp: number;1340  entryCount: number;1341  slot: StoredVault;1342}13431344/** Public summary (no sensitive data) returned to the popup. */1345export interface SnapshotSummary {1346  index: number;1347  timestamp: number;1348  entryCount: number;1349}13501351async function takeSnapshot(1352  slot: StoredVault,1353  entryCount: number1354): Promise<void> {1355  try {1356    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1357    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1358    const next: VaultSnapshot[] = [1359      { timestamp: Date.now(), entryCount, slot },1360      ...existing,1361    ].slice(0, MAX_SNAPSHOTS);1362    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1363  } catch {1364    // Non-fatal — snapshot failure must never interrupt vault saves1365  }1366}13671368export async function listSnapshots(): Promise<SnapshotSummary[]> {1369  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1370  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1371  return snapshots.map((s, i) => ({1372    index: i,1373    timestamp: s.timestamp,1374    entryCount: s.entryCount,1375  }));1376}13771378/**1379 * Restore a snapshot by index. The password must decrypt that snapshot1380 * (i.e., the same master password used when the snapshot was taken).1381 * On success the vault is unlocked in-memory with the restored entries1382 * — no re-login required.1383 */1384export async function restoreFromSnapshot(1385  index: number,1386  password: string1387): Promise<boolean> {1388  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1389  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1390  const snapshot = snapshots[index];1391  if (!snapshot) return false;13921393  // Verify the password decrypts this snapshot1394  const key = await deriveKey(password, unhex(snapshot.slot.salt));1395  const payload = await tryDecryptSlot(snapshot.slot, key);1396  if (!payload) {1397    zeroKey(key);1398    return false;1399  }14001401  // Write the snapshot back to slot A. Regenerate noise for slot B.1402  await writeSlot(SLOT_A, snapshot.slot);1403  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));14041405  // Swap in-memory state to the restored vault — caller gets immediate access1406  if (derivedKey) zeroKey(derivedKey);1407  derivedKey = key;1408  entries = payload.entries;1409  duressConfigured = payload.duressConfigured;1410  vaultMode = "real";1411  buildDomainIndexSync(entries);1412  await loadSettings();1413  touchAutoLock();14141415  return true;1416}14171418/**1419 * Change the master password. Re-encrypts slot A with a fresh salt + new1420 * derived key. Slot B (duress or noise) is left untouched when duress is1421 * configured — it uses an independent key derived from the duress password —1422 * and regenerated as noise when duress is not configured.1423 */1424export async function changePassword(1425  currentPassword: string,1426  newPassword: string1427): Promise<boolean> {1428  if (!derivedKey || vaultMode !== "real") return false;14291430  // Verify current password before allowing any change1431  const valid = await verifyMainPassword(currentPassword);1432  if (!valid) return false;14331434  const slotA = await loadSlot(SLOT_A);1435  if (!slotA) return false;14361437  // Derive fresh key from new password + new random salt1438  const newSalt = generateSalt();1439  const newKey = await deriveKey(newPassword, newSalt);14401441  // Re-encrypt the in-memory entries with the new key1442  const plaintext = encodePayload(entries, duressConfigured);1443  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);14441445  const freshSlotA: StoredVault = {1446    ...slotA,1447    salt: hex(newSalt),1448    nonce,1449    ciphertext,1450    tag,1451  };1452  await writeSlot(SLOT_A, freshSlotA);14531454  // Swap the in-memory key synchronously before any further await. If we1455  // threw between the Slot A write and this swap, the next persistEntries()1456  // would overwrite Slot A with old-key ciphertext, locking the user out.1457  zeroKey(derivedKey);1458  derivedKey = newKey;14591460  // Snapshot now that in-memory key and disk are consistent. Pass the1461  // slot content we just wrote rather than re-reading from storage so the1462  // snapshot is immune to any microtask-coalesced read-after-write race.1463  const activeCount = entries.filter((e) => !e.deletedAt).length;1464  void takeSnapshot(freshSlotA, activeCount);14651466  // Regenerate noise for slot B when duress is not in use. Runs after the1467  // key swap so a failure here is a disposable noise regeneration, not a1468  // lockout.1469  if (!duressConfigured) {1470    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1471  }14721473  return true;1474}