vault.ts

47.8 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 */288function generateNoiseBlob(ciphertextBytes: number): StoredVault {289  const salt = crypto.getRandomValues(new Uint8Array(16));290  const nonce = crypto.getRandomValues(new Uint8Array(12));291  const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));292  const tag = crypto.getRandomValues(new Uint8Array(16));293  return {294    version: 1,295    salt: hex(salt),296    nonce: hex(nonce),297    ciphertext: hex(cipher),298    tag: hex(tag),299  };300}301302// ---------------------------------------------------------------------------303// Encrypted plaintext envelope304// ---------------------------------------------------------------------------305306type VaultPayload = {307  entries: VaultEntry[];308  duressConfigured?: boolean;309};310311type DecryptedPayload = {312  entries: VaultEntry[];313  duressConfigured: boolean;314};315316function encodePayload(entries: VaultEntry[], duress: boolean): string {317  const envelope: VaultPayload = { entries, duressConfigured: duress };318  return padPlaintext(JSON.stringify(envelope));319}320321function decodePayload(plain: string): DecryptedPayload {322  const parsed = JSON.parse(plain) as323    | Array<VaultEntry | LegacyVaultEntry>324    | VaultPayload;325326  if (Array.isArray(parsed)) {327    // Pre-A-2 plaintext: bare array of entries, no duress flag.328    return {329      entries: parsed.map(normalizeEntry),330      duressConfigured: false,331    };332  }333334  return {335    entries: (parsed.entries ?? []).map(normalizeEntry),336    duressConfigured: parsed.duressConfigured === true,337  };338}339340async function tryDecryptSlot(341  vault: StoredVault,342  key: Uint8Array343): Promise<DecryptedPayload | null> {344  try {345    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);346    return decodePayload(plain);347  } catch {348    return null;349  }350}351352// ---------------------------------------------------------------------------353// Core operations354// ---------------------------------------------------------------------------355356function buildDomainIndexSync(all: VaultEntry[]): void {357  domainIndex = new Map();358359  for (const entry of all) {360    if (entry.deletedAt) continue;361    if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;362363    // An entry can match multiple sites via `additionalUrls`. Index each364    // hostname so `queryDomain` returns the entry regardless of which alias365    // the user is currently visiting. Dedupe per-entry so a single entry366    // never appears twice in one bucket.367    const domains = new Set<string>();368    const primary = extractDomain(entry.url ?? "");369    if (primary) domains.add(primary);370    for (const extra of entry.additionalUrls ?? []) {371      const d = extractDomain(extra);372      if (d) domains.add(d);373    }374    if (domains.size === 0) continue;375376    for (const domain of domains) {377      const bucket = domainIndex.get(domain) ?? [];378      bucket.push(entry);379      domainIndex.set(domain, bucket);380    }381  }382}383384export async function createVault(password: string): Promise<void> {385  await ensureMigrated();386387  const salt = generateSalt();388  derivedKey = await deriveKey(password, salt);389  entries = [];390  vaultMode = "real";391  duressConfigured = false;392  buildDomainIndexSync(entries);393394  const plaintext = encodePayload(entries, duressConfigured);395  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);396397  const slotA: StoredVault = {398    version: 1,399    salt: hex(salt),400    nonce,401    ciphertext,402    tag,403  };404  await writeSlot(SLOT_A, slotA);405406  // Matching-size noise blob for slot B, so a fresh install shows two407  // similarly-shaped records regardless of whether duress is ever set up.408  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));409410  touchAutoLock();411}412413export async function hasVault(): Promise<boolean> {414  await ensureMigrated();415  return (await loadSlot(SLOT_A)) !== null;416}417418/**419 * Returns whether the real vault has recorded that a duress passphrase is420 * configured. Reliable only after a real-mode unlock — before unlock or in421 * decoy mode, returns false because we cannot read slot A's plaintext.422 */423export async function isDuressEnabled(): Promise<boolean> {424  return vaultMode === "real" && duressConfigured;425}426427export async function hasDecoyVault(): Promise<boolean> {428  return isDuressEnabled();429}430431export async function setupDuress(duressPassword: string): Promise<void> {432  if (!derivedKey || vaultMode !== "real") {433    throw new Error("vault must be unlocked in real mode");434  }435  await ensureMigrated();436437  // Encrypt the decoy payload (empty entries) under the duress key. Pad to438  // at least the current slot A ciphertext size so the two slots look the439  // same at rest at setup time.440  const slotA = await loadSlot(SLOT_A);441  if (!slotA) throw new Error("missing slot A");442443  const decoySalt = generateSalt();444  const decoyKey = await deriveKey(duressPassword, decoySalt);445  try {446    // Pad the decoy plaintext to match slot A's ciphertext length (minus the447    // 16-byte tag) so encrypted sizes align exactly.448    const targetBytes = slotA.ciphertext.length / 2;449    let plaintext = encodePayload([], false);450    const encoded = new TextEncoder().encode(plaintext);451    if (encoded.length < targetBytes) {452      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);453    }454    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);455    await writeSlot(SLOT_B, {456      version: 1,457      salt: hex(decoySalt),458      nonce,459      ciphertext,460      tag,461    });462  } finally {463    zeroKey(decoyKey);464  }465466  // Re-encrypt slot A with the duressConfigured flag set to true. No external467  // flag is written — the bit lives inside the encrypted envelope.468  duressConfigured = true;469  await persistSlotA();470}471472export async function disableDuress(): Promise<void> {473  if (!derivedKey || vaultMode !== "real") {474    throw new Error("vault must be unlocked in real mode");475  }476  await ensureMigrated();477478  // Overwrite slot B with fresh noise that matches slot A's size. The slot479  // remains occupied so its mere existence does not signal that duress was480  // ever configured.481  const slotA = await loadSlot(SLOT_A);482  if (slotA) {483    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));484  }485486  duressConfigured = false;487  await persistSlotA();488}489490export async function verifyMainPassword(password: string): Promise<boolean> {491  await ensureMigrated();492  const slotA = await loadSlot(SLOT_A);493  if (!slotA) return false;494  const key = await deriveKey(password, unhex(slotA.salt));495  try {496    return (await tryDecryptSlot(slotA, key)) !== null;497  } finally {498    zeroKey(key);499  }500}501502export async function unlock(password: string): Promise<boolean> {503  await ensureMigrated();504505  // Load both slots in parallel. Both always exist post-migration; the506  // parallel load and the parallel derivation below keep wall-clock time507  // constant regardless of which slot's passphrase (real or decoy) the508  // caller supplied, so a timing side channel cannot distinguish the509  // duress path from the real-unlock path (DUR-002).510  const [slotA, slotB] = await Promise.all([511    loadSlot(SLOT_A),512    loadSlot(SLOT_B),513  ]);514515  // Derive both keys in parallel, unconditionally.516  const [keyA, keyB] = await Promise.all([517    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),518    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),519  ]);520521  // Attempt both decrypts in parallel.522  const [payloadA, payloadB] = await Promise.all([523    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),524    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),525  ]);526527  if (payloadA && keyA) {528    derivedKey = keyA;529    vaultMode = "real";530    entries = payloadA.entries;531    duressConfigured = payloadA.duressConfigured;532    buildDomainIndexSync(entries);533    await loadSettings();534    touchAutoLock();535    if (keyB) zeroKey(keyB);536    // If the payload came from the pre-envelope (legacy) shape, rewrite537    // it immediately so the next save uses the new format.538    if (hasLegacyShape(payloadA.entries)) {539      await persistSlotA();540    }541    return true;542  }543544  if (payloadB && keyB) {545    derivedKey = keyB;546    vaultMode = "decoy";547    entries = payloadB.entries;548    duressConfigured = false;549    buildDomainIndexSync(entries);550    await loadSettings();551    touchAutoLock();552    if (keyA) zeroKey(keyA);553    return true;554  }555556  if (keyA) zeroKey(keyA);557  if (keyB) zeroKey(keyB);558  return false;559}560561export function lock(): void {562  if (derivedKey) {563    zeroKey(derivedKey);564    derivedKey = null;565  }566567  entries = [];568  domainIndex = new Map();569  vaultMode = "real";570  duressConfigured = false;571  clearAutoLock();572}573574export function isUnlocked(): boolean {575  return derivedKey !== null;576}577578export function getEntries(): VaultEntry[] {579  if (!derivedKey) throw new Error("vault locked");580  return entries.filter((entry) => !entry.deletedAt);581}582583const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;584585export function getIdentities(): SavedIdentity[] {586  if (!derivedKey) throw new Error("vault locked");587  const active = entries.filter((e) => !e.deletedAt && e.type === "login");588589  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();590  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();591592  for (const entry of active) {593    const value = entry.username?.trim();594    if (!value) continue;595    const map = EMAIL_RE.test(value) ? emailMap : usernameMap;596    const existing = map.get(value);597    const domain = extractDomain(entry.url ?? "") ?? "";598    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;599    if (!existing || modifiedAt > existing.lastUsed) {600      map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });601    } else {602      map.set(value, { ...existing, count: existing.count + 1 });603    }604  }605606  const toIdentity = (type: "email" | "username") =>607    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({608      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,609    });610611  return [612    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),613    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),614  ];615}616617export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {618  if (!derivedKey) throw new Error("vault locked");619620  const now = Date.now();621  const added: VaultEntry[] = [];622  for (const draft of drafts) {623    try {624      const sanitized = sanitizeEntryDraft(draft);625      const nextEntry: VaultEntry = {626        ...sanitized,627        id: crypto.randomUUID(),628        createdAt: now,629        modifiedAt: now,630      };631      entries.push(nextEntry);632      added.push(nextEntry);633    } catch {634      // Skip invalid drafts silently — caller can see diff between input and output length635    }636  }637638  if (added.length > 0) {639    await persistEntries();640  }641642  return added;643}644645/**646 * Restore a set of entries in full fidelity — preserves id, createdAt,647 * modifiedAt, and every other field unlike `batchAddEntries` which648 * assigns fresh identity. Used by restore paths where the backup is the649 * authoritative source of truth. `mode` controls how entries already in650 * the vault are treated.651 */652export async function restoreEntries(653  incoming: VaultEntry[],654  mode: "replace" | "merge"655): Promise<{ added: number; updated: number; skipped: number }> {656  if (!derivedKey) throw new Error("vault locked");657658  // A replace with zero incoming entries is almost certainly a caller bug659  // (e.g. an import that silently dropped everything). Refuse rather than660  // wipe the vault.661  if (mode === "replace" && incoming.length === 0) {662    throw new Error("refuse to replace with empty entry set");663  }664665  let added = 0;666  let updated = 0;667  let skipped = 0;668669  if (mode === "replace") {670    entries = [];671    for (const entry of incoming) {672      const normalized = sanitizeRestoredEntry(entry);673      if (!normalized) {674        skipped += 1;675        continue;676      }677      entries.push(normalized);678      added += 1;679    }680  } else {681    const byId = new Map<string, VaultEntry>();682    for (const e of entries) byId.set(e.id, e);683684    for (const entry of incoming) {685      const normalized = sanitizeRestoredEntry(entry);686      if (!normalized) {687        skipped += 1;688        continue;689      }690      const existing = byId.get(normalized.id);691      if (!existing) {692        entries.push(normalized);693        added += 1;694      } else if (normalized.modifiedAt > existing.modifiedAt) {695        const idx = entries.findIndex((e) => e.id === existing.id);696        if (idx !== -1) {697          entries[idx] = normalized;698          updated += 1;699        }700      } else {701        skipped += 1;702      }703    }704  }705706  await persistEntries();707  return { added, updated, skipped };708}709710/**711 * Validate + normalise a VaultEntry coming from a backup. Accepts712 * anything structurally sound; keeps all metadata verbatim. Returns713 * null for values the live vault shouldn't accept (e.g., missing title).714 */715function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {716  if (!entry || typeof entry !== "object") return null;717  const title = typeof entry.title === "string" ? entry.title.trim() : "";718  if (!title) return null;719  const type = entry.type ?? "login";720  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();721  const now = Date.now();722  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;723  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;724725  if (type === "login" && typeof entry.password !== "string") {726    return null;727  }728  if (729    type === "passkey" &&730    (typeof entry.rpId !== "string" ||731      typeof entry.credentialId !== "string" ||732      !entry.privateKeyJwk)733  ) {734    return null;735  }736737  return {738    ...entry,739    id,740    title,741    type,742    createdAt,743    modifiedAt,744  };745}746747// Soft-deletes every non-deleted entry in one call. The literal string748// argument is a tripwire against accidental calls — any caller that would749// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this750// to a boolean.751export async function clearVaultEntries(752  confirmation: "REPLACE_ALL"753): Promise<void> {754  if (confirmation !== "REPLACE_ALL") {755    throw new Error("clearVaultEntries requires explicit confirmation");756  }757  if (!derivedKey) throw new Error("vault locked");758  for (const entry of entries) {759    if (!entry.deletedAt) {760      entry.deletedAt = Date.now();761      entry.modifiedAt = Date.now();762    }763  }764  await persistEntries();765}766767export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {768  if (!derivedKey) throw new Error("vault locked");769770  const sanitized = sanitizeEntryDraft(entry);771  const now = Date.now();772  const nextEntry: VaultEntry = {773    ...sanitized,774    id: crypto.randomUUID(),775    createdAt: now,776    modifiedAt: now,777    // Stamp passwordChangedAt at creation so age tracking is accurate from778    // day one. Non-login entries carry the same stamp harmlessly.779    passwordChangedAt: sanitized.password ? now : undefined,780  };781782  entries.push(nextEntry);783  await persistEntries();784785  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)786  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {787    checkBreach(nextEntry.password).then(async (count) => {788      if (count > 0) {789        const idx = entries.findIndex((e) => e.id === nextEntry.id);790        if (idx !== -1) {791          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };792          await persistEntries();793        }794      }795    });796  }797798  return nextEntry;799}800801export async function updateEntry(802  id: string,803  patch: Partial<VaultEntry>804): Promise<void> {805  if (!derivedKey) throw new Error("vault locked");806807  const idx = entries.findIndex((entry) => entry.id === id);808  if (idx === -1 || entries[idx].deletedAt) {809    throw new Error("entry not found");810  }811812  const current = entries[idx];813  const sanitizedPatch = sanitizeEntryPatch(patch);814  // Refresh passwordChangedAt only when the password value actually changes —815  // a metadata-only update (title, tags, etc.) must not reset the age clock816  // that drives the Pro health rotation recommendation.817  const passwordChanged =818    "password" in patch &&819    typeof sanitizedPatch.password === "string" &&820    sanitizedPatch.password !== current.password;821  entries[idx] = {822    ...current,823    ...sanitizedPatch,824    modifiedAt: Date.now(),825    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),826  };827828  // sanitizeEntryPatch maps an empty password to `undefined`. For login829  // entries that would silently produce a passwordless record — roll back830  // the merge and refuse.831  if (entries[idx].type === "login" && !entries[idx].password) {832    entries[idx] = current;833    throw new Error("password is required for login entries");834  }835836  // Card entries must retain their card number837  if (entries[idx].type === "card" && !entries[idx].cardNumber) {838    entries[idx] = current;839    throw new Error("card number is required for card entries");840  }841842  await persistEntries();843844  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)845  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {846    const password = entries[idx].password!;847    checkBreach(password).then(async (count) => {848      const i = entries.findIndex((e) => e.id === id);849      if (i !== -1) {850        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };851        await persistEntries();852      }853    });854  }855}856857export async function deleteEntry(id: string): Promise<void> {858  if (!derivedKey) throw new Error("vault locked");859860  const entry = entries.find((item) => item.id === id);861  if (!entry || entry.deletedAt) {862    throw new Error("entry not found");863  }864865  entry.deletedAt = Date.now();866  entry.modifiedAt = Date.now();867  await persistEntries();868}869870export function getDeletedEntries(): VaultEntry[] {871  if (!derivedKey) return [];872  return entries.filter((e) => !!e.deletedAt);873}874875export async function restoreEntry(id: string): Promise<void> {876  if (!derivedKey) throw new Error("vault locked");877  const entry = entries.find((e) => e.id === id && e.deletedAt);878  if (!entry) throw new Error("deleted entry not found");879  delete entry.deletedAt;880  entry.modifiedAt = Date.now();881  await persistEntries();882  buildDomainIndexSync(entries);883}884885export async function purgeEntry(id: string): Promise<void> {886  if (!derivedKey) throw new Error("vault locked");887  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);888  if (idx === -1) throw new Error("deleted entry not found");889  entries.splice(idx, 1);890  await persistEntries();891}892893/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */894export async function purgeExpiredTrash(retentionMs: number): Promise<number> {895  if (!derivedKey || retentionMs === 0) return 0;896  const cutoff = Date.now() - retentionMs;897  const before = entries.length;898  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));899  const purged = before - entries.length;900  if (purged > 0) await persistEntries();901  return purged;902}903904export function queryDomain(domain: string): VaultEntry[] {905  return domainIndex.get(domain) ?? [];906}907908export function getEntryById(id: string): VaultEntry | undefined {909  return entries.find((entry) => entry.id === id && !entry.deletedAt);910}911912async function persistSlotA(): Promise<StoredVault> {913  if (!derivedKey) throw new Error("vault locked");914915  const slotA = await loadSlot(SLOT_A);916  if (!slotA) throw new Error("missing slot A");917918  const plaintext = encodePayload(entries, duressConfigured);919  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);920921  const nextSlotA: StoredVault = {922    ...slotA,923    nonce,924    ciphertext,925    tag,926  };927  await writeSlot(SLOT_A, nextSlotA);928929  // When duress is not configured, keep slot B size-matched with slot A by930  // regenerating its noise. When duress IS configured, leave slot B alone —931  // we don't have the decoy key to re-encrypt it. This is a known trade-off.932  if (!duressConfigured) {933    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));934  }935936  return nextSlotA;937}938939async function persistSlotB(): Promise<void> {940  if (!derivedKey) throw new Error("vault locked");941942  const slotB = await loadSlot(SLOT_B);943  if (!slotB) throw new Error("missing slot B");944945  // Decoy plaintext always reports duressConfigured=false — the decoy user946  // sees a clean slate.947  const plaintext = encodePayload(entries, false);948  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);949950  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });951}952953async function persistEntries(): Promise<void> {954  if (!derivedKey) throw new Error("vault locked");955956  if (vaultMode === "decoy") {957    await persistSlotB();958  } else {959    const freshSlotA = await persistSlotA();960    // Save a snapshot of the real vault after every successful write.961    // Failures are silently swallowed — snapshots must never interrupt saves.962    const activeCount = entries.filter((e) => !e.deletedAt).length;963    void takeSnapshot(freshSlotA, activeCount);964  }965  buildDomainIndexSync(entries);966}967968function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {969  if (!Array.isArray(values)) return undefined;970  const seen = new Set<string>();971  const cleaned: string[] = [];972  for (const value of values) {973    const trimmed = typeof value === "string" ? value.trim() : "";974    if (!trimmed) continue;975    const key = trimmed.toLowerCase();976    if (seen.has(key)) continue;977    seen.add(key);978    cleaned.push(trimmed);979  }980  return cleaned.length > 0 ? cleaned : undefined;981}982983function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {984  const title = entry.title.trim();985  const type = entry.type ?? "login";986987  if (!title) throw new Error("title is required");988  if (type === "login" && !entry.password?.trim()) {989    throw new Error("password is required");990  }991  if (type === "passkey") {992    if (993      !entry.rpId?.trim() ||994      !entry.rpName?.trim() ||995      !entry.credentialId?.trim() ||996      !entry.publicKeyCose?.trim() ||997      !entry.privateKeyJwk ||998      !entry.userHandle?.trim() ||999      !entry.userName?.trim() ||1000      !entry.userDisplayName?.trim() ||1001      (entry.algorithm !== -7 && entry.algorithm !== -8)1002    ) {1003      throw new Error("invalid passkey entry");1004    }1005  }1006  if (type === "card") {1007    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1008      throw new Error("cardholder name and card number are required");1009    }1010  }10111012  return {1013    type,1014    title,1015    username: type === "login" ? entry.username?.trim() ?? "" : undefined,1016    password: type === "login" ? entry.password : undefined,1017    url: type === "login" ? entry.url?.trim() ?? "" : undefined,1018    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1019    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1020    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,1021    breach: type === "login" ? entry.breach : undefined,1022    canary: type === "login" ? entry.canary : undefined,1023    tags: entry.tags,1024    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,1025    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,1026    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,1027    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,1028    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,1029    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,1030    userName: type === "passkey" ? entry.userName?.trim() : undefined,1031    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,1032    algorithm: type === "passkey" ? entry.algorithm : undefined,1033    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,1034    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,1035    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,1036    backupState: type === "passkey" ? entry.backupState === true : undefined,1037    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,1038    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1039    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1040    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1041    expYear: type === "card" ? entry.expYear?.trim() : undefined,1042    cvc: type === "card" ? entry.cvc?.trim() : undefined,1043    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1044    network: type === "card" ? entry.network : undefined,1045    last4: type === "card" ? entry.last4?.trim() : undefined,1046  };1047}10481049function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1050  const nextPatch: Partial<VaultEntry> = {};10511052  if ("type" in patch) {1053    nextPatch.type = patch.type;1054  }10551056  if ("title" in patch) {1057    const title = patch.title?.trim() ?? "";1058    if (!title) throw new Error("title is required");1059    nextPatch.title = title;1060  }10611062  if ("url" in patch) {1063    nextPatch.url = patch.url?.trim() ?? "";1064  }10651066  if ("additionalUrls" in patch) {1067    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1068    const seen = new Set<string>();1069    const cleaned: string[] = [];1070    for (const value of incoming) {1071      const trimmed = typeof value === "string" ? value.trim() : "";1072      if (!trimmed) continue;1073      const key = trimmed.toLowerCase();1074      if (seen.has(key)) continue;1075      seen.add(key);1076      cleaned.push(trimmed);1077    }1078    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1079  }10801081  if ("username" in patch) {1082    nextPatch.username = patch.username?.trim() ?? "";1083  }10841085  if ("password" in patch) {1086    const password = patch.password ?? "";1087    // Allow clearing a password (e.g. on passkey-only entries)1088    nextPatch.password = password || undefined;1089  }10901091  if ("totp" in patch) {1092    nextPatch.totp = patch.totp?.trim() || undefined;1093  }10941095  if ("notes" in patch) {1096    nextPatch.notes = patch.notes?.trim() || undefined;1097  }10981099  if ("breach" in patch) {1100    nextPatch.breach = patch.breach;1101  }11021103  if ("canary" in patch) {1104    nextPatch.canary = patch.canary;1105  }11061107  if ("rpId" in patch) {1108    nextPatch.rpId = patch.rpId?.trim();1109  }11101111  if ("rpName" in patch) {1112    nextPatch.rpName = patch.rpName?.trim();1113  }11141115  if ("credentialId" in patch) {1116    nextPatch.credentialId = patch.credentialId?.trim();1117  }11181119  if ("publicKeyCose" in patch) {1120    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1121  }11221123  if ("privateKeyJwk" in patch) {1124    nextPatch.privateKeyJwk = patch.privateKeyJwk;1125  }11261127  if ("userHandle" in patch) {1128    nextPatch.userHandle = patch.userHandle?.trim();1129  }11301131  if ("userName" in patch) {1132    nextPatch.userName = patch.userName?.trim();1133  }11341135  if ("userDisplayName" in patch) {1136    nextPatch.userDisplayName = patch.userDisplayName?.trim();1137  }11381139  if ("algorithm" in patch) {1140    nextPatch.algorithm = patch.algorithm;1141  }11421143  if ("signCount" in patch) {1144    nextPatch.signCount = patch.signCount;1145  }11461147  if ("aaguid" in patch) {1148    nextPatch.aaguid = patch.aaguid?.trim();1149  }11501151  if ("backupEligible" in patch) {1152    nextPatch.backupEligible = patch.backupEligible;1153  }11541155  if ("backupState" in patch) {1156    nextPatch.backupState = patch.backupState;1157  }11581159  if ("lastUsedAt" in patch) {1160    nextPatch.lastUsedAt = patch.lastUsedAt;1161  }11621163  if ("tags" in patch) {1164    nextPatch.tags = patch.tags;1165  }11661167  if ("cardholderName" in patch) {1168    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1169  }1170  if ("cardNumber" in patch) {1171    const n = patch.cardNumber ?? "";1172    nextPatch.cardNumber = n || undefined;1173  }1174  if ("expMonth" in patch) {1175    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1176  }1177  if ("expYear" in patch) {1178    nextPatch.expYear = patch.expYear?.trim() || undefined;1179  }1180  if ("cvc" in patch) {1181    nextPatch.cvc = patch.cvc?.trim() || undefined;1182  }1183  if ("cardNickname" in patch) {1184    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1185  }1186  if ("network" in patch) {1187    nextPatch.network = patch.network;1188  }1189  if ("last4" in patch) {1190    nextPatch.last4 = patch.last4?.trim() || undefined;1191  }11921193  return nextPatch;1194}11951196function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1197  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1198  const modifiedAt =1199    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12001201  return {1202    id: entry.id,1203    type: "type" in entry ? entry.type : "login",1204    title: entry.title,1205    username: entry.username,1206    password: entry.password,1207    url: entry.url,1208    totp: entry.totp,1209    notes: entry.notes,1210    breach: entry.breach,1211    canary: "canary" in entry ? entry.canary : undefined,1212    rpId: "rpId" in entry ? entry.rpId : undefined,1213    rpName: "rpName" in entry ? entry.rpName : undefined,1214    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1215    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1216    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1217    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1218    userName: "userName" in entry ? entry.userName : undefined,1219    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1220    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1221    signCount: "signCount" in entry ? entry.signCount : undefined,1222    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1223    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1224    backupState: "backupState" in entry ? entry.backupState : undefined,1225    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1226    createdAt,1227    modifiedAt,1228    deletedAt: entry.deletedAt,1229    tags: "tags" in entry ? entry.tags : undefined,1230    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1231    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1232    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1233    expYear: "expYear" in entry ? entry.expYear : undefined,1234    cvc: "cvc" in entry ? entry.cvc : undefined,1235    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1236    network: "network" in entry ? entry.network : undefined,1237    last4: "last4" in entry ? entry.last4 : undefined,1238  };1239}12401241function hasLegacyShape(parsed: VaultEntry[]): boolean {1242  return parsed.some(1243    (entry) => "created" in entry || "updated" in entry1244  );1245}12461247// ---------------------------------------------------------------------------1248// Deduplication1249// ---------------------------------------------------------------------------12501251function entryDupeKey(e: VaultEntry): string {1252  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1253  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1254  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1255}12561257/** Returns the number of duplicate entries (those that would be removed). */1258export function countDuplicates(): number {1259  const best = new Map<string, VaultEntry>();1260  let dupeCount = 0;1261  for (const e of entries.filter((e) => !e.deletedAt)) {1262    const key = entryDupeKey(e);1263    const existing = best.get(key);1264    if (!existing) {1265      best.set(key, e);1266    } else {1267      dupeCount++;1268      // Keep the more recently modified one in the map1269      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1270        best.set(key, e);1271      }1272    }1273  }1274  return dupeCount;1275}12761277/**1278 * Soft-deletes duplicate entries, keeping the most recently modified copy of1279 * each group. Returns the number of entries moved to trash.1280 */1281export async function deduplicateEntries(): Promise<number> {1282  if (!derivedKey) throw new Error("vault locked");1283  // Dedup reshapes slot B permanently under the decoy key, which is1284  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1285  if (vaultMode === "decoy") {1286    throw new Error("deduplicate disabled in decoy mode");1287  }12881289  const best = new Map<string, VaultEntry>();1290  for (const e of entries.filter((en) => !en.deletedAt)) {1291    const key = entryDupeKey(e);1292    const existing = best.get(key);1293    if (!existing) {1294      best.set(key, e);1295    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1296      best.set(key, e);1297    }1298  }12991300  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1301  const now = Date.now();1302  let removed = 0;1303  entries = entries.map((e) => {1304    if (!e.deletedAt && !keepIds.has(e.id)) {1305      removed++;1306      return { ...e, deletedAt: now };1307    }1308    return e;1309  });13101311  if (removed > 0) await persistEntries();1312  return removed;1313}13141315// ---------------------------------------------------------------------------1316// Vault snapshot system — last 6 encrypted states, used for self-recovery1317// ---------------------------------------------------------------------------13181319const SNAPSHOT_KEY = "peach_vault_snapshots";1320const MAX_SNAPSHOTS = 6;13211322interface VaultSnapshot {1323  timestamp: number;1324  entryCount: number;1325  slot: StoredVault;1326}13271328/** Public summary (no sensitive data) returned to the popup. */1329export interface SnapshotSummary {1330  index: number;1331  timestamp: number;1332  entryCount: number;1333}13341335async function takeSnapshot(1336  slot: StoredVault,1337  entryCount: number1338): Promise<void> {1339  try {1340    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1341    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1342    const next: VaultSnapshot[] = [1343      { timestamp: Date.now(), entryCount, slot },1344      ...existing,1345    ].slice(0, MAX_SNAPSHOTS);1346    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1347  } catch {1348    // Non-fatal — snapshot failure must never interrupt vault saves1349  }1350}13511352export async function listSnapshots(): Promise<SnapshotSummary[]> {1353  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1354  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1355  return snapshots.map((s, i) => ({1356    index: i,1357    timestamp: s.timestamp,1358    entryCount: s.entryCount,1359  }));1360}13611362/**1363 * Restore a snapshot by index. The password must decrypt that snapshot1364 * (i.e., the same master password used when the snapshot was taken).1365 * On success the vault is unlocked in-memory with the restored entries1366 * — no re-login required.1367 */1368export async function restoreFromSnapshot(1369  index: number,1370  password: string1371): Promise<boolean> {1372  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1373  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1374  const snapshot = snapshots[index];1375  if (!snapshot) return false;13761377  // Verify the password decrypts this snapshot1378  const key = await deriveKey(password, unhex(snapshot.slot.salt));1379  const payload = await tryDecryptSlot(snapshot.slot, key);1380  if (!payload) {1381    zeroKey(key);1382    return false;1383  }13841385  // Write the snapshot back to slot A. Regenerate noise for slot B.1386  await writeSlot(SLOT_A, snapshot.slot);1387  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));13881389  // Swap in-memory state to the restored vault — caller gets immediate access1390  if (derivedKey) zeroKey(derivedKey);1391  derivedKey = key;1392  entries = payload.entries;1393  duressConfigured = payload.duressConfigured;1394  vaultMode = "real";1395  buildDomainIndexSync(entries);1396  await loadSettings();1397  touchAutoLock();13981399  return true;1400}14011402/**1403 * Change the master password. Re-encrypts slot A with a fresh salt + new1404 * derived key. Slot B (duress or noise) is left untouched when duress is1405 * configured — it uses an independent key derived from the duress password —1406 * and regenerated as noise when duress is not configured.1407 */1408export async function changePassword(1409  currentPassword: string,1410  newPassword: string1411): Promise<boolean> {1412  if (!derivedKey || vaultMode !== "real") return false;14131414  // Verify current password before allowing any change1415  const valid = await verifyMainPassword(currentPassword);1416  if (!valid) return false;14171418  const slotA = await loadSlot(SLOT_A);1419  if (!slotA) return false;14201421  // Derive fresh key from new password + new random salt1422  const newSalt = generateSalt();1423  const newKey = await deriveKey(newPassword, newSalt);14241425  // Re-encrypt the in-memory entries with the new key1426  const plaintext = encodePayload(entries, duressConfigured);1427  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);14281429  const freshSlotA: StoredVault = {1430    ...slotA,1431    salt: hex(newSalt),1432    nonce,1433    ciphertext,1434    tag,1435  };1436  await writeSlot(SLOT_A, freshSlotA);14371438  // Swap the in-memory key synchronously before any further await. If we1439  // threw between the Slot A write and this swap, the next persistEntries()1440  // would overwrite Slot A with old-key ciphertext, locking the user out.1441  zeroKey(derivedKey);1442  derivedKey = newKey;14431444  // Snapshot now that in-memory key and disk are consistent. Pass the1445  // slot content we just wrote rather than re-reading from storage so the1446  // snapshot is immune to any microtask-coalesced read-after-write race.1447  const activeCount = entries.filter((e) => !e.deletedAt).length;1448  void takeSnapshot(freshSlotA, activeCount);14491450  // Regenerate noise for slot B when duress is not in use. Runs after the1451  // key swap so a failure here is a disposable noise regeneration, not a1452  // lockout.1453  if (!duressConfigured) {1454    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1455  }14561457  return true;1458}