vault.ts

47.0 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 interface VaultSettings {62  accentColor?: AccentColorName;63  theme?: "dark" | "light";64  autoLockMinutes?: number;65  clipboardClearSeconds?: number;66  lockOnClose?: boolean;67  breachMonitoring?: boolean;68  passkeyProviderEnabled?: boolean;69  trashRetentionDays?: number;70  manualSummonDomains?: Record<string, ManualSummonRecord>;71  declinedCardFingerprints?: string[];72  /**73   * User overrides for keyboard shortcuts. Keys are ShortcutAction names74   * (see src/shared/shortcuts.ts). Missing actions fall through to75   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the76   * canonical lookup. Storing only the deltas keeps the vault payload77   * small and lets default updates land for users who never rebind.78   */79  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;80}8182let settings: VaultSettings = {};8384export function getSettings(): VaultSettings {85  return settings;86}8788export async function ensureSettingsLoaded(): Promise<VaultSettings> {89  await loadSettings();90  return settings;91}9293export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {94  await loadSettings();95  settings = { ...settings, ...patch };96  await persistSettings();97}9899async function persistSettings(): Promise<void> {100  try {101    await chrome.storage.local.set({ peach_settings: settings });102  } catch (err) {103    throw mapStorageError(err);104  }105}106107function mapStorageError(err: unknown): Error {108  const message = err instanceof Error ? err.message : String(err);109  if (/QUOTA_BYTES|quota/i.test(message)) {110    return new Error("STORAGE_QUOTA_EXCEEDED");111  }112  return err instanceof Error ? err : new Error(message);113}114115async function loadSettings(): Promise<void> {116  const result = await chrome.storage.local.get("peach_settings");117  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};118}119120// ---------------------------------------------------------------------------121// HIBP breach check (k-anonymity model)122// ---------------------------------------------------------------------------123124export async function checkBreach(password: string): Promise<number> {125  const encoder = new TextEncoder();126  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));127  const hashHex = Array.from(new Uint8Array(hashBuffer))128    .map((b) => b.toString(16).padStart(2, "0"))129    .join("")130    .toUpperCase();131132  const prefix = hashHex.slice(0, 5);133  const suffix = hashHex.slice(5);134135  try {136    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);137    if (!res.ok) return 0;138    const text = await res.text();139    const match = text.split("\r\n").find((l) => l.startsWith(suffix));140    return match ? parseInt(match.split(":")[1], 10) : 0;141  } catch {142    return 0; // Network unavailable — don't block143  }144}145146export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {147  const fingerprint = await cardFingerprint(last4, expMonth, expYear);148  await loadSettings();149  settings = {150    ...settings,151    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],152  };153  await persistSettings();154}155156export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {157  await loadSettings();158  const fingerprint = await cardFingerprint(last4, expMonth, expYear);159  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);160}161162async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {163  const encoder = new TextEncoder();164  const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));165  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");166}167168function hex(buf: Uint8Array): string {169  return Array.from(buf)170    .map((b) => b.toString(16).padStart(2, "0"))171    .join("");172}173174function unhex(s: string): Uint8Array {175  const out = new Uint8Array(s.length / 2);176  for (let i = 0; i < s.length; i += 2) {177    out[i / 2] = parseInt(s.slice(i, i + 2), 16);178  }179  return out;180}181182// ---------------------------------------------------------------------------183// Storage schema migration184// ---------------------------------------------------------------------------185186async function loadSlot(key: string): Promise<StoredVault | null> {187  const result = await chrome.storage.local.get(key);188  return (result as Record<string, StoredVault | undefined>)[key] ?? null;189}190191async function writeSlot(key: string, vault: StoredVault): Promise<void> {192  try {193    await chrome.storage.local.set({ [key]: vault });194  } catch (err) {195    throw mapStorageError(err);196  }197}198199/**200 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault201 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +202 * peach_vault_b). Runs at most once per session; safe to call repeatedly.203 */204async function ensureMigrated(): Promise<void> {205  if (migrationRan) return;206  migrationRan = true;207208  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];209  const raw = await chrome.storage.local.get(keys);210  const slotAExists = raw[SLOT_A] !== undefined;211  const slotBExists = raw[SLOT_B] !== undefined;212  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;213  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;214215  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.216  if (!slotAExists && !legacyMain) return;217218  // Already migrated.219  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;220221  if (legacyMain && !slotAExists) {222    await writeSlot(SLOT_A, legacyMain);223  }224225  // If slot B isn't set, populate it: use legacy decoy when present, else noise.226  if (!slotBExists) {227    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;228    if (legacyDecoy) {229      await writeSlot(SLOT_B, legacyDecoy);230    } else if (target) {231      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));232    }233  }234235  const toRemove: string[] = [];236  if (legacyMain) toRemove.push(LEGACY_MAIN);237  if (legacyDecoy) toRemove.push(LEGACY_DECOY);238  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);239  if (toRemove.length > 0) {240    await chrome.storage.local.remove(toRemove);241  }242}243244// ---------------------------------------------------------------------------245// Padding + noise helpers246// ---------------------------------------------------------------------------247248/**249 * Pads the plaintext string with trailing spaces so that the encoded byte250 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing251 * whitespace after a complete document, so the padding is transparent on252 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.253 */254function padPlaintext(json: string): string {255  const encoded = new TextEncoder().encode(json);256  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);257  const padLen = target - encoded.length;258  return json + " ".repeat(padLen);259}260261/**262 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The263 * ciphertext length matches the given byte count. Because GCM ciphertext is264 * pseudo-random, this blob is indistinguishable at rest from a real265 * encrypted vault without the key.266 */267function generateNoiseBlob(ciphertextBytes: number): StoredVault {268  const salt = crypto.getRandomValues(new Uint8Array(16));269  const nonce = crypto.getRandomValues(new Uint8Array(12));270  const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));271  const tag = crypto.getRandomValues(new Uint8Array(16));272  return {273    version: 1,274    salt: hex(salt),275    nonce: hex(nonce),276    ciphertext: hex(cipher),277    tag: hex(tag),278  };279}280281// ---------------------------------------------------------------------------282// Encrypted plaintext envelope283// ---------------------------------------------------------------------------284285type VaultPayload = {286  entries: VaultEntry[];287  duressConfigured?: boolean;288};289290type DecryptedPayload = {291  entries: VaultEntry[];292  duressConfigured: boolean;293};294295function encodePayload(entries: VaultEntry[], duress: boolean): string {296  const envelope: VaultPayload = { entries, duressConfigured: duress };297  return padPlaintext(JSON.stringify(envelope));298}299300function decodePayload(plain: string): DecryptedPayload {301  const parsed = JSON.parse(plain) as302    | Array<VaultEntry | LegacyVaultEntry>303    | VaultPayload;304305  if (Array.isArray(parsed)) {306    // Pre-A-2 plaintext: bare array of entries, no duress flag.307    return {308      entries: parsed.map(normalizeEntry),309      duressConfigured: false,310    };311  }312313  return {314    entries: (parsed.entries ?? []).map(normalizeEntry),315    duressConfigured: parsed.duressConfigured === true,316  };317}318319async function tryDecryptSlot(320  vault: StoredVault,321  key: Uint8Array322): Promise<DecryptedPayload | null> {323  try {324    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);325    return decodePayload(plain);326  } catch {327    return null;328  }329}330331// ---------------------------------------------------------------------------332// Core operations333// ---------------------------------------------------------------------------334335function buildDomainIndexSync(all: VaultEntry[]): void {336  domainIndex = new Map();337338  for (const entry of all) {339    if (entry.deletedAt) continue;340    if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;341342    // An entry can match multiple sites via `additionalUrls`. Index each343    // hostname so `queryDomain` returns the entry regardless of which alias344    // the user is currently visiting. Dedupe per-entry so a single entry345    // never appears twice in one bucket.346    const domains = new Set<string>();347    const primary = extractDomain(entry.url ?? "");348    if (primary) domains.add(primary);349    for (const extra of entry.additionalUrls ?? []) {350      const d = extractDomain(extra);351      if (d) domains.add(d);352    }353    if (domains.size === 0) continue;354355    for (const domain of domains) {356      const bucket = domainIndex.get(domain) ?? [];357      bucket.push(entry);358      domainIndex.set(domain, bucket);359    }360  }361}362363export async function createVault(password: string): Promise<void> {364  await ensureMigrated();365366  const salt = generateSalt();367  derivedKey = await deriveKey(password, salt);368  entries = [];369  vaultMode = "real";370  duressConfigured = false;371  buildDomainIndexSync(entries);372373  const plaintext = encodePayload(entries, duressConfigured);374  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);375376  const slotA: StoredVault = {377    version: 1,378    salt: hex(salt),379    nonce,380    ciphertext,381    tag,382  };383  await writeSlot(SLOT_A, slotA);384385  // Matching-size noise blob for slot B, so a fresh install shows two386  // similarly-shaped records regardless of whether duress is ever set up.387  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));388389  touchAutoLock();390}391392export async function hasVault(): Promise<boolean> {393  await ensureMigrated();394  return (await loadSlot(SLOT_A)) !== null;395}396397/**398 * Returns whether the real vault has recorded that a duress passphrase is399 * configured. Reliable only after a real-mode unlock — before unlock or in400 * decoy mode, returns false because we cannot read slot A's plaintext.401 */402export async function isDuressEnabled(): Promise<boolean> {403  return vaultMode === "real" && duressConfigured;404}405406export async function hasDecoyVault(): Promise<boolean> {407  return isDuressEnabled();408}409410export async function setupDuress(duressPassword: string): Promise<void> {411  if (!derivedKey || vaultMode !== "real") {412    throw new Error("vault must be unlocked in real mode");413  }414  await ensureMigrated();415416  // Encrypt the decoy payload (empty entries) under the duress key. Pad to417  // at least the current slot A ciphertext size so the two slots look the418  // same at rest at setup time.419  const slotA = await loadSlot(SLOT_A);420  if (!slotA) throw new Error("missing slot A");421422  const decoySalt = generateSalt();423  const decoyKey = await deriveKey(duressPassword, decoySalt);424  try {425    // Pad the decoy plaintext to match slot A's ciphertext length (minus the426    // 16-byte tag) so encrypted sizes align exactly.427    const targetBytes = slotA.ciphertext.length / 2;428    let plaintext = encodePayload([], false);429    const encoded = new TextEncoder().encode(plaintext);430    if (encoded.length < targetBytes) {431      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);432    }433    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);434    await writeSlot(SLOT_B, {435      version: 1,436      salt: hex(decoySalt),437      nonce,438      ciphertext,439      tag,440    });441  } finally {442    zeroKey(decoyKey);443  }444445  // Re-encrypt slot A with the duressConfigured flag set to true. No external446  // flag is written — the bit lives inside the encrypted envelope.447  duressConfigured = true;448  await persistSlotA();449}450451export async function disableDuress(): Promise<void> {452  if (!derivedKey || vaultMode !== "real") {453    throw new Error("vault must be unlocked in real mode");454  }455  await ensureMigrated();456457  // Overwrite slot B with fresh noise that matches slot A's size. The slot458  // remains occupied so its mere existence does not signal that duress was459  // ever configured.460  const slotA = await loadSlot(SLOT_A);461  if (slotA) {462    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));463  }464465  duressConfigured = false;466  await persistSlotA();467}468469export async function verifyMainPassword(password: string): Promise<boolean> {470  await ensureMigrated();471  const slotA = await loadSlot(SLOT_A);472  if (!slotA) return false;473  const key = await deriveKey(password, unhex(slotA.salt));474  try {475    return (await tryDecryptSlot(slotA, key)) !== null;476  } finally {477    zeroKey(key);478  }479}480481export async function unlock(password: string): Promise<boolean> {482  await ensureMigrated();483484  // Load both slots in parallel. Both always exist post-migration; the485  // parallel load and the parallel derivation below keep wall-clock time486  // constant regardless of which slot's passphrase (real or decoy) the487  // caller supplied, so a timing side channel cannot distinguish the488  // duress path from the real-unlock path (DUR-002).489  const [slotA, slotB] = await Promise.all([490    loadSlot(SLOT_A),491    loadSlot(SLOT_B),492  ]);493494  // Derive both keys in parallel, unconditionally.495  const [keyA, keyB] = await Promise.all([496    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),497    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),498  ]);499500  // Attempt both decrypts in parallel.501  const [payloadA, payloadB] = await Promise.all([502    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),503    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),504  ]);505506  if (payloadA && keyA) {507    derivedKey = keyA;508    vaultMode = "real";509    entries = payloadA.entries;510    duressConfigured = payloadA.duressConfigured;511    buildDomainIndexSync(entries);512    await loadSettings();513    touchAutoLock();514    if (keyB) zeroKey(keyB);515    // If the payload came from the pre-envelope (legacy) shape, rewrite516    // it immediately so the next save uses the new format.517    if (hasLegacyShape(payloadA.entries)) {518      await persistSlotA();519    }520    return true;521  }522523  if (payloadB && keyB) {524    derivedKey = keyB;525    vaultMode = "decoy";526    entries = payloadB.entries;527    duressConfigured = false;528    buildDomainIndexSync(entries);529    await loadSettings();530    touchAutoLock();531    if (keyA) zeroKey(keyA);532    return true;533  }534535  if (keyA) zeroKey(keyA);536  if (keyB) zeroKey(keyB);537  return false;538}539540export function lock(): void {541  if (derivedKey) {542    zeroKey(derivedKey);543    derivedKey = null;544  }545546  entries = [];547  domainIndex = new Map();548  vaultMode = "real";549  duressConfigured = false;550  clearAutoLock();551}552553export function isUnlocked(): boolean {554  return derivedKey !== null;555}556557export function getEntries(): VaultEntry[] {558  if (!derivedKey) throw new Error("vault locked");559  return entries.filter((entry) => !entry.deletedAt);560}561562const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;563564export function getIdentities(): SavedIdentity[] {565  if (!derivedKey) throw new Error("vault locked");566  const active = entries.filter((e) => !e.deletedAt && e.type === "login");567568  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();569  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();570571  for (const entry of active) {572    const value = entry.username?.trim();573    if (!value) continue;574    const map = EMAIL_RE.test(value) ? emailMap : usernameMap;575    const existing = map.get(value);576    const domain = extractDomain(entry.url ?? "") ?? "";577    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;578    if (!existing || modifiedAt > existing.lastUsed) {579      map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });580    } else {581      map.set(value, { ...existing, count: existing.count + 1 });582    }583  }584585  const toIdentity = (type: "email" | "username") =>586    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({587      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,588    });589590  return [591    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),592    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),593  ];594}595596export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {597  if (!derivedKey) throw new Error("vault locked");598599  const now = Date.now();600  const added: VaultEntry[] = [];601  for (const draft of drafts) {602    try {603      const sanitized = sanitizeEntryDraft(draft);604      const nextEntry: VaultEntry = {605        ...sanitized,606        id: crypto.randomUUID(),607        createdAt: now,608        modifiedAt: now,609      };610      entries.push(nextEntry);611      added.push(nextEntry);612    } catch {613      // Skip invalid drafts silently — caller can see diff between input and output length614    }615  }616617  if (added.length > 0) {618    await persistEntries();619  }620621  return added;622}623624/**625 * Restore a set of entries in full fidelity — preserves id, createdAt,626 * modifiedAt, and every other field unlike `batchAddEntries` which627 * assigns fresh identity. Used by the CJK phrase restore path where the628 * backup is the authoritative source of truth. `mode` controls how629 * entries already in the vault are treated.630 */631export async function restoreEntries(632  incoming: VaultEntry[],633  mode: "replace" | "merge"634): Promise<{ added: number; updated: number; skipped: number }> {635  if (!derivedKey) throw new Error("vault locked");636637  // A replace with zero incoming entries is almost certainly a caller bug638  // (e.g. an import that silently dropped everything). Refuse rather than639  // wipe the vault.640  if (mode === "replace" && incoming.length === 0) {641    throw new Error("refuse to replace with empty entry set");642  }643644  let added = 0;645  let updated = 0;646  let skipped = 0;647648  if (mode === "replace") {649    entries = [];650    for (const entry of incoming) {651      const normalized = sanitizeRestoredEntry(entry);652      if (!normalized) {653        skipped += 1;654        continue;655      }656      entries.push(normalized);657      added += 1;658    }659  } else {660    const byId = new Map<string, VaultEntry>();661    for (const e of entries) byId.set(e.id, e);662663    for (const entry of incoming) {664      const normalized = sanitizeRestoredEntry(entry);665      if (!normalized) {666        skipped += 1;667        continue;668      }669      const existing = byId.get(normalized.id);670      if (!existing) {671        entries.push(normalized);672        added += 1;673      } else if (normalized.modifiedAt > existing.modifiedAt) {674        const idx = entries.findIndex((e) => e.id === existing.id);675        if (idx !== -1) {676          entries[idx] = normalized;677          updated += 1;678        }679      } else {680        skipped += 1;681      }682    }683  }684685  await persistEntries();686  return { added, updated, skipped };687}688689/**690 * Validate + normalise a VaultEntry coming from a backup. Accepts691 * anything structurally sound; keeps all metadata verbatim. Returns692 * null for values the live vault shouldn't accept (e.g., missing title).693 */694function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {695  if (!entry || typeof entry !== "object") return null;696  const title = typeof entry.title === "string" ? entry.title.trim() : "";697  if (!title) return null;698  const type = entry.type ?? "login";699  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();700  const now = Date.now();701  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;702  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;703704  if (type === "login" && typeof entry.password !== "string") {705    return null;706  }707  if (708    type === "passkey" &&709    (typeof entry.rpId !== "string" ||710      typeof entry.credentialId !== "string" ||711      !entry.privateKeyJwk)712  ) {713    return null;714  }715716  return {717    ...entry,718    id,719    title,720    type,721    createdAt,722    modifiedAt,723  };724}725726// Soft-deletes every non-deleted entry in one call. The literal string727// argument is a tripwire against accidental calls — any caller that would728// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this729// to a boolean.730export async function clearVaultEntries(731  confirmation: "REPLACE_ALL"732): Promise<void> {733  if (confirmation !== "REPLACE_ALL") {734    throw new Error("clearVaultEntries requires explicit confirmation");735  }736  if (!derivedKey) throw new Error("vault locked");737  for (const entry of entries) {738    if (!entry.deletedAt) {739      entry.deletedAt = Date.now();740      entry.modifiedAt = Date.now();741    }742  }743  await persistEntries();744}745746export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {747  if (!derivedKey) throw new Error("vault locked");748749  const sanitized = sanitizeEntryDraft(entry);750  const now = Date.now();751  const nextEntry: VaultEntry = {752    ...sanitized,753    id: crypto.randomUUID(),754    createdAt: now,755    modifiedAt: now,756    // Stamp passwordChangedAt at creation so age tracking is accurate from757    // day one. Non-login entries carry the same stamp harmlessly.758    passwordChangedAt: sanitized.password ? now : undefined,759  };760761  entries.push(nextEntry);762  await persistEntries();763764  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)765  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {766    checkBreach(nextEntry.password).then(async (count) => {767      if (count > 0) {768        const idx = entries.findIndex((e) => e.id === nextEntry.id);769        if (idx !== -1) {770          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };771          await persistEntries();772        }773      }774    });775  }776777  return nextEntry;778}779780export async function updateEntry(781  id: string,782  patch: Partial<VaultEntry>783): Promise<void> {784  if (!derivedKey) throw new Error("vault locked");785786  const idx = entries.findIndex((entry) => entry.id === id);787  if (idx === -1 || entries[idx].deletedAt) {788    throw new Error("entry not found");789  }790791  const current = entries[idx];792  const sanitizedPatch = sanitizeEntryPatch(patch);793  // Refresh passwordChangedAt only when the password value actually changes —794  // a metadata-only update (title, tags, etc.) must not reset the age clock795  // that drives the Pro health rotation recommendation.796  const passwordChanged =797    "password" in patch &&798    typeof sanitizedPatch.password === "string" &&799    sanitizedPatch.password !== current.password;800  entries[idx] = {801    ...current,802    ...sanitizedPatch,803    modifiedAt: Date.now(),804    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),805  };806807  // sanitizeEntryPatch maps an empty password to `undefined`. For login808  // entries that would silently produce a passwordless record — roll back809  // the merge and refuse.810  if (entries[idx].type === "login" && !entries[idx].password) {811    entries[idx] = current;812    throw new Error("password is required for login entries");813  }814815  // Card entries must retain their card number816  if (entries[idx].type === "card" && !entries[idx].cardNumber) {817    entries[idx] = current;818    throw new Error("card number is required for card entries");819  }820821  await persistEntries();822823  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)824  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {825    const password = entries[idx].password!;826    checkBreach(password).then(async (count) => {827      const i = entries.findIndex((e) => e.id === id);828      if (i !== -1) {829        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };830        await persistEntries();831      }832    });833  }834}835836export async function deleteEntry(id: string): Promise<void> {837  if (!derivedKey) throw new Error("vault locked");838839  const entry = entries.find((item) => item.id === id);840  if (!entry || entry.deletedAt) {841    throw new Error("entry not found");842  }843844  entry.deletedAt = Date.now();845  entry.modifiedAt = Date.now();846  await persistEntries();847}848849export function getDeletedEntries(): VaultEntry[] {850  if (!derivedKey) return [];851  return entries.filter((e) => !!e.deletedAt);852}853854export async function restoreEntry(id: string): Promise<void> {855  if (!derivedKey) throw new Error("vault locked");856  const entry = entries.find((e) => e.id === id && e.deletedAt);857  if (!entry) throw new Error("deleted entry not found");858  delete entry.deletedAt;859  entry.modifiedAt = Date.now();860  await persistEntries();861  buildDomainIndexSync(entries);862}863864export async function purgeEntry(id: string): Promise<void> {865  if (!derivedKey) throw new Error("vault locked");866  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);867  if (idx === -1) throw new Error("deleted entry not found");868  entries.splice(idx, 1);869  await persistEntries();870}871872/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */873export async function purgeExpiredTrash(retentionMs: number): Promise<number> {874  if (!derivedKey || retentionMs === 0) return 0;875  const cutoff = Date.now() - retentionMs;876  const before = entries.length;877  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));878  const purged = before - entries.length;879  if (purged > 0) await persistEntries();880  return purged;881}882883export function queryDomain(domain: string): VaultEntry[] {884  return domainIndex.get(domain) ?? [];885}886887export function getEntryById(id: string): VaultEntry | undefined {888  return entries.find((entry) => entry.id === id && !entry.deletedAt);889}890891async function persistSlotA(): Promise<StoredVault> {892  if (!derivedKey) throw new Error("vault locked");893894  const slotA = await loadSlot(SLOT_A);895  if (!slotA) throw new Error("missing slot A");896897  const plaintext = encodePayload(entries, duressConfigured);898  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);899900  const nextSlotA: StoredVault = {901    ...slotA,902    nonce,903    ciphertext,904    tag,905  };906  await writeSlot(SLOT_A, nextSlotA);907908  // When duress is not configured, keep slot B size-matched with slot A by909  // regenerating its noise. When duress IS configured, leave slot B alone —910  // we don't have the decoy key to re-encrypt it. This is a known trade-off.911  if (!duressConfigured) {912    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));913  }914915  return nextSlotA;916}917918async function persistSlotB(): Promise<void> {919  if (!derivedKey) throw new Error("vault locked");920921  const slotB = await loadSlot(SLOT_B);922  if (!slotB) throw new Error("missing slot B");923924  // Decoy plaintext always reports duressConfigured=false — the decoy user925  // sees a clean slate.926  const plaintext = encodePayload(entries, false);927  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);928929  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });930}931932async function persistEntries(): Promise<void> {933  if (!derivedKey) throw new Error("vault locked");934935  if (vaultMode === "decoy") {936    await persistSlotB();937  } else {938    const freshSlotA = await persistSlotA();939    // Save a snapshot of the real vault after every successful write.940    // Failures are silently swallowed — snapshots must never interrupt saves.941    const activeCount = entries.filter((e) => !e.deletedAt).length;942    void takeSnapshot(freshSlotA, activeCount);943  }944  buildDomainIndexSync(entries);945}946947function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {948  if (!Array.isArray(values)) return undefined;949  const seen = new Set<string>();950  const cleaned: string[] = [];951  for (const value of values) {952    const trimmed = typeof value === "string" ? value.trim() : "";953    if (!trimmed) continue;954    const key = trimmed.toLowerCase();955    if (seen.has(key)) continue;956    seen.add(key);957    cleaned.push(trimmed);958  }959  return cleaned.length > 0 ? cleaned : undefined;960}961962function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {963  const title = entry.title.trim();964  const type = entry.type ?? "login";965966  if (!title) throw new Error("title is required");967  if (type === "login" && !entry.password?.trim()) {968    throw new Error("password is required");969  }970  if (type === "passkey") {971    if (972      !entry.rpId?.trim() ||973      !entry.rpName?.trim() ||974      !entry.credentialId?.trim() ||975      !entry.publicKeyCose?.trim() ||976      !entry.privateKeyJwk ||977      !entry.userHandle?.trim() ||978      !entry.userName?.trim() ||979      !entry.userDisplayName?.trim() ||980      (entry.algorithm !== -7 && entry.algorithm !== -8)981    ) {982      throw new Error("invalid passkey entry");983    }984  }985  if (type === "card") {986    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {987      throw new Error("cardholder name and card number are required");988    }989  }990991  return {992    type,993    title,994    username: type === "login" ? entry.username?.trim() ?? "" : undefined,995    password: type === "login" ? entry.password : undefined,996    url: type === "login" ? entry.url?.trim() ?? "" : undefined,997    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,998    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,999    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,1000    breach: type === "login" ? entry.breach : undefined,1001    canary: type === "login" ? entry.canary : undefined,1002    tags: entry.tags,1003    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,1004    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,1005    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,1006    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,1007    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,1008    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,1009    userName: type === "passkey" ? entry.userName?.trim() : undefined,1010    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,1011    algorithm: type === "passkey" ? entry.algorithm : undefined,1012    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,1013    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,1014    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,1015    backupState: type === "passkey" ? entry.backupState === true : undefined,1016    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,1017    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1018    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1019    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1020    expYear: type === "card" ? entry.expYear?.trim() : undefined,1021    cvc: type === "card" ? entry.cvc?.trim() : undefined,1022    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1023    network: type === "card" ? entry.network : undefined,1024    last4: type === "card" ? entry.last4?.trim() : undefined,1025  };1026}10271028function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1029  const nextPatch: Partial<VaultEntry> = {};10301031  if ("type" in patch) {1032    nextPatch.type = patch.type;1033  }10341035  if ("title" in patch) {1036    const title = patch.title?.trim() ?? "";1037    if (!title) throw new Error("title is required");1038    nextPatch.title = title;1039  }10401041  if ("url" in patch) {1042    nextPatch.url = patch.url?.trim() ?? "";1043  }10441045  if ("additionalUrls" in patch) {1046    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1047    const seen = new Set<string>();1048    const cleaned: string[] = [];1049    for (const value of incoming) {1050      const trimmed = typeof value === "string" ? value.trim() : "";1051      if (!trimmed) continue;1052      const key = trimmed.toLowerCase();1053      if (seen.has(key)) continue;1054      seen.add(key);1055      cleaned.push(trimmed);1056    }1057    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1058  }10591060  if ("username" in patch) {1061    nextPatch.username = patch.username?.trim() ?? "";1062  }10631064  if ("password" in patch) {1065    const password = patch.password ?? "";1066    // Allow clearing a password (e.g. on passkey-only entries)1067    nextPatch.password = password || undefined;1068  }10691070  if ("totp" in patch) {1071    nextPatch.totp = patch.totp?.trim() || undefined;1072  }10731074  if ("notes" in patch) {1075    nextPatch.notes = patch.notes?.trim() || undefined;1076  }10771078  if ("breach" in patch) {1079    nextPatch.breach = patch.breach;1080  }10811082  if ("canary" in patch) {1083    nextPatch.canary = patch.canary;1084  }10851086  if ("rpId" in patch) {1087    nextPatch.rpId = patch.rpId?.trim();1088  }10891090  if ("rpName" in patch) {1091    nextPatch.rpName = patch.rpName?.trim();1092  }10931094  if ("credentialId" in patch) {1095    nextPatch.credentialId = patch.credentialId?.trim();1096  }10971098  if ("publicKeyCose" in patch) {1099    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1100  }11011102  if ("privateKeyJwk" in patch) {1103    nextPatch.privateKeyJwk = patch.privateKeyJwk;1104  }11051106  if ("userHandle" in patch) {1107    nextPatch.userHandle = patch.userHandle?.trim();1108  }11091110  if ("userName" in patch) {1111    nextPatch.userName = patch.userName?.trim();1112  }11131114  if ("userDisplayName" in patch) {1115    nextPatch.userDisplayName = patch.userDisplayName?.trim();1116  }11171118  if ("algorithm" in patch) {1119    nextPatch.algorithm = patch.algorithm;1120  }11211122  if ("signCount" in patch) {1123    nextPatch.signCount = patch.signCount;1124  }11251126  if ("aaguid" in patch) {1127    nextPatch.aaguid = patch.aaguid?.trim();1128  }11291130  if ("backupEligible" in patch) {1131    nextPatch.backupEligible = patch.backupEligible;1132  }11331134  if ("backupState" in patch) {1135    nextPatch.backupState = patch.backupState;1136  }11371138  if ("lastUsedAt" in patch) {1139    nextPatch.lastUsedAt = patch.lastUsedAt;1140  }11411142  if ("tags" in patch) {1143    nextPatch.tags = patch.tags;1144  }11451146  if ("cardholderName" in patch) {1147    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1148  }1149  if ("cardNumber" in patch) {1150    const n = patch.cardNumber ?? "";1151    nextPatch.cardNumber = n || undefined;1152  }1153  if ("expMonth" in patch) {1154    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1155  }1156  if ("expYear" in patch) {1157    nextPatch.expYear = patch.expYear?.trim() || undefined;1158  }1159  if ("cvc" in patch) {1160    nextPatch.cvc = patch.cvc?.trim() || undefined;1161  }1162  if ("cardNickname" in patch) {1163    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1164  }1165  if ("network" in patch) {1166    nextPatch.network = patch.network;1167  }1168  if ("last4" in patch) {1169    nextPatch.last4 = patch.last4?.trim() || undefined;1170  }11711172  return nextPatch;1173}11741175function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1176  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1177  const modifiedAt =1178    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;11791180  return {1181    id: entry.id,1182    type: "type" in entry ? entry.type : "login",1183    title: entry.title,1184    username: entry.username,1185    password: entry.password,1186    url: entry.url,1187    totp: entry.totp,1188    notes: entry.notes,1189    breach: entry.breach,1190    canary: "canary" in entry ? entry.canary : undefined,1191    rpId: "rpId" in entry ? entry.rpId : undefined,1192    rpName: "rpName" in entry ? entry.rpName : undefined,1193    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1194    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1195    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1196    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1197    userName: "userName" in entry ? entry.userName : undefined,1198    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1199    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1200    signCount: "signCount" in entry ? entry.signCount : undefined,1201    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1202    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1203    backupState: "backupState" in entry ? entry.backupState : undefined,1204    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1205    createdAt,1206    modifiedAt,1207    deletedAt: entry.deletedAt,1208    tags: "tags" in entry ? entry.tags : undefined,1209    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1210    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1211    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1212    expYear: "expYear" in entry ? entry.expYear : undefined,1213    cvc: "cvc" in entry ? entry.cvc : undefined,1214    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1215    network: "network" in entry ? entry.network : undefined,1216    last4: "last4" in entry ? entry.last4 : undefined,1217  };1218}12191220function hasLegacyShape(parsed: VaultEntry[]): boolean {1221  return parsed.some(1222    (entry) => "created" in entry || "updated" in entry1223  );1224}12251226// ---------------------------------------------------------------------------1227// Deduplication1228// ---------------------------------------------------------------------------12291230function entryDupeKey(e: VaultEntry): string {1231  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1232  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1233  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1234}12351236/** Returns the number of duplicate entries (those that would be removed). */1237export function countDuplicates(): number {1238  const best = new Map<string, VaultEntry>();1239  let dupeCount = 0;1240  for (const e of entries.filter((e) => !e.deletedAt)) {1241    const key = entryDupeKey(e);1242    const existing = best.get(key);1243    if (!existing) {1244      best.set(key, e);1245    } else {1246      dupeCount++;1247      // Keep the more recently modified one in the map1248      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1249        best.set(key, e);1250      }1251    }1252  }1253  return dupeCount;1254}12551256/**1257 * Soft-deletes duplicate entries, keeping the most recently modified copy of1258 * each group. Returns the number of entries moved to trash.1259 */1260export async function deduplicateEntries(): Promise<number> {1261  if (!derivedKey) throw new Error("vault locked");1262  // Dedup reshapes slot B permanently under the decoy key, which is1263  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1264  if (vaultMode === "decoy") {1265    throw new Error("deduplicate disabled in decoy mode");1266  }12671268  const best = new Map<string, VaultEntry>();1269  for (const e of entries.filter((en) => !en.deletedAt)) {1270    const key = entryDupeKey(e);1271    const existing = best.get(key);1272    if (!existing) {1273      best.set(key, e);1274    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1275      best.set(key, e);1276    }1277  }12781279  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1280  const now = Date.now();1281  let removed = 0;1282  entries = entries.map((e) => {1283    if (!e.deletedAt && !keepIds.has(e.id)) {1284      removed++;1285      return { ...e, deletedAt: now };1286    }1287    return e;1288  });12891290  if (removed > 0) await persistEntries();1291  return removed;1292}12931294// ---------------------------------------------------------------------------1295// Vault snapshot system — last 6 encrypted states, used for self-recovery1296// ---------------------------------------------------------------------------12971298const SNAPSHOT_KEY = "peach_vault_snapshots";1299const MAX_SNAPSHOTS = 6;13001301interface VaultSnapshot {1302  timestamp: number;1303  entryCount: number;1304  slot: StoredVault;1305}13061307/** Public summary (no sensitive data) returned to the popup. */1308export interface SnapshotSummary {1309  index: number;1310  timestamp: number;1311  entryCount: number;1312}13131314async function takeSnapshot(1315  slot: StoredVault,1316  entryCount: number1317): Promise<void> {1318  try {1319    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1320    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1321    const next: VaultSnapshot[] = [1322      { timestamp: Date.now(), entryCount, slot },1323      ...existing,1324    ].slice(0, MAX_SNAPSHOTS);1325    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1326  } catch {1327    // Non-fatal — snapshot failure must never interrupt vault saves1328  }1329}13301331export async function listSnapshots(): Promise<SnapshotSummary[]> {1332  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1333  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1334  return snapshots.map((s, i) => ({1335    index: i,1336    timestamp: s.timestamp,1337    entryCount: s.entryCount,1338  }));1339}13401341/**1342 * Restore a snapshot by index. The password must decrypt that snapshot1343 * (i.e., the same master password used when the snapshot was taken).1344 * On success the vault is unlocked in-memory with the restored entries1345 * — no re-login required.1346 */1347export async function restoreFromSnapshot(1348  index: number,1349  password: string1350): Promise<boolean> {1351  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1352  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1353  const snapshot = snapshots[index];1354  if (!snapshot) return false;13551356  // Verify the password decrypts this snapshot1357  const key = await deriveKey(password, unhex(snapshot.slot.salt));1358  const payload = await tryDecryptSlot(snapshot.slot, key);1359  if (!payload) {1360    zeroKey(key);1361    return false;1362  }13631364  // Write the snapshot back to slot A. Regenerate noise for slot B.1365  await writeSlot(SLOT_A, snapshot.slot);1366  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));13671368  // Swap in-memory state to the restored vault — caller gets immediate access1369  if (derivedKey) zeroKey(derivedKey);1370  derivedKey = key;1371  entries = payload.entries;1372  duressConfigured = payload.duressConfigured;1373  vaultMode = "real";1374  buildDomainIndexSync(entries);1375  await loadSettings();1376  touchAutoLock();13771378  return true;1379}13801381/**1382 * Change the master password. Re-encrypts slot A with a fresh salt + new1383 * derived key. Slot B (duress or noise) is left untouched when duress is1384 * configured — it uses an independent key derived from the duress password —1385 * and regenerated as noise when duress is not configured.1386 */1387export async function changePassword(1388  currentPassword: string,1389  newPassword: string1390): Promise<boolean> {1391  if (!derivedKey || vaultMode !== "real") return false;13921393  // Verify current password before allowing any change1394  const valid = await verifyMainPassword(currentPassword);1395  if (!valid) return false;13961397  const slotA = await loadSlot(SLOT_A);1398  if (!slotA) return false;13991400  // Derive fresh key from new password + new random salt1401  const newSalt = generateSalt();1402  const newKey = await deriveKey(newPassword, newSalt);14031404  // Re-encrypt the in-memory entries with the new key1405  const plaintext = encodePayload(entries, duressConfigured);1406  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);14071408  const freshSlotA: StoredVault = {1409    ...slotA,1410    salt: hex(newSalt),1411    nonce,1412    ciphertext,1413    tag,1414  };1415  await writeSlot(SLOT_A, freshSlotA);14161417  // Swap the in-memory key synchronously before any further await. If we1418  // threw between the Slot A write and this swap, the next persistEntries()1419  // would overwrite Slot A with old-key ciphertext, locking the user out.1420  zeroKey(derivedKey);1421  derivedKey = newKey;14221423  // Snapshot now that in-memory key and disk are consistent. Pass the1424  // slot content we just wrote rather than re-reading from storage so the1425  // snapshot is immune to any microtask-coalesced read-after-write race.1426  const activeCount = entries.filter((e) => !e.deletedAt).length;1427  void takeSnapshot(freshSlotA, activeCount);14281429  // Regenerate noise for slot B when duress is not in use. Runs after the1430  // key swap so a failure here is a disposable noise regeneration, not a1431  // lockout.1432  if (!duressConfigured) {1433    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1434  }14351436  return true;1437}