vault.ts

52.7 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import type {15  AccentColorName,16  LegacyVaultEntry,17  ManualSummonRecord,18  SavedIdentity,19  StoredVault,20  VaultEntry,21} from "../shared/types";22import { decrypt, encrypt, zeroKey } from "./aes";23import { clearAutoLock, touchAutoLock } from "./alarms";2425type EntryDraft = Omit<26  VaultEntry,27  "id" | "createdAt" | "modifiedAt" | "deletedAt"28>;2930// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B31// either holds the decoy vault (when duress is configured) or random noise32// that looks identical at rest. No external indicator is stored — duress33// configuration is recorded only inside the encrypted payload of slot A.34const SLOT_A = "peach_vault_a";35const SLOT_B = "peach_vault_b";3637// Legacy keys, migrated away on first access.38const LEGACY_MAIN = "peach_vault";39const LEGACY_DECOY = "peach_decoy_vault";40const LEGACY_DURESS_FLAG = "peach_duress";4142// Plaintext is padded to the next multiple of this size before encryption.43// This coarsens ciphertext-length leakage into 1 KiB buckets.44const PAD_BLOCK = 1024;4546let derivedKey: Uint8Array | null = null;47let entries: VaultEntry[] = [];48let domainIndex: Map<string, VaultEntry[]> = new Map();49let vaultMode: "real" | "decoy" = "real";50// Cached post-unlock view of whether the real vault records that duress is51// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock52// this stays false because we never see slot A's plaintext.53let duressConfigured = false;5455let migrationRan = false;5657// ---------------------------------------------------------------------------58// Settings (persisted inside the encrypted vault store as a sidecar key)59// ---------------------------------------------------------------------------6061export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";62export type TextSize = "small" | "medium" | "large";6364export interface VaultSettings {65  accentColor?: AccentColorName;66  theme?: "dark" | "light";67  autoLockMinutes?: number;68  clipboardClearSeconds?: number;69  lockOnClose?: boolean;70  breachMonitoring?: boolean;71  passkeyProviderEnabled?: boolean;72  trashRetentionDays?: number;73  manualSummonDomains?: Record<string, ManualSummonRecord>;74  declinedCardFingerprints?: string[];75  /**76   * Display font for headlines, brand wordmark, and narrative copy. Default77   * is "cormorant-italic" — the bold italic serif that ships as Peach's78   * voice. Non-italic options exist for users who prefer upright type.79   */80  displayFont?: DisplayFont;81  /**82   * Text size scale for body copy and entry titles. Stops at "large" by83   * design — XL clips dense screens (Vault, Health) inside the fixed84   * 330×480 popup surface.85   */86  textSize?: TextSize;87  /**88   * When true, all entrance/exit transitions and ambient animations are89   * collapsed to instant. Honoured alongside the user's OS-level90   * `prefers-reduced-motion`.91   */92  reduceMotion?: boolean;93  /**94   * User overrides for keyboard shortcuts. Keys are ShortcutAction names95   * (see src/shared/shortcuts.ts). Missing actions fall through to96   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the97   * canonical lookup. Storing only the deltas keeps the vault payload98   * small and lets default updates land for users who never rebind.99   */100  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;101}102103let settings: VaultSettings = {};104105export function getSettings(): VaultSettings {106  return settings;107}108109export async function ensureSettingsLoaded(): Promise<VaultSettings> {110  await loadSettings();111  return settings;112}113114export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {115  await loadSettings();116  settings = { ...settings, ...patch };117  await persistSettings();118}119120async function persistSettings(): Promise<void> {121  try {122    await chrome.storage.local.set({ peach_settings: settings });123  } catch (err) {124    throw mapStorageError(err);125  }126}127128function mapStorageError(err: unknown): Error {129  const message = err instanceof Error ? err.message : String(err);130  if (/QUOTA_BYTES|quota/i.test(message)) {131    return new Error("STORAGE_QUOTA_EXCEEDED");132  }133  return err instanceof Error ? err : new Error(message);134}135136async function loadSettings(): Promise<void> {137  const result = await chrome.storage.local.get("peach_settings");138  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};139}140141// ---------------------------------------------------------------------------142// HIBP breach check (k-anonymity model)143// ---------------------------------------------------------------------------144145export async function checkBreach(password: string): Promise<number> {146  const encoder = new TextEncoder();147  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));148  const hashHex = Array.from(new Uint8Array(hashBuffer))149    .map((b) => b.toString(16).padStart(2, "0"))150    .join("")151    .toUpperCase();152153  const prefix = hashHex.slice(0, 5);154  const suffix = hashHex.slice(5);155156  try {157    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);158    if (!res.ok) return 0;159    const text = await res.text();160    const match = text.split("\r\n").find((l) => l.startsWith(suffix));161    return match ? parseInt(match.split(":")[1], 10) : 0;162  } catch {163    return 0; // Network unavailable — don't block164  }165}166167export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {168  const fingerprint = await cardFingerprint(last4, expMonth, expYear);169  await loadSettings();170  settings = {171    ...settings,172    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],173  };174  await persistSettings();175}176177export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {178  await loadSettings();179  const fingerprint = await cardFingerprint(last4, expMonth, expYear);180  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);181}182183async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {184  const encoder = new TextEncoder();185  const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));186  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");187}188189function hex(buf: Uint8Array): string {190  return Array.from(buf)191    .map((b) => b.toString(16).padStart(2, "0"))192    .join("");193}194195function unhex(s: string): Uint8Array {196  const out = new Uint8Array(s.length / 2);197  for (let i = 0; i < s.length; i += 2) {198    out[i / 2] = parseInt(s.slice(i, i + 2), 16);199  }200  return out;201}202203// ---------------------------------------------------------------------------204// Storage schema migration205// ---------------------------------------------------------------------------206207async function loadSlot(key: string): Promise<StoredVault | null> {208  const result = await chrome.storage.local.get(key);209  return (result as Record<string, StoredVault | undefined>)[key] ?? null;210}211212async function writeSlot(key: string, vault: StoredVault): Promise<void> {213  try {214    await chrome.storage.local.set({ [key]: vault });215  } catch (err) {216    throw mapStorageError(err);217  }218}219220/**221 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault222 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +223 * peach_vault_b). Runs at most once per session; safe to call repeatedly.224 */225async function ensureMigrated(): Promise<void> {226  if (migrationRan) return;227  migrationRan = true;228229  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];230  const raw = await chrome.storage.local.get(keys);231  const slotAExists = raw[SLOT_A] !== undefined;232  const slotBExists = raw[SLOT_B] !== undefined;233  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;234  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;235236  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.237  if (!slotAExists && !legacyMain) return;238239  // Already migrated.240  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;241242  if (legacyMain && !slotAExists) {243    await writeSlot(SLOT_A, legacyMain);244  }245246  // If slot B isn't set, populate it: use legacy decoy when present, else noise.247  if (!slotBExists) {248    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;249    if (legacyDecoy) {250      await writeSlot(SLOT_B, legacyDecoy);251    } else if (target) {252      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));253    }254  }255256  const toRemove: string[] = [];257  if (legacyMain) toRemove.push(LEGACY_MAIN);258  if (legacyDecoy) toRemove.push(LEGACY_DECOY);259  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);260  if (toRemove.length > 0) {261    await chrome.storage.local.remove(toRemove);262  }263}264265// ---------------------------------------------------------------------------266// Padding + noise helpers267// ---------------------------------------------------------------------------268269/**270 * Pads the plaintext string with trailing spaces so that the encoded byte271 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing272 * whitespace after a complete document, so the padding is transparent on273 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.274 */275function padPlaintext(json: string): string {276  const encoded = new TextEncoder().encode(json);277  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);278  const padLen = target - encoded.length;279  return json + " ".repeat(padLen);280}281282/**283 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The284 * ciphertext length matches the given byte count. Because GCM ciphertext is285 * pseudo-random, this blob is indistinguishable at rest from a real286 * encrypted vault without the key.287 *288 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto289 * spec hard limit). Chunk the fill so vaults larger than that — common once290 * the user has dozens of entries or any passkey — don't throw.291 */292const CSPRNG_CHUNK = 65_536;293294function fillRandom(buf: Uint8Array): Uint8Array {295  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {296    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);297    const chunk = new Uint8Array(end - offset);298    crypto.getRandomValues(chunk);299    buf.set(chunk, offset);300  }301  return buf;302}303304function generateNoiseBlob(ciphertextBytes: number): StoredVault {305  const salt = crypto.getRandomValues(new Uint8Array(16));306  const nonce = crypto.getRandomValues(new Uint8Array(12));307  const cipher = fillRandom(new Uint8Array(ciphertextBytes));308  const tag = crypto.getRandomValues(new Uint8Array(16));309  return {310    version: 1,311    salt: hex(salt),312    nonce: hex(nonce),313    ciphertext: hex(cipher),314    tag: hex(tag),315  };316}317318// ---------------------------------------------------------------------------319// Encrypted plaintext envelope320// ---------------------------------------------------------------------------321322type VaultPayload = {323  entries: VaultEntry[];324  duressConfigured?: boolean;325};326327type DecryptedPayload = {328  entries: VaultEntry[];329  duressConfigured: boolean;330  /**331   * True when at least one entry in the on-disk payload had a legacy shape332   * (pre-v2.11.0 passkey type/flat fields, or pre-A-2 created/updated keys)333   * and got rewritten by normalizeEntry. The unlock path uses this to334   * persist the new shape immediately — without it, the migration would335   * re-run on every unlock and old shape never disappears from disk.336   */337  migratedFromLegacy: boolean;338};339340function encodePayload(entries: VaultEntry[], duress: boolean): string {341  const envelope: VaultPayload = { entries, duressConfigured: duress };342  return padPlaintext(JSON.stringify(envelope));343}344345function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {346  if ("created" in raw || "updated" in raw) return true;347  const flat = raw as Partial<VaultEntry>;348  if (flat.type === "passkey") return true;349  // Flat rpId without a passkey sub-object means pre-v2.11.0 schema.350  if (flat.rpId !== undefined && flat.passkey === undefined) return true;351  return false;352}353354function decodePayload(plain: string): DecryptedPayload {355  const parsed = JSON.parse(plain) as356    | Array<VaultEntry | LegacyVaultEntry>357    | VaultPayload;358359  const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];360  const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);361362  const normalized = rawEntries.map(normalizeEntry);363  const { merged } = mergePasskeyOnlyIntoLogins(normalized);364365  if (Array.isArray(parsed)) {366    return {367      entries: normalized,368      duressConfigured: false,369      migratedFromLegacy: true, // pre-A-2 array shape always needs save370    };371  }372373  return {374    entries: normalized,375    duressConfigured: parsed.duressConfigured === true,376    migratedFromLegacy: flatMigrationNeeded || merged,377  };378}379380/**381 * One-time consolidation pass run alongside the legacy passkey migration.382 *383 * Pre-v2.11.0 the Chrome passkey path always created a fresh `type:"passkey"`384 * entry per registration — so users who tested registration multiple times385 * accumulated several passkey-bearing entries for the same RP, and the386 * migration to the unified shape carried that duplication forward. This387 * pass collapses the duplicates:388 *389 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.390 *   2. Within each group, the most recently-active credential wins (prefer391 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).392 *   3. If a NON-passkey login entry exists for that RP (matched by url or393 *      additionalUrls hostname), attach the winning passkey to it and394 *      soft-delete every passkey-bearing entry in the group.395 *   4. Otherwise, soft-delete every loser in the group, keeping the winner396 *      as a passkey-only entry.397 *398 * Soft delete (deletedAt) rather than splice — the trash flow keeps the399 * record around for retention and the user can restore if the heuristic400 * picked wrong. The `merged` flag goes back to decodePayload so the401 * unlock path persists the consolidated shape immediately.402 */403function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {404  let merged = false;405  const now = Date.now();406407  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);408  const byRpId = new Map<string, VaultEntry[]>();409  for (const e of candidates) {410    const rpId = e.passkey!.rpId;411    const list = byRpId.get(rpId);412    if (list) list.push(e);413    else byRpId.set(rpId, [e]);414  }415416  for (const [rpId, group] of byRpId) {417    if (group.length === 0) continue;418    group.sort((a, b) => {419      const aT =420        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;421      const bT =422        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;423      return bT - aT;424    });425    const winner = group[0]!;426427    // Look for a NON-passkey login entry matching this RP. Use the URL428    // hostname rather than a substring search so a "login.foo.com" entry429    // doesn't accidentally absorb a passkey for "foo.com".430    const loginTarget = entries.find((e) => {431      if (e.deletedAt || e.passkey || e.type !== "login") return false;432      if (e.url && extractDomain(e.url) === rpId) return true;433      for (const extra of e.additionalUrls ?? []) {434        if (extractDomain(extra) === rpId) return true;435      }436      return false;437    });438439    if (loginTarget) {440      loginTarget.passkey = winner.passkey;441      loginTarget.modifiedAt = now;442      for (const dup of group) {443        if (!dup.deletedAt) {444          dup.deletedAt = now;445          dup.modifiedAt = now;446        }447      }448      merged = true;449    } else if (group.length > 1) {450      for (let i = 1; i < group.length; i += 1) {451        const dup = group[i]!;452        if (!dup.deletedAt) {453          dup.deletedAt = now;454          dup.modifiedAt = now;455        }456      }457      merged = true;458    }459  }460461  return { merged };462}463464async function tryDecryptSlot(465  vault: StoredVault,466  key: Uint8Array467): Promise<DecryptedPayload | null> {468  try {469    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);470    return decodePayload(plain);471  } catch {472    return null;473  }474}475476// ---------------------------------------------------------------------------477// Core operations478// ---------------------------------------------------------------------------479480function buildDomainIndexSync(all: VaultEntry[]): void {481  domainIndex = new Map();482483  for (const entry of all) {484    if (entry.deletedAt) continue;485    if (entry.type === "note" || entry.type === "card") continue;486487    // An entry can match multiple sites via `additionalUrls`. Index each488    // hostname so `queryDomain` returns the entry regardless of which alias489    // the user is currently visiting. Dedupe per-entry so a single entry490    // never appears twice in one bucket.491    const domains = new Set<string>();492    const primary = extractDomain(entry.url ?? "");493    if (primary) domains.add(primary);494    for (const extra of entry.additionalUrls ?? []) {495      const d = extractDomain(extra);496      if (d) domains.add(d);497    }498    if (domains.size === 0) continue;499500    for (const domain of domains) {501      const bucket = domainIndex.get(domain) ?? [];502      bucket.push(entry);503      domainIndex.set(domain, bucket);504    }505  }506}507508export async function createVault(password: string): Promise<void> {509  await ensureMigrated();510511  const salt = generateSalt();512  derivedKey = await deriveKey(password, salt);513  entries = [];514  vaultMode = "real";515  duressConfigured = false;516  buildDomainIndexSync(entries);517518  const plaintext = encodePayload(entries, duressConfigured);519  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);520521  const slotA: StoredVault = {522    version: 1,523    salt: hex(salt),524    nonce,525    ciphertext,526    tag,527  };528  await writeSlot(SLOT_A, slotA);529530  // Matching-size noise blob for slot B, so a fresh install shows two531  // similarly-shaped records regardless of whether duress is ever set up.532  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));533534  touchAutoLock();535}536537export async function hasVault(): Promise<boolean> {538  await ensureMigrated();539  return (await loadSlot(SLOT_A)) !== null;540}541542/**543 * Returns whether the real vault has recorded that a duress passphrase is544 * configured. Reliable only after a real-mode unlock — before unlock or in545 * decoy mode, returns false because we cannot read slot A's plaintext.546 */547export async function isDuressEnabled(): Promise<boolean> {548  return vaultMode === "real" && duressConfigured;549}550551export async function hasDecoyVault(): Promise<boolean> {552  return isDuressEnabled();553}554555export async function setupDuress(duressPassword: string): Promise<void> {556  if (!derivedKey || vaultMode !== "real") {557    throw new Error("vault must be unlocked in real mode");558  }559  await ensureMigrated();560561  // Encrypt the decoy payload (empty entries) under the duress key. Pad to562  // at least the current slot A ciphertext size so the two slots look the563  // same at rest at setup time.564  const slotA = await loadSlot(SLOT_A);565  if (!slotA) throw new Error("missing slot A");566567  const decoySalt = generateSalt();568  const decoyKey = await deriveKey(duressPassword, decoySalt);569  try {570    // Pad the decoy plaintext to match slot A's ciphertext length (minus the571    // 16-byte tag) so encrypted sizes align exactly.572    const targetBytes = slotA.ciphertext.length / 2;573    let plaintext = encodePayload([], false);574    const encoded = new TextEncoder().encode(plaintext);575    if (encoded.length < targetBytes) {576      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);577    }578    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);579    await writeSlot(SLOT_B, {580      version: 1,581      salt: hex(decoySalt),582      nonce,583      ciphertext,584      tag,585    });586  } finally {587    zeroKey(decoyKey);588  }589590  // Re-encrypt slot A with the duressConfigured flag set to true. No external591  // flag is written — the bit lives inside the encrypted envelope.592  duressConfigured = true;593  await persistSlotA();594}595596export async function disableDuress(): Promise<void> {597  if (!derivedKey || vaultMode !== "real") {598    throw new Error("vault must be unlocked in real mode");599  }600  await ensureMigrated();601602  // Overwrite slot B with fresh noise that matches slot A's size. The slot603  // remains occupied so its mere existence does not signal that duress was604  // ever configured.605  const slotA = await loadSlot(SLOT_A);606  if (slotA) {607    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));608  }609610  duressConfigured = false;611  await persistSlotA();612}613614export async function verifyMainPassword(password: string): Promise<boolean> {615  await ensureMigrated();616  const slotA = await loadSlot(SLOT_A);617  if (!slotA) return false;618  const key = await deriveKey(password, unhex(slotA.salt));619  try {620    return (await tryDecryptSlot(slotA, key)) !== null;621  } finally {622    zeroKey(key);623  }624}625626export async function unlock(password: string): Promise<boolean> {627  await ensureMigrated();628629  // Load both slots in parallel. Both always exist post-migration; the630  // parallel load and the parallel derivation below keep wall-clock time631  // constant regardless of which slot's passphrase (real or decoy) the632  // caller supplied, so a timing side channel cannot distinguish the633  // duress path from the real-unlock path (DUR-002).634  const [slotA, slotB] = await Promise.all([635    loadSlot(SLOT_A),636    loadSlot(SLOT_B),637  ]);638639  // Derive both keys in parallel, unconditionally.640  const [keyA, keyB] = await Promise.all([641    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),642    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),643  ]);644645  // Attempt both decrypts in parallel.646  const [payloadA, payloadB] = await Promise.all([647    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),648    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),649  ]);650651  if (payloadA && keyA) {652    derivedKey = keyA;653    vaultMode = "real";654    entries = payloadA.entries;655    duressConfigured = payloadA.duressConfigured;656    buildDomainIndexSync(entries);657    await loadSettings();658    touchAutoLock();659    if (keyB) zeroKey(keyB);660    // If the payload had a pre-v2.11.0 shape (legacy passkey type, flat661    // passkey fields, or pre-A-2 created/updated keys), rewrite it now so662    // the next save uses the new format and migration doesn't re-run forever.663    if (payloadA.migratedFromLegacy) {664      await persistSlotA();665    }666    return true;667  }668669  if (payloadB && keyB) {670    derivedKey = keyB;671    vaultMode = "decoy";672    entries = payloadB.entries;673    duressConfigured = false;674    buildDomainIndexSync(entries);675    await loadSettings();676    touchAutoLock();677    if (keyA) zeroKey(keyA);678    return true;679  }680681  if (keyA) zeroKey(keyA);682  if (keyB) zeroKey(keyB);683  return false;684}685686export function lock(): void {687  if (derivedKey) {688    zeroKey(derivedKey);689    derivedKey = null;690  }691692  entries = [];693  domainIndex = new Map();694  vaultMode = "real";695  duressConfigured = false;696  clearAutoLock();697}698699export function isUnlocked(): boolean {700  return derivedKey !== null;701}702703export function getEntries(): VaultEntry[] {704  if (!derivedKey) throw new Error("vault locked");705  return entries.filter((entry) => !entry.deletedAt);706}707708const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;709710export function getIdentities(): SavedIdentity[] {711  if (!derivedKey) throw new Error("vault locked");712  const active = entries.filter((e) => !e.deletedAt && e.type === "login");713714  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();715  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();716717  for (const entry of active) {718    const value = entry.username?.trim();719    if (!value) continue;720    const map = EMAIL_RE.test(value) ? emailMap : usernameMap;721    const existing = map.get(value);722    const domain = extractDomain(entry.url ?? "") ?? "";723    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;724    if (!existing || modifiedAt > existing.lastUsed) {725      map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });726    } else {727      map.set(value, { ...existing, count: existing.count + 1 });728    }729  }730731  const toIdentity = (type: "email" | "username") =>732    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({733      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,734    });735736  return [737    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),738    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),739  ];740}741742export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {743  if (!derivedKey) throw new Error("vault locked");744745  const now = Date.now();746  const added: VaultEntry[] = [];747  for (const draft of drafts) {748    try {749      const sanitized = sanitizeEntryDraft(draft);750      const nextEntry: VaultEntry = {751        ...sanitized,752        id: crypto.randomUUID(),753        createdAt: now,754        modifiedAt: now,755      };756      entries.push(nextEntry);757      added.push(nextEntry);758    } catch {759      // Skip invalid drafts silently — caller can see diff between input and output length760    }761  }762763  if (added.length > 0) {764    await persistEntries();765  }766767  return added;768}769770/**771 * Restore a set of entries in full fidelity — preserves id, createdAt,772 * modifiedAt, and every other field unlike `batchAddEntries` which773 * assigns fresh identity. Used by restore paths where the backup is the774 * authoritative source of truth. `mode` controls how entries already in775 * the vault are treated.776 */777export async function restoreEntries(778  incoming: VaultEntry[],779  mode: "replace" | "merge"780): Promise<{ added: number; updated: number; skipped: number }> {781  if (!derivedKey) throw new Error("vault locked");782783  // A replace with zero incoming entries is almost certainly a caller bug784  // (e.g. an import that silently dropped everything). Refuse rather than785  // wipe the vault.786  if (mode === "replace" && incoming.length === 0) {787    throw new Error("refuse to replace with empty entry set");788  }789790  let added = 0;791  let updated = 0;792  let skipped = 0;793794  if (mode === "replace") {795    entries = [];796    for (const entry of incoming) {797      const normalized = sanitizeRestoredEntry(entry);798      if (!normalized) {799        skipped += 1;800        continue;801      }802      entries.push(normalized);803      added += 1;804    }805  } else {806    const byId = new Map<string, VaultEntry>();807    for (const e of entries) byId.set(e.id, e);808809    for (const entry of incoming) {810      const normalized = sanitizeRestoredEntry(entry);811      if (!normalized) {812        skipped += 1;813        continue;814      }815      const existing = byId.get(normalized.id);816      if (!existing) {817        entries.push(normalized);818        added += 1;819      } else if (normalized.modifiedAt > existing.modifiedAt) {820        const idx = entries.findIndex((e) => e.id === existing.id);821        if (idx !== -1) {822          entries[idx] = normalized;823          updated += 1;824        }825      } else {826        skipped += 1;827      }828    }829  }830831  await persistEntries();832  return { added, updated, skipped };833}834835/**836 * Validate + normalise a VaultEntry coming from a backup. Accepts837 * anything structurally sound; keeps all metadata verbatim. Returns838 * null for values the live vault shouldn't accept (e.g., missing title).839 */840function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {841  if (!entry || typeof entry !== "object") return null;842  const title = typeof entry.title === "string" ? entry.title.trim() : "";843  if (!title) return null;844  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();845  const now = Date.now();846  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;847  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;848849  // Migrate legacy passkey shape on import (same logic as on-disk read).850  // Imports from pre-v2.11.0 vault backups still carry type:"passkey" with851  // flat fields; bring them into the new shape immediately.852  const migrated: VaultEntry = migrateLegacyPasskey(853    {854      ...entry,855      id,856      title,857      type: entry.type ?? "login",858      createdAt,859      modifiedAt,860    },861    entry862  );863864  // Login entries from backup must carry a password (or be passkey-only,865  // in which case the passkey sub-object replaces the password requirement).866  if (867    migrated.type === "login" &&868    !migrated.passkey &&869    typeof migrated.password !== "string"870  ) {871    return null;872  }873  if (migrated.passkey && !migrated.passkey.credentialId) {874    return null;875  }876877  return migrated;878}879880// Soft-deletes every non-deleted entry in one call. The literal string881// argument is a tripwire against accidental calls — any caller that would882// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this883// to a boolean.884export async function clearVaultEntries(885  confirmation: "REPLACE_ALL"886): Promise<void> {887  if (confirmation !== "REPLACE_ALL") {888    throw new Error("clearVaultEntries requires explicit confirmation");889  }890  if (!derivedKey) throw new Error("vault locked");891  for (const entry of entries) {892    if (!entry.deletedAt) {893      entry.deletedAt = Date.now();894      entry.modifiedAt = Date.now();895    }896  }897  await persistEntries();898}899900export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {901  if (!derivedKey) throw new Error("vault locked");902903  const sanitized = sanitizeEntryDraft(entry);904  const now = Date.now();905  const nextEntry: VaultEntry = {906    ...sanitized,907    id: crypto.randomUUID(),908    createdAt: now,909    modifiedAt: now,910    // Stamp passwordChangedAt at creation so age tracking is accurate from911    // day one. Non-login entries carry the same stamp harmlessly.912    passwordChangedAt: sanitized.password ? now : undefined,913  };914915  entries.push(nextEntry);916  await persistEntries();917918  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)919  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {920    checkBreach(nextEntry.password).then(async (count) => {921      if (count > 0) {922        const idx = entries.findIndex((e) => e.id === nextEntry.id);923        if (idx !== -1) {924          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };925          await persistEntries();926        }927      }928    });929  }930931  return nextEntry;932}933934export async function updateEntry(935  id: string,936  patch: Partial<VaultEntry>937): Promise<void> {938  if (!derivedKey) throw new Error("vault locked");939940  const idx = entries.findIndex((entry) => entry.id === id);941  if (idx === -1 || entries[idx].deletedAt) {942    throw new Error("entry not found");943  }944945  const current = entries[idx];946  const sanitizedPatch = sanitizeEntryPatch(patch);947  // Refresh passwordChangedAt only when the password value actually changes —948  // a metadata-only update (title, tags, etc.) must not reset the age clock949  // that drives the Pro health rotation recommendation.950  const passwordChanged =951    "password" in patch &&952    typeof sanitizedPatch.password === "string" &&953    sanitizedPatch.password !== current.password;954  entries[idx] = {955    ...current,956    ...sanitizedPatch,957    modifiedAt: Date.now(),958    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),959  };960961  // A login entry must carry SOMETHING fillable — either a password or a962  // passkey credential. An empty-password login with no passkey is dead963  // state. Permit the password-clear when a passkey is attached (that's964  // the legitimate "drop the password, keep the passkey" path).965  if (966    entries[idx].type === "login" &&967    !entries[idx].password &&968    !entries[idx].passkey969  ) {970    entries[idx] = current;971    throw new Error("password or passkey required");972  }973974  // Card entries must retain their card number975  if (entries[idx].type === "card" && !entries[idx].cardNumber) {976    entries[idx] = current;977    throw new Error("card number is required for card entries");978  }979980  await persistEntries();981982  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)983  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {984    const password = entries[idx].password!;985    checkBreach(password).then(async (count) => {986      const i = entries.findIndex((e) => e.id === id);987      if (i !== -1) {988        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };989        await persistEntries();990      }991    });992  }993}994995export async function deleteEntry(id: string): Promise<void> {996  if (!derivedKey) throw new Error("vault locked");997998  const entry = entries.find((item) => item.id === id);999  if (!entry || entry.deletedAt) {1000    throw new Error("entry not found");1001  }10021003  entry.deletedAt = Date.now();1004  entry.modifiedAt = Date.now();1005  await persistEntries();1006}10071008export function getDeletedEntries(): VaultEntry[] {1009  if (!derivedKey) return [];1010  return entries.filter((e) => !!e.deletedAt);1011}10121013export async function restoreEntry(id: string): Promise<void> {1014  if (!derivedKey) throw new Error("vault locked");1015  const entry = entries.find((e) => e.id === id && e.deletedAt);1016  if (!entry) throw new Error("deleted entry not found");1017  delete entry.deletedAt;1018  entry.modifiedAt = Date.now();1019  await persistEntries();1020  buildDomainIndexSync(entries);1021}10221023export async function purgeEntry(id: string): Promise<void> {1024  if (!derivedKey) throw new Error("vault locked");1025  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1026  if (idx === -1) throw new Error("deleted entry not found");1027  entries.splice(idx, 1);1028  await persistEntries();1029}10301031/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */1032export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1033  if (!derivedKey || retentionMs === 0) return 0;1034  const cutoff = Date.now() - retentionMs;1035  const before = entries.length;1036  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1037  const purged = before - entries.length;1038  if (purged > 0) await persistEntries();1039  return purged;1040}10411042export function queryDomain(domain: string): VaultEntry[] {1043  return domainIndex.get(domain) ?? [];1044}10451046export function getEntryById(id: string): VaultEntry | undefined {1047  return entries.find((entry) => entry.id === id && !entry.deletedAt);1048}10491050async function persistSlotA(): Promise<StoredVault> {1051  if (!derivedKey) throw new Error("vault locked");10521053  const slotA = await loadSlot(SLOT_A);1054  if (!slotA) throw new Error("missing slot A");10551056  const plaintext = encodePayload(entries, duressConfigured);1057  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);10581059  const nextSlotA: StoredVault = {1060    ...slotA,1061    nonce,1062    ciphertext,1063    tag,1064  };1065  await writeSlot(SLOT_A, nextSlotA);10661067  // When duress is not configured, keep slot B size-matched with slot A by1068  // regenerating its noise. When duress IS configured, leave slot B alone —1069  // we don't have the decoy key to re-encrypt it. This is a known trade-off.1070  if (!duressConfigured) {1071    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1072  }10731074  return nextSlotA;1075}10761077async function persistSlotB(): Promise<void> {1078  if (!derivedKey) throw new Error("vault locked");10791080  const slotB = await loadSlot(SLOT_B);1081  if (!slotB) throw new Error("missing slot B");10821083  // Decoy plaintext always reports duressConfigured=false — the decoy user1084  // sees a clean slate.1085  const plaintext = encodePayload(entries, false);1086  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);10871088  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1089}10901091async function persistEntries(): Promise<void> {1092  if (!derivedKey) throw new Error("vault locked");10931094  if (vaultMode === "decoy") {1095    await persistSlotB();1096  } else {1097    const freshSlotA = await persistSlotA();1098    // Save a snapshot of the real vault after every successful write.1099    // Failures are silently swallowed — snapshots must never interrupt saves.1100    const activeCount = entries.filter((e) => !e.deletedAt).length;1101    void takeSnapshot(freshSlotA, activeCount);1102  }1103  buildDomainIndexSync(entries);1104}11051106function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1107  if (!Array.isArray(values)) return undefined;1108  const seen = new Set<string>();1109  const cleaned: string[] = [];1110  for (const value of values) {1111    const trimmed = typeof value === "string" ? value.trim() : "";1112    if (!trimmed) continue;1113    const key = trimmed.toLowerCase();1114    if (seen.has(key)) continue;1115    seen.add(key);1116    cleaned.push(trimmed);1117  }1118  return cleaned.length > 0 ? cleaned : undefined;1119}11201121function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1122  const title = entry.title.trim();1123  // Accept legacy `type:"passkey"` drafts but rewrite to "login" — the new1124  // model is "any entry, optionally with a passkey sub-object." Imports and1125  // any in-flight callers from before v2.11.0 keep working without throwing.1126  let type: VaultEntry["type"] = entry.type ?? "login";1127  if (type === "passkey") type = "login";11281129  if (!title) throw new Error("title is required");1130  // A login entry needs either a password or a passkey credential to be useful.1131  // Passkey-only entries (passkey present, no password) are valid.1132  if (type === "login" && !entry.password?.trim() && !entry.passkey) {1133    throw new Error("password or passkey required");1134  }1135  if (entry.passkey) {1136    const p = entry.passkey;1137    if (1138      !p.rpId ||1139      !p.credentialId ||1140      !p.publicKeyCose ||1141      !p.userHandle ||1142      !p.userName ||1143      (p.algorithm !== -7 && p.algorithm !== -8)1144    ) {1145      throw new Error("invalid passkey credential");1146    }1147  }1148  if (type === "card") {1149    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1150      throw new Error("cardholder name and card number are required");1151    }1152  }11531154  return {1155    type,1156    title,1157    username: type === "login" ? entry.username?.trim() ?? "" : undefined,1158    password: type === "login" ? entry.password : undefined,1159    url: type === "login" ? entry.url?.trim() ?? "" : undefined,1160    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1161    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1162    notes: entry.notes?.trim() || undefined,1163    breach: type === "login" ? entry.breach : undefined,1164    canary: type === "login" ? entry.canary : undefined,1165    tags: entry.tags,1166    passkey: entry.passkey,1167    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1168    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1169    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1170    expYear: type === "card" ? entry.expYear?.trim() : undefined,1171    cvc: type === "card" ? entry.cvc?.trim() : undefined,1172    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1173    network: type === "card" ? entry.network : undefined,1174    last4: type === "card" ? entry.last4?.trim() : undefined,1175  };1176}11771178function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1179  const nextPatch: Partial<VaultEntry> = {};11801181  if ("type" in patch) {1182    nextPatch.type = patch.type;1183  }11841185  if ("title" in patch) {1186    const title = patch.title?.trim() ?? "";1187    if (!title) throw new Error("title is required");1188    nextPatch.title = title;1189  }11901191  if ("url" in patch) {1192    nextPatch.url = patch.url?.trim() ?? "";1193  }11941195  if ("additionalUrls" in patch) {1196    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1197    const seen = new Set<string>();1198    const cleaned: string[] = [];1199    for (const value of incoming) {1200      const trimmed = typeof value === "string" ? value.trim() : "";1201      if (!trimmed) continue;1202      const key = trimmed.toLowerCase();1203      if (seen.has(key)) continue;1204      seen.add(key);1205      cleaned.push(trimmed);1206    }1207    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1208  }12091210  if ("username" in patch) {1211    nextPatch.username = patch.username?.trim() ?? "";1212  }12131214  if ("password" in patch) {1215    const password = patch.password ?? "";1216    // Allow clearing a password (e.g. on passkey-only entries)1217    nextPatch.password = password || undefined;1218  }12191220  if ("totp" in patch) {1221    nextPatch.totp = patch.totp?.trim() || undefined;1222  }12231224  if ("notes" in patch) {1225    nextPatch.notes = patch.notes?.trim() || undefined;1226  }12271228  if ("breach" in patch) {1229    nextPatch.breach = patch.breach;1230  }12311232  if ("canary" in patch) {1233    nextPatch.canary = patch.canary;1234  }12351236  if ("passkey" in patch) {1237    // Passkey sub-object replaces all the legacy flat passkey fields. A1238    // patch with `passkey: undefined` removes the credential from the entry1239    // (used by "remove passkey" without deleting the whole entry).1240    nextPatch.passkey = patch.passkey;1241  }12421243  if ("tags" in patch) {1244    nextPatch.tags = patch.tags;1245  }12461247  if ("cardholderName" in patch) {1248    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1249  }1250  if ("cardNumber" in patch) {1251    const n = patch.cardNumber ?? "";1252    nextPatch.cardNumber = n || undefined;1253  }1254  if ("expMonth" in patch) {1255    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1256  }1257  if ("expYear" in patch) {1258    nextPatch.expYear = patch.expYear?.trim() || undefined;1259  }1260  if ("cvc" in patch) {1261    nextPatch.cvc = patch.cvc?.trim() || undefined;1262  }1263  if ("cardNickname" in patch) {1264    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1265  }1266  if ("network" in patch) {1267    nextPatch.network = patch.network;1268  }1269  if ("last4" in patch) {1270    nextPatch.last4 = patch.last4?.trim() || undefined;1271  }12721273  return nextPatch;1274}12751276function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1277  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1278  const modifiedAt =1279    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12801281  const base: VaultEntry = {1282    id: entry.id,1283    type: "type" in entry ? entry.type : "login",1284    title: entry.title,1285    username: entry.username,1286    password: entry.password,1287    url: entry.url,1288    totp: entry.totp,1289    notes: entry.notes,1290    breach: entry.breach,1291    canary: "canary" in entry ? entry.canary : undefined,1292    passkey: "passkey" in entry ? entry.passkey : undefined,1293    createdAt,1294    modifiedAt,1295    deletedAt: entry.deletedAt,1296    tags: "tags" in entry ? entry.tags : undefined,1297    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1298    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1299    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1300    expYear: "expYear" in entry ? entry.expYear : undefined,1301    cvc: "cvc" in entry ? entry.cvc : undefined,1302    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1303    network: "network" in entry ? entry.network : undefined,1304    last4: "last4" in entry ? entry.last4 : undefined,1305  };13061307  return migrateLegacyPasskey(base, entry);1308}13091310/**1311 * v2.11.0 read migration. Pre-v2.11.0 passkey entries used `type:"passkey"`1312 * with flat top-level fields (rpId, credentialId, etc.). This converts them1313 * to the new shape — `type:"login"` with a `passkey` sub-object — so all1314 * downstream consumers can rely on the new model. The result is rewritten1315 * to disk on next vault save (hasLegacyShape detects the un-migrated case).1316 */1317function migrateLegacyPasskey(1318  base: VaultEntry,1319  raw: VaultEntry | LegacyVaultEntry1320): VaultEntry {1321  if (base.passkey) return base;1322  const flat = raw as Partial<VaultEntry>;1323  const isPasskeyType = base.type === "passkey";1324  const hasFlatPasskeyFields =1325    typeof flat.rpId === "string" &&1326    typeof flat.credentialId === "string" &&1327    typeof flat.publicKeyCose === "string" &&1328    typeof flat.userHandle === "string" &&1329    typeof flat.userName === "string";1330  if (!isPasskeyType && !hasFlatPasskeyFields) return base;1331  if (!hasFlatPasskeyFields) return base;13321333  const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1334  base.passkey = {1335    rpId: flat.rpId!,1336    rpName: flat.rpName ?? flat.rpId!,1337    credentialId: flat.credentialId!,1338    publicKeyCose: flat.publicKeyCose!,1339    privateKeyJwk: flat.privateKeyJwk,1340    userHandle: flat.userHandle!,1341    userName: flat.userName!,1342    userDisplayName: flat.userDisplayName ?? flat.userName!,1343    algorithm,1344    signCount: flat.signCount ?? 0,1345    aaguid: flat.aaguid ?? "",1346    backupEligible: flat.backupEligible === true,1347    backupState: flat.backupState === true,1348    lastUsedAt: flat.lastUsedAt,1349    browser: flat.browser,1350    transports: flat.transports,1351    createdAt: base.createdAt,1352  };1353  // Promote to "login" so the unified Entry view renders. Username gets1354  // populated from the passkey's userName so the entry is searchable in the1355  // existing autofill flow without a separate code path.1356  if (isPasskeyType) base.type = "login";1357  if (!base.username) base.username = flat.userName;1358  if (!base.url) base.url = `https://${flat.rpId}`;1359  return base;1360}136113621363// ---------------------------------------------------------------------------1364// Deduplication1365// ---------------------------------------------------------------------------13661367function entryDupeKey(e: VaultEntry): string {1368  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1369  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins1370  // on different sites that happen to share a username are still distinct;1371  // two passkey credentials for the same RP+user are duplicates regardless1372  // of any login fields they may also carry.1373  if (e.passkey) {1374    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1375  }1376  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1377}13781379/** Returns the number of duplicate entries (those that would be removed). */1380export function countDuplicates(): number {1381  const best = new Map<string, VaultEntry>();1382  let dupeCount = 0;1383  for (const e of entries.filter((e) => !e.deletedAt)) {1384    const key = entryDupeKey(e);1385    const existing = best.get(key);1386    if (!existing) {1387      best.set(key, e);1388    } else {1389      dupeCount++;1390      // Keep the more recently modified one in the map1391      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1392        best.set(key, e);1393      }1394    }1395  }1396  return dupeCount;1397}13981399/**1400 * Soft-deletes duplicate entries, keeping the most recently modified copy of1401 * each group. Returns the number of entries moved to trash.1402 */1403export async function deduplicateEntries(): Promise<number> {1404  if (!derivedKey) throw new Error("vault locked");1405  // Dedup reshapes slot B permanently under the decoy key, which is1406  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1407  if (vaultMode === "decoy") {1408    throw new Error("deduplicate disabled in decoy mode");1409  }14101411  const best = new Map<string, VaultEntry>();1412  for (const e of entries.filter((en) => !en.deletedAt)) {1413    const key = entryDupeKey(e);1414    const existing = best.get(key);1415    if (!existing) {1416      best.set(key, e);1417    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1418      best.set(key, e);1419    }1420  }14211422  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1423  const now = Date.now();1424  let removed = 0;1425  entries = entries.map((e) => {1426    if (!e.deletedAt && !keepIds.has(e.id)) {1427      removed++;1428      return { ...e, deletedAt: now };1429    }1430    return e;1431  });14321433  if (removed > 0) await persistEntries();1434  return removed;1435}14361437// ---------------------------------------------------------------------------1438// Vault snapshot system — last 6 encrypted states, used for self-recovery1439// ---------------------------------------------------------------------------14401441const SNAPSHOT_KEY = "peach_vault_snapshots";1442const MAX_SNAPSHOTS = 6;14431444interface VaultSnapshot {1445  timestamp: number;1446  entryCount: number;1447  slot: StoredVault;1448}14491450/** Public summary (no sensitive data) returned to the popup. */1451export interface SnapshotSummary {1452  index: number;1453  timestamp: number;1454  entryCount: number;1455}14561457async function takeSnapshot(1458  slot: StoredVault,1459  entryCount: number1460): Promise<void> {1461  try {1462    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1463    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1464    const next: VaultSnapshot[] = [1465      { timestamp: Date.now(), entryCount, slot },1466      ...existing,1467    ].slice(0, MAX_SNAPSHOTS);1468    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1469  } catch {1470    // Non-fatal — snapshot failure must never interrupt vault saves1471  }1472}14731474export async function listSnapshots(): Promise<SnapshotSummary[]> {1475  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1476  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1477  return snapshots.map((s, i) => ({1478    index: i,1479    timestamp: s.timestamp,1480    entryCount: s.entryCount,1481  }));1482}14831484/**1485 * Restore a snapshot by index. The password must decrypt that snapshot1486 * (i.e., the same master password used when the snapshot was taken).1487 * On success the vault is unlocked in-memory with the restored entries1488 * — no re-login required.1489 */1490export async function restoreFromSnapshot(1491  index: number,1492  password: string1493): Promise<boolean> {1494  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1495  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1496  const snapshot = snapshots[index];1497  if (!snapshot) return false;14981499  // Verify the password decrypts this snapshot1500  const key = await deriveKey(password, unhex(snapshot.slot.salt));1501  const payload = await tryDecryptSlot(snapshot.slot, key);1502  if (!payload) {1503    zeroKey(key);1504    return false;1505  }15061507  // Write the snapshot back to slot A. Regenerate noise for slot B.1508  await writeSlot(SLOT_A, snapshot.slot);1509  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));15101511  // Swap in-memory state to the restored vault — caller gets immediate access1512  if (derivedKey) zeroKey(derivedKey);1513  derivedKey = key;1514  entries = payload.entries;1515  duressConfigured = payload.duressConfigured;1516  vaultMode = "real";1517  buildDomainIndexSync(entries);1518  await loadSettings();1519  touchAutoLock();15201521  return true;1522}15231524/**1525 * Change the master password. Re-encrypts slot A with a fresh salt + new1526 * derived key. Slot B (duress or noise) is left untouched when duress is1527 * configured — it uses an independent key derived from the duress password —1528 * and regenerated as noise when duress is not configured.1529 */1530export async function changePassword(1531  currentPassword: string,1532  newPassword: string1533): Promise<boolean> {1534  if (!derivedKey || vaultMode !== "real") return false;15351536  // Verify current password before allowing any change1537  const valid = await verifyMainPassword(currentPassword);1538  if (!valid) return false;15391540  const slotA = await loadSlot(SLOT_A);1541  if (!slotA) return false;15421543  // Derive fresh key from new password + new random salt1544  const newSalt = generateSalt();1545  const newKey = await deriveKey(newPassword, newSalt);15461547  // Re-encrypt the in-memory entries with the new key1548  const plaintext = encodePayload(entries, duressConfigured);1549  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);15501551  const freshSlotA: StoredVault = {1552    ...slotA,1553    salt: hex(newSalt),1554    nonce,1555    ciphertext,1556    tag,1557  };1558  await writeSlot(SLOT_A, freshSlotA);15591560  // Swap the in-memory key synchronously before any further await. If we1561  // threw between the Slot A write and this swap, the next persistEntries()1562  // would overwrite Slot A with old-key ciphertext, locking the user out.1563  zeroKey(derivedKey);1564  derivedKey = newKey;15651566  // Snapshot now that in-memory key and disk are consistent. Pass the1567  // slot content we just wrote rather than re-reading from storage so the1568  // snapshot is immune to any microtask-coalesced read-after-write race.1569  const activeCount = entries.filter((e) => !e.deletedAt).length;1570  void takeSnapshot(freshSlotA, activeCount);15711572  // Regenerate noise for slot B when duress is not in use. Runs after the1573  // key swap so a failure here is a disposable noise regeneration, not a1574  // lockout.1575  if (!duressConfigured) {1576    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1577  }15781579  return true;1580}