vault.ts

55.0 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import type {15  AccentColorName,16  LegacyVaultEntry,17  ManualSummonRecord,18  SavedIdentity,19  StoredVault,20  VaultEntry,21  VaultHistory,22  VaultSnapshot,23} from "../shared/types";24import { decrypt, encrypt, zeroKey } from "./aes";25import { clearAutoLock, touchAutoLock } from "./alarms";2627type EntryDraft = Omit<28  VaultEntry,29  "id" | "createdAt" | "modifiedAt" | "deletedAt"30>;3132// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B33// either holds the decoy vault (when duress is configured) or random noise34// that looks identical at rest. No external indicator is stored — duress35// configuration is recorded only inside the encrypted payload of slot A.36const SLOT_A = "peach_vault_a";37const SLOT_B = "peach_vault_b";3839// Legacy keys, migrated away on first access.40const LEGACY_MAIN = "peach_vault";41const LEGACY_DECOY = "peach_decoy_vault";42const LEGACY_DURESS_FLAG = "peach_duress";4344// Plaintext is padded to the next multiple of this size before encryption.45// This coarsens ciphertext-length leakage into 1 KiB buckets.46const PAD_BLOCK = 1024;4748let derivedKey: Uint8Array | null = null;49let entries: VaultEntry[] = [];50let domainIndex: Map<string, VaultEntry[]> = new Map();51let vaultMode: "real" | "decoy" = "real";52// Cached post-unlock view of whether the real vault records that duress is53// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock54// this stays false because we never see slot A's plaintext.55let duressConfigured = false;5657let migrationRan = false;5859// ---------------------------------------------------------------------------60// Settings (persisted inside the encrypted vault store as a sidecar key)61// ---------------------------------------------------------------------------6263export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";64export type TextSize = "small" | "medium" | "large";6566export interface VaultSettings {67  accentColor?: AccentColorName;68  theme?: "dark" | "light";69  autoLockMinutes?: number;70  clipboardClearSeconds?: number;71  lockOnClose?: boolean;72  breachMonitoring?: boolean;73  passkeyProviderEnabled?: boolean;74  trashRetentionDays?: number;75  manualSummonDomains?: Record<string, ManualSummonRecord>;76  declinedCardFingerprints?: string[];77  /**78   * Display font for headlines, brand wordmark, and narrative copy. Default79   * is "cormorant-italic" — the bold italic serif that ships as Peach's80   * voice. Non-italic options exist for users who prefer upright type.81   */82  displayFont?: DisplayFont;83  /**84   * Text size scale for body copy and entry titles. Stops at "large" by85   * design — XL clips dense screens (Vault, Health) inside the fixed86   * 330×480 popup surface.87   */88  textSize?: TextSize;89  /**90   * When true, all entrance/exit transitions and ambient animations are91   * collapsed to instant. Honoured alongside the user's OS-level92   * `prefers-reduced-motion`.93   */94  reduceMotion?: boolean;95  /**96   * User overrides for keyboard shortcuts. Keys are ShortcutAction names97   * (see src/shared/shortcuts.ts). Missing actions fall through to98   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the99   * canonical lookup. Storing only the deltas keeps the vault payload100   * small and lets default updates land for users who never rebind.101   */102  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;103}104105let settings: VaultSettings = {};106107export function getSettings(): VaultSettings {108  return settings;109}110111export async function ensureSettingsLoaded(): Promise<VaultSettings> {112  await loadSettings();113  return settings;114}115116export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {117  await loadSettings();118  settings = { ...settings, ...patch };119  await persistSettings();120}121122async function persistSettings(): Promise<void> {123  try {124    await chrome.storage.local.set({ peach_settings: settings });125  } catch (err) {126    throw mapStorageError(err);127  }128}129130function mapStorageError(err: unknown): Error {131  const message = err instanceof Error ? err.message : String(err);132  if (/QUOTA_BYTES|quota/i.test(message)) {133    return new Error("STORAGE_QUOTA_EXCEEDED");134  }135  return err instanceof Error ? err : new Error(message);136}137138async function loadSettings(): Promise<void> {139  const result = await chrome.storage.local.get("peach_settings");140  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};141}142143// ---------------------------------------------------------------------------144// HIBP breach check (k-anonymity model)145// ---------------------------------------------------------------------------146147export async function checkBreach(password: string): Promise<number> {148  const encoder = new TextEncoder();149  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));150  const hashHex = Array.from(new Uint8Array(hashBuffer))151    .map((b) => b.toString(16).padStart(2, "0"))152    .join("")153    .toUpperCase();154155  const prefix = hashHex.slice(0, 5);156  const suffix = hashHex.slice(5);157158  try {159    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);160    if (!res.ok) return 0;161    const text = await res.text();162    const match = text.split("\r\n").find((l) => l.startsWith(suffix));163    return match ? parseInt(match.split(":")[1], 10) : 0;164  } catch {165    return 0; // Network unavailable — don't block166  }167}168169export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {170  const fingerprint = await cardFingerprint(last4, expMonth, expYear);171  await loadSettings();172  settings = {173    ...settings,174    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],175  };176  await persistSettings();177}178179export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {180  await loadSettings();181  const fingerprint = await cardFingerprint(last4, expMonth, expYear);182  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);183}184185async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {186  const encoder = new TextEncoder();187  const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));188  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");189}190191function hex(buf: Uint8Array): string {192  return Array.from(buf)193    .map((b) => b.toString(16).padStart(2, "0"))194    .join("");195}196197function unhex(s: string): Uint8Array {198  const out = new Uint8Array(s.length / 2);199  for (let i = 0; i < s.length; i += 2) {200    out[i / 2] = parseInt(s.slice(i, i + 2), 16);201  }202  return out;203}204205// ---------------------------------------------------------------------------206// Storage schema migration207// ---------------------------------------------------------------------------208209async function loadSlot(key: string): Promise<StoredVault | null> {210  const result = await chrome.storage.local.get(key);211  return (result as Record<string, StoredVault | undefined>)[key] ?? null;212}213214async function writeSlot(key: string, vault: StoredVault): Promise<void> {215  try {216    await chrome.storage.local.set({ [key]: vault });217  } catch (err) {218    throw mapStorageError(err);219  }220}221222/**223 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault224 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +225 * peach_vault_b). Runs at most once per session; safe to call repeatedly.226 */227async function ensureMigrated(): Promise<void> {228  if (migrationRan) return;229  migrationRan = true;230231  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];232  const raw = await chrome.storage.local.get(keys);233  const slotAExists = raw[SLOT_A] !== undefined;234  const slotBExists = raw[SLOT_B] !== undefined;235  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;236  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;237238  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.239  if (!slotAExists && !legacyMain) return;240241  // Already migrated.242  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;243244  if (legacyMain && !slotAExists) {245    await writeSlot(SLOT_A, legacyMain);246  }247248  // If slot B isn't set, populate it: use legacy decoy when present, else noise.249  if (!slotBExists) {250    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;251    if (legacyDecoy) {252      await writeSlot(SLOT_B, legacyDecoy);253    } else if (target) {254      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));255    }256  }257258  const toRemove: string[] = [];259  if (legacyMain) toRemove.push(LEGACY_MAIN);260  if (legacyDecoy) toRemove.push(LEGACY_DECOY);261  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);262  if (toRemove.length > 0) {263    await chrome.storage.local.remove(toRemove);264  }265}266267// ---------------------------------------------------------------------------268// Padding + noise helpers269// ---------------------------------------------------------------------------270271/**272 * Pads the plaintext string with trailing spaces so that the encoded byte273 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing274 * whitespace after a complete document, so the padding is transparent on275 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.276 */277function padPlaintext(json: string): string {278  const encoded = new TextEncoder().encode(json);279  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);280  const padLen = target - encoded.length;281  return json + " ".repeat(padLen);282}283284/**285 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The286 * ciphertext length matches the given byte count. Because GCM ciphertext is287 * pseudo-random, this blob is indistinguishable at rest from a real288 * encrypted vault without the key.289 *290 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto291 * spec hard limit). Chunk the fill so vaults larger than that — common once292 * the user has dozens of entries or any passkey — don't throw.293 */294const CSPRNG_CHUNK = 65_536;295296function fillRandom(buf: Uint8Array): Uint8Array {297  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {298    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);299    const chunk = new Uint8Array(end - offset);300    crypto.getRandomValues(chunk);301    buf.set(chunk, offset);302  }303  return buf;304}305306function generateNoiseBlob(ciphertextBytes: number): StoredVault {307  const salt = crypto.getRandomValues(new Uint8Array(16));308  const nonce = crypto.getRandomValues(new Uint8Array(12));309  const cipher = fillRandom(new Uint8Array(ciphertextBytes));310  const tag = crypto.getRandomValues(new Uint8Array(16));311  return {312    version: 1,313    salt: hex(salt),314    nonce: hex(nonce),315    ciphertext: hex(cipher),316    tag: hex(tag),317  };318}319320// ---------------------------------------------------------------------------321// Encrypted plaintext envelope322// ---------------------------------------------------------------------------323324type VaultPayload = {325  entries: VaultEntry[];326  duressConfigured?: boolean;327};328329type DecryptedPayload = {330  entries: VaultEntry[];331  duressConfigured: boolean;332  /**333   * True when at least one entry in the on-disk payload had a legacy shape334   * (pre-v2.11.0 passkey type/flat fields, or pre-A-2 created/updated keys)335   * and got rewritten by normalizeEntry. The unlock path uses this to336   * persist the new shape immediately — without it, the migration would337   * re-run on every unlock and old shape never disappears from disk.338   */339  migratedFromLegacy: boolean;340};341342function encodePayload(entries: VaultEntry[], duress: boolean): string {343  const envelope: VaultPayload = { entries, duressConfigured: duress };344  return padPlaintext(JSON.stringify(envelope));345}346347function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {348  if ("created" in raw || "updated" in raw) return true;349  const flat = raw as Partial<VaultEntry>;350  if (flat.type === "passkey") return true;351  // Flat rpId without a passkey sub-object means pre-v2.11.0 schema.352  if (flat.rpId !== undefined && flat.passkey === undefined) return true;353  return false;354}355356function decodePayload(plain: string): DecryptedPayload {357  const parsed = JSON.parse(plain) as358    | Array<VaultEntry | LegacyVaultEntry>359    | VaultPayload;360361  const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];362  const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);363364  const normalized = rawEntries.map(normalizeEntry);365  const { merged } = mergePasskeyOnlyIntoLogins(normalized);366367  if (Array.isArray(parsed)) {368    return {369      entries: normalized,370      duressConfigured: false,371      migratedFromLegacy: true, // pre-A-2 array shape always needs save372    };373  }374375  return {376    entries: normalized,377    duressConfigured: parsed.duressConfigured === true,378    migratedFromLegacy: flatMigrationNeeded || merged,379  };380}381382/**383 * One-time consolidation pass run alongside the legacy passkey migration.384 *385 * Pre-v2.11.0 the Chrome passkey path always created a fresh `type:"passkey"`386 * entry per registration — so users who tested registration multiple times387 * accumulated several passkey-bearing entries for the same RP, and the388 * migration to the unified shape carried that duplication forward. This389 * pass collapses the duplicates:390 *391 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.392 *   2. Within each group, the most recently-active credential wins (prefer393 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).394 *   3. If a NON-passkey login entry exists for that RP (matched by url or395 *      additionalUrls hostname), attach the winning passkey to it and396 *      soft-delete every passkey-bearing entry in the group.397 *   4. Otherwise, soft-delete every loser in the group, keeping the winner398 *      as a passkey-only entry.399 *400 * Soft delete (deletedAt) rather than splice — the trash flow keeps the401 * record around for retention and the user can restore if the heuristic402 * picked wrong. The `merged` flag goes back to decodePayload so the403 * unlock path persists the consolidated shape immediately.404 */405function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {406  let merged = false;407  const now = Date.now();408409  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);410  const byRpId = new Map<string, VaultEntry[]>();411  for (const e of candidates) {412    const rpId = e.passkey!.rpId;413    const list = byRpId.get(rpId);414    if (list) list.push(e);415    else byRpId.set(rpId, [e]);416  }417418  for (const [rpId, group] of byRpId) {419    if (group.length === 0) continue;420    group.sort((a, b) => {421      const aT =422        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;423      const bT =424        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;425      return bT - aT;426    });427    const winner = group[0]!;428429    // Look for a NON-passkey login entry matching this RP. Use the URL430    // hostname rather than a substring search so a "login.foo.com" entry431    // doesn't accidentally absorb a passkey for "foo.com".432    const loginTarget = entries.find((e) => {433      if (e.deletedAt || e.passkey || e.type !== "login") return false;434      if (e.url && extractDomain(e.url) === rpId) return true;435      for (const extra of e.additionalUrls ?? []) {436        if (extractDomain(extra) === rpId) return true;437      }438      return false;439    });440441    if (loginTarget) {442      loginTarget.passkey = winner.passkey;443      loginTarget.modifiedAt = now;444      for (const dup of group) {445        if (!dup.deletedAt) {446          dup.deletedAt = now;447          dup.modifiedAt = now;448        }449      }450      merged = true;451    } else if (group.length > 1) {452      for (let i = 1; i < group.length; i += 1) {453        const dup = group[i]!;454        if (!dup.deletedAt) {455          dup.deletedAt = now;456          dup.modifiedAt = now;457        }458      }459      merged = true;460    }461  }462463  return { merged };464}465466async function tryDecryptSlot(467  vault: StoredVault,468  key: Uint8Array469): Promise<DecryptedPayload | null> {470  try {471    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);472    return decodePayload(plain);473  } catch {474    return null;475  }476}477478// ---------------------------------------------------------------------------479// Core operations480// ---------------------------------------------------------------------------481482function buildDomainIndexSync(all: VaultEntry[]): void {483  domainIndex = new Map();484485  for (const entry of all) {486    if (entry.deletedAt) continue;487    if (entry.type === "note" || entry.type === "card") continue;488489    // An entry can match multiple sites via `additionalUrls`. Index each490    // hostname so `queryDomain` returns the entry regardless of which alias491    // the user is currently visiting. Dedupe per-entry so a single entry492    // never appears twice in one bucket.493    const domains = new Set<string>();494    const primary = extractDomain(entry.url ?? "");495    if (primary) domains.add(primary);496    for (const extra of entry.additionalUrls ?? []) {497      const d = extractDomain(extra);498      if (d) domains.add(d);499    }500    if (domains.size === 0) continue;501502    for (const domain of domains) {503      const bucket = domainIndex.get(domain) ?? [];504      bucket.push(entry);505      domainIndex.set(domain, bucket);506    }507  }508}509510export async function createVault(password: string): Promise<void> {511  await ensureMigrated();512513  const salt = generateSalt();514  derivedKey = await deriveKey(password, salt);515  entries = [];516  vaultMode = "real";517  duressConfigured = false;518  buildDomainIndexSync(entries);519520  const plaintext = encodePayload(entries, duressConfigured);521  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);522523  const slotA: StoredVault = {524    version: 1,525    salt: hex(salt),526    nonce,527    ciphertext,528    tag,529  };530  await writeSlot(SLOT_A, slotA);531532  // Matching-size noise blob for slot B, so a fresh install shows two533  // similarly-shaped records regardless of whether duress is ever set up.534  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));535536  touchAutoLock();537}538539export async function hasVault(): Promise<boolean> {540  await ensureMigrated();541  return (await loadSlot(SLOT_A)) !== null;542}543544/**545 * Returns whether the real vault has recorded that a duress passphrase is546 * configured. Reliable only after a real-mode unlock — before unlock or in547 * decoy mode, returns false because we cannot read slot A's plaintext.548 */549export async function isDuressEnabled(): Promise<boolean> {550  return vaultMode === "real" && duressConfigured;551}552553export async function hasDecoyVault(): Promise<boolean> {554  return isDuressEnabled();555}556557export async function setupDuress(duressPassword: string): Promise<void> {558  if (!derivedKey || vaultMode !== "real") {559    throw new Error("vault must be unlocked in real mode");560  }561  await ensureMigrated();562563  // Encrypt the decoy payload (empty entries) under the duress key. Pad to564  // at least the current slot A ciphertext size so the two slots look the565  // same at rest at setup time.566  const slotA = await loadSlot(SLOT_A);567  if (!slotA) throw new Error("missing slot A");568569  const decoySalt = generateSalt();570  const decoyKey = await deriveKey(duressPassword, decoySalt);571  try {572    // Pad the decoy plaintext to match slot A's ciphertext length (minus the573    // 16-byte tag) so encrypted sizes align exactly.574    const targetBytes = slotA.ciphertext.length / 2;575    let plaintext = encodePayload([], false);576    const encoded = new TextEncoder().encode(plaintext);577    if (encoded.length < targetBytes) {578      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);579    }580    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);581    await writeSlot(SLOT_B, {582      version: 1,583      salt: hex(decoySalt),584      nonce,585      ciphertext,586      tag,587    });588  } finally {589    zeroKey(decoyKey);590  }591592  // Re-encrypt slot A with the duressConfigured flag set to true. No external593  // flag is written — the bit lives inside the encrypted envelope.594  duressConfigured = true;595  await persistSlotA();596}597598export async function disableDuress(): Promise<void> {599  if (!derivedKey || vaultMode !== "real") {600    throw new Error("vault must be unlocked in real mode");601  }602  await ensureMigrated();603604  // Overwrite slot B with fresh noise that matches slot A's size. The slot605  // remains occupied so its mere existence does not signal that duress was606  // ever configured.607  const slotA = await loadSlot(SLOT_A);608  if (slotA) {609    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));610  }611612  duressConfigured = false;613  await persistSlotA();614}615616export async function verifyMainPassword(password: string): Promise<boolean> {617  await ensureMigrated();618  const slotA = await loadSlot(SLOT_A);619  if (!slotA) return false;620  const key = await deriveKey(password, unhex(slotA.salt));621  try {622    return (await tryDecryptSlot(slotA, key)) !== null;623  } finally {624    zeroKey(key);625  }626}627628export async function unlock(password: string): Promise<boolean> {629  await ensureMigrated();630631  // Load both slots in parallel. Both always exist post-migration; the632  // parallel load and the parallel derivation below keep wall-clock time633  // constant regardless of which slot's passphrase (real or decoy) the634  // caller supplied, so a timing side channel cannot distinguish the635  // duress path from the real-unlock path (DUR-002).636  const [slotA, slotB] = await Promise.all([637    loadSlot(SLOT_A),638    loadSlot(SLOT_B),639  ]);640641  // Derive both keys in parallel, unconditionally.642  const [keyA, keyB] = await Promise.all([643    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),644    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),645  ]);646647  // Attempt both decrypts in parallel.648  const [payloadA, payloadB] = await Promise.all([649    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),650    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),651  ]);652653  if (payloadA && keyA) {654    derivedKey = keyA;655    vaultMode = "real";656    entries = payloadA.entries;657    duressConfigured = payloadA.duressConfigured;658    buildDomainIndexSync(entries);659    await loadSettings();660    touchAutoLock();661    if (keyB) zeroKey(keyB);662    // If the payload had a pre-v2.11.0 shape (legacy passkey type, flat663    // passkey fields, or pre-A-2 created/updated keys), rewrite it now so664    // the next save uses the new format and migration doesn't re-run forever.665    if (payloadA.migratedFromLegacy) {666      await persistSlotA();667    }668    return true;669  }670671  if (payloadB && keyB) {672    derivedKey = keyB;673    vaultMode = "decoy";674    entries = payloadB.entries;675    duressConfigured = false;676    buildDomainIndexSync(entries);677    await loadSettings();678    touchAutoLock();679    if (keyA) zeroKey(keyA);680    return true;681  }682683  if (keyA) zeroKey(keyA);684  if (keyB) zeroKey(keyB);685  return false;686}687688export function lock(): void {689  if (derivedKey) {690    zeroKey(derivedKey);691    derivedKey = null;692  }693694  entries = [];695  domainIndex = new Map();696  vaultMode = "real";697  duressConfigured = false;698  clearAutoLock();699}700701export function isUnlocked(): boolean {702  return derivedKey !== null;703}704705export function getEntries(): VaultEntry[] {706  if (!derivedKey) throw new Error("vault locked");707  return entries.filter((entry) => !entry.deletedAt);708}709710const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;711712export function getIdentities(): SavedIdentity[] {713  if (!derivedKey) throw new Error("vault locked");714  const active = entries.filter((e) => !e.deletedAt && e.type === "login");715716  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();717  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();718719  for (const entry of active) {720    const value = entry.username?.trim();721    if (!value) continue;722    const map = EMAIL_RE.test(value) ? emailMap : usernameMap;723    const existing = map.get(value);724    const domain = extractDomain(entry.url ?? "") ?? "";725    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;726    if (!existing || modifiedAt > existing.lastUsed) {727      map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });728    } else {729      map.set(value, { ...existing, count: existing.count + 1 });730    }731  }732733  const toIdentity = (type: "email" | "username") =>734    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({735      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,736    });737738  return [739    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),740    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),741  ];742}743744export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {745  if (!derivedKey) throw new Error("vault locked");746747  const now = Date.now();748  const added: VaultEntry[] = [];749  for (const draft of drafts) {750    try {751      const sanitized = sanitizeEntryDraft(draft);752      const nextEntry: VaultEntry = {753        ...sanitized,754        id: crypto.randomUUID(),755        createdAt: now,756        modifiedAt: now,757      };758      entries.push(nextEntry);759      added.push(nextEntry);760    } catch {761      // Skip invalid drafts silently — caller can see diff between input and output length762    }763  }764765  if (added.length > 0) {766    await persistEntries();767  }768769  return added;770}771772/**773 * Restore a set of entries in full fidelity — preserves id, createdAt,774 * modifiedAt, and every other field unlike `batchAddEntries` which775 * assigns fresh identity. Used by restore paths where the backup is the776 * authoritative source of truth. `mode` controls how entries already in777 * the vault are treated.778 */779export async function restoreEntries(780  incoming: VaultEntry[],781  mode: "replace" | "merge"782): Promise<{ added: number; updated: number; skipped: number }> {783  if (!derivedKey) throw new Error("vault locked");784785  // A replace with zero incoming entries is almost certainly a caller bug786  // (e.g. an import that silently dropped everything). Refuse rather than787  // wipe the vault.788  if (mode === "replace" && incoming.length === 0) {789    throw new Error("refuse to replace with empty entry set");790  }791792  let added = 0;793  let updated = 0;794  let skipped = 0;795796  if (mode === "replace") {797    entries = [];798    for (const entry of incoming) {799      const normalized = sanitizeRestoredEntry(entry);800      if (!normalized) {801        skipped += 1;802        continue;803      }804      entries.push(normalized);805      added += 1;806    }807  } else {808    const byId = new Map<string, VaultEntry>();809    for (const e of entries) byId.set(e.id, e);810811    for (const entry of incoming) {812      const normalized = sanitizeRestoredEntry(entry);813      if (!normalized) {814        skipped += 1;815        continue;816      }817      const existing = byId.get(normalized.id);818      if (!existing) {819        entries.push(normalized);820        added += 1;821      } else if (normalized.modifiedAt > existing.modifiedAt) {822        const idx = entries.findIndex((e) => e.id === existing.id);823        if (idx !== -1) {824          entries[idx] = normalized;825          updated += 1;826        }827      } else {828        skipped += 1;829      }830    }831  }832833  await persistEntries("import");834  return { added, updated, skipped };835}836837/**838 * Validate + normalise a VaultEntry coming from a backup. Accepts839 * anything structurally sound; keeps all metadata verbatim. Returns840 * null for values the live vault shouldn't accept (e.g., missing title).841 */842function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {843  if (!entry || typeof entry !== "object") return null;844  const title = typeof entry.title === "string" ? entry.title.trim() : "";845  if (!title) return null;846  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();847  const now = Date.now();848  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;849  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;850851  // Migrate legacy passkey shape on import (same logic as on-disk read).852  // Imports from pre-v2.11.0 vault backups still carry type:"passkey" with853  // flat fields; bring them into the new shape immediately.854  const migrated: VaultEntry = migrateLegacyPasskey(855    {856      ...entry,857      id,858      title,859      type: entry.type ?? "login",860      createdAt,861      modifiedAt,862    },863    entry864  );865866  // Login entries from backup must carry a password (or be passkey-only,867  // in which case the passkey sub-object replaces the password requirement).868  if (869    migrated.type === "login" &&870    !migrated.passkey &&871    typeof migrated.password !== "string"872  ) {873    return null;874  }875  if (migrated.passkey && !migrated.passkey.credentialId) {876    return null;877  }878879  return migrated;880}881882// Soft-deletes every non-deleted entry in one call. The literal string883// argument is a tripwire against accidental calls — any caller that would884// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this885// to a boolean.886export async function clearVaultEntries(887  confirmation: "REPLACE_ALL"888): Promise<void> {889  if (confirmation !== "REPLACE_ALL") {890    throw new Error("clearVaultEntries requires explicit confirmation");891  }892  if (!derivedKey) throw new Error("vault locked");893  for (const entry of entries) {894    if (!entry.deletedAt) {895      entry.deletedAt = Date.now();896      entry.modifiedAt = Date.now();897    }898  }899  await persistEntries();900}901902export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {903  if (!derivedKey) throw new Error("vault locked");904905  const sanitized = sanitizeEntryDraft(entry);906  const now = Date.now();907  const nextEntry: VaultEntry = {908    ...sanitized,909    id: crypto.randomUUID(),910    createdAt: now,911    modifiedAt: now,912    // Stamp passwordChangedAt at creation so age tracking is accurate from913    // day one. Non-login entries carry the same stamp harmlessly.914    passwordChangedAt: sanitized.password ? now : undefined,915  };916917  entries.push(nextEntry);918  await persistEntries();919920  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)921  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {922    checkBreach(nextEntry.password).then(async (count) => {923      if (count > 0) {924        const idx = entries.findIndex((e) => e.id === nextEntry.id);925        if (idx !== -1) {926          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };927          await persistEntries();928        }929      }930    });931  }932933  return nextEntry;934}935936export async function updateEntry(937  id: string,938  patch: Partial<VaultEntry>939): Promise<void> {940  if (!derivedKey) throw new Error("vault locked");941942  const idx = entries.findIndex((entry) => entry.id === id);943  if (idx === -1 || entries[idx].deletedAt) {944    throw new Error("entry not found");945  }946947  const current = entries[idx];948  const sanitizedPatch = sanitizeEntryPatch(patch);949  // Refresh passwordChangedAt only when the password value actually changes —950  // a metadata-only update (title, tags, etc.) must not reset the age clock951  // that drives the Pro health rotation recommendation.952  const passwordChanged =953    "password" in patch &&954    typeof sanitizedPatch.password === "string" &&955    sanitizedPatch.password !== current.password;956  entries[idx] = {957    ...current,958    ...sanitizedPatch,959    modifiedAt: Date.now(),960    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),961  };962963  // A login entry must carry SOMETHING fillable — either a password or a964  // passkey credential. An empty-password login with no passkey is dead965  // state. Permit the password-clear when a passkey is attached (that's966  // the legitimate "drop the password, keep the passkey" path).967  if (968    entries[idx].type === "login" &&969    !entries[idx].password &&970    !entries[idx].passkey971  ) {972    entries[idx] = current;973    throw new Error("password or passkey required");974  }975976  // Card entries must retain their card number977  if (entries[idx].type === "card" && !entries[idx].cardNumber) {978    entries[idx] = current;979    throw new Error("card number is required for card entries");980  }981982  await persistEntries();983984  // Breach check when password changes: clear immediately so old breach doesn't linger,985  // then async-check the new password and only re-set if it's also found.986  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {987    entries[idx] = { ...entries[idx], breach: undefined };988    await persistEntries();989    const password = entries[idx].password;990    if (password) {991      checkBreach(password).then(async (count) => {992        const i = entries.findIndex((e) => e.id === id);993        if (i !== -1 && count > 0) {994          entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };995          await persistEntries();996        }997      });998    }999  }1000}10011002export async function deleteEntry(id: string): Promise<void> {1003  if (!derivedKey) throw new Error("vault locked");10041005  const entry = entries.find((item) => item.id === id);1006  if (!entry || entry.deletedAt) {1007    throw new Error("entry not found");1008  }10091010  entry.deletedAt = Date.now();1011  entry.modifiedAt = Date.now();1012  await persistEntries();1013}10141015export function getDeletedEntries(): VaultEntry[] {1016  if (!derivedKey) return [];1017  return entries.filter((e) => !!e.deletedAt);1018}10191020export async function restoreEntry(id: string): Promise<void> {1021  if (!derivedKey) throw new Error("vault locked");1022  const entry = entries.find((e) => e.id === id && e.deletedAt);1023  if (!entry) throw new Error("deleted entry not found");1024  delete entry.deletedAt;1025  entry.modifiedAt = Date.now();1026  await persistEntries();1027  buildDomainIndexSync(entries);1028}10291030export async function purgeEntry(id: string): Promise<void> {1031  if (!derivedKey) throw new Error("vault locked");1032  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1033  if (idx === -1) throw new Error("deleted entry not found");1034  entries.splice(idx, 1);1035  await persistEntries();1036}10371038/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */1039export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1040  if (!derivedKey || retentionMs === 0) return 0;1041  const cutoff = Date.now() - retentionMs;1042  const before = entries.length;1043  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1044  const purged = before - entries.length;1045  if (purged > 0) await persistEntries();1046  return purged;1047}10481049export function queryDomain(domain: string): VaultEntry[] {1050  return domainIndex.get(domain) ?? [];1051}10521053export function getEntryById(id: string): VaultEntry | undefined {1054  return entries.find((entry) => entry.id === id && !entry.deletedAt);1055}10561057async function persistSlotA(1058  reason: VaultSnapshot["reason"] = "local_save",1059): Promise<StoredVault> {1060  if (!derivedKey) throw new Error("vault locked");10611062  const slotA = await loadSlot(SLOT_A);1063  if (!slotA) throw new Error("missing slot A");10641065  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures1066  // are swallowed by takeVaultSnapshot — history must never block a save.1067  await takeVaultSnapshot(reason);10681069  const plaintext = encodePayload(entries, duressConfigured);1070  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);10711072  const nextSlotA: StoredVault = {1073    ...slotA,1074    nonce,1075    ciphertext,1076    tag,1077  };1078  await writeSlot(SLOT_A, nextSlotA);10791080  // When duress is not configured, keep slot B size-matched with slot A by1081  // regenerating its noise. When duress IS configured, leave slot B alone —1082  // we don't have the decoy key to re-encrypt it. This is a known trade-off.1083  if (!duressConfigured) {1084    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1085  }10861087  return nextSlotA;1088}10891090async function persistSlotB(): Promise<void> {1091  if (!derivedKey) throw new Error("vault locked");10921093  const slotB = await loadSlot(SLOT_B);1094  if (!slotB) throw new Error("missing slot B");10951096  // Decoy plaintext always reports duressConfigured=false — the decoy user1097  // sees a clean slate.1098  const plaintext = encodePayload(entries, false);1099  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);11001101  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1102}11031104async function persistEntries(1105  reason: VaultSnapshot["reason"] = "local_save",1106): Promise<void> {1107  if (!derivedKey) throw new Error("vault locked");11081109  if (vaultMode === "decoy") {1110    await persistSlotB();1111  } else {1112    await persistSlotA(reason);1113  }1114  buildDomainIndexSync(entries);1115}11161117function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1118  if (!Array.isArray(values)) return undefined;1119  const seen = new Set<string>();1120  const cleaned: string[] = [];1121  for (const value of values) {1122    const trimmed = typeof value === "string" ? value.trim() : "";1123    if (!trimmed) continue;1124    const key = trimmed.toLowerCase();1125    if (seen.has(key)) continue;1126    seen.add(key);1127    cleaned.push(trimmed);1128  }1129  return cleaned.length > 0 ? cleaned : undefined;1130}11311132function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1133  const title = entry.title.trim();1134  // Accept legacy `type:"passkey"` drafts but rewrite to "login" — the new1135  // model is "any entry, optionally with a passkey sub-object." Imports and1136  // any in-flight callers from before v2.11.0 keep working without throwing.1137  let type: VaultEntry["type"] = entry.type ?? "login";1138  if (type === "passkey") type = "login";11391140  if (!title) throw new Error("title is required");1141  // A login entry needs either a password or a passkey credential to be useful.1142  // Passkey-only entries (passkey present, no password) are valid.1143  if (type === "login" && !entry.password?.trim() && !entry.passkey) {1144    throw new Error("password or passkey required");1145  }1146  if (entry.passkey) {1147    const p = entry.passkey;1148    if (1149      !p.rpId ||1150      !p.credentialId ||1151      !p.publicKeyCose ||1152      !p.userHandle ||1153      !p.userName ||1154      (p.algorithm !== -7 && p.algorithm !== -8)1155    ) {1156      throw new Error("invalid passkey credential");1157    }1158  }1159  if (type === "card") {1160    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1161      throw new Error("cardholder name and card number are required");1162    }1163  }11641165  return {1166    type,1167    title,1168    username: type === "login" ? entry.username?.trim() ?? "" : undefined,1169    password: type === "login" ? entry.password : undefined,1170    url: type === "login" ? entry.url?.trim() ?? "" : undefined,1171    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1172    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1173    notes: entry.notes?.trim() || undefined,1174    breach: type === "login" ? entry.breach : undefined,1175    canary: type === "login" ? entry.canary : undefined,1176    tags: entry.tags,1177    passkey: entry.passkey,1178    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1179    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1180    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1181    expYear: type === "card" ? entry.expYear?.trim() : undefined,1182    cvc: type === "card" ? entry.cvc?.trim() : undefined,1183    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1184    network: type === "card" ? entry.network : undefined,1185    last4: type === "card" ? entry.last4?.trim() : undefined,1186  };1187}11881189function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1190  const nextPatch: Partial<VaultEntry> = {};11911192  if ("type" in patch) {1193    nextPatch.type = patch.type;1194  }11951196  if ("title" in patch) {1197    const title = patch.title?.trim() ?? "";1198    if (!title) throw new Error("title is required");1199    nextPatch.title = title;1200  }12011202  if ("url" in patch) {1203    nextPatch.url = patch.url?.trim() ?? "";1204  }12051206  if ("additionalUrls" in patch) {1207    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1208    const seen = new Set<string>();1209    const cleaned: string[] = [];1210    for (const value of incoming) {1211      const trimmed = typeof value === "string" ? value.trim() : "";1212      if (!trimmed) continue;1213      const key = trimmed.toLowerCase();1214      if (seen.has(key)) continue;1215      seen.add(key);1216      cleaned.push(trimmed);1217    }1218    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1219  }12201221  if ("username" in patch) {1222    nextPatch.username = patch.username?.trim() ?? "";1223  }12241225  if ("password" in patch) {1226    const password = patch.password ?? "";1227    // Allow clearing a password (e.g. on passkey-only entries)1228    nextPatch.password = password || undefined;1229  }12301231  if ("totp" in patch) {1232    nextPatch.totp = patch.totp?.trim() || undefined;1233  }12341235  if ("notes" in patch) {1236    nextPatch.notes = patch.notes?.trim() || undefined;1237  }12381239  if ("breach" in patch) {1240    nextPatch.breach = patch.breach;1241  }12421243  if ("canary" in patch) {1244    nextPatch.canary = patch.canary;1245  }12461247  if ("passkey" in patch) {1248    // Passkey sub-object replaces all the legacy flat passkey fields. A1249    // patch with `passkey: undefined` removes the credential from the entry1250    // (used by "remove passkey" without deleting the whole entry).1251    nextPatch.passkey = patch.passkey;1252  }12531254  if ("tags" in patch) {1255    nextPatch.tags = patch.tags;1256  }12571258  if ("cardholderName" in patch) {1259    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1260  }1261  if ("cardNumber" in patch) {1262    const n = patch.cardNumber ?? "";1263    nextPatch.cardNumber = n || undefined;1264  }1265  if ("expMonth" in patch) {1266    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1267  }1268  if ("expYear" in patch) {1269    nextPatch.expYear = patch.expYear?.trim() || undefined;1270  }1271  if ("cvc" in patch) {1272    nextPatch.cvc = patch.cvc?.trim() || undefined;1273  }1274  if ("cardNickname" in patch) {1275    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1276  }1277  if ("network" in patch) {1278    nextPatch.network = patch.network;1279  }1280  if ("last4" in patch) {1281    nextPatch.last4 = patch.last4?.trim() || undefined;1282  }12831284  return nextPatch;1285}12861287function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1288  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1289  const modifiedAt =1290    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12911292  const base: VaultEntry = {1293    id: entry.id,1294    type: "type" in entry ? entry.type : "login",1295    title: entry.title,1296    username: entry.username,1297    password: entry.password,1298    url: entry.url,1299    totp: entry.totp,1300    notes: entry.notes,1301    breach: entry.breach,1302    canary: "canary" in entry ? entry.canary : undefined,1303    passkey: "passkey" in entry ? entry.passkey : undefined,1304    createdAt,1305    modifiedAt,1306    deletedAt: entry.deletedAt,1307    tags: "tags" in entry ? entry.tags : undefined,1308    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1309    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1310    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1311    expYear: "expYear" in entry ? entry.expYear : undefined,1312    cvc: "cvc" in entry ? entry.cvc : undefined,1313    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1314    network: "network" in entry ? entry.network : undefined,1315    last4: "last4" in entry ? entry.last4 : undefined,1316  };13171318  return migrateLegacyPasskey(base, entry);1319}13201321/**1322 * v2.11.0 read migration. Pre-v2.11.0 passkey entries used `type:"passkey"`1323 * with flat top-level fields (rpId, credentialId, etc.). This converts them1324 * to the new shape — `type:"login"` with a `passkey` sub-object — so all1325 * downstream consumers can rely on the new model. The result is rewritten1326 * to disk on next vault save (hasLegacyShape detects the un-migrated case).1327 */1328function migrateLegacyPasskey(1329  base: VaultEntry,1330  raw: VaultEntry | LegacyVaultEntry1331): VaultEntry {1332  if (base.passkey) return base;1333  const flat = raw as Partial<VaultEntry>;1334  const isPasskeyType = base.type === "passkey";1335  const hasFlatPasskeyFields =1336    typeof flat.rpId === "string" &&1337    typeof flat.credentialId === "string" &&1338    typeof flat.publicKeyCose === "string" &&1339    typeof flat.userHandle === "string" &&1340    typeof flat.userName === "string";1341  if (!isPasskeyType && !hasFlatPasskeyFields) return base;1342  if (!hasFlatPasskeyFields) return base;13431344  const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1345  base.passkey = {1346    rpId: flat.rpId!,1347    rpName: flat.rpName ?? flat.rpId!,1348    credentialId: flat.credentialId!,1349    publicKeyCose: flat.publicKeyCose!,1350    privateKeyJwk: flat.privateKeyJwk,1351    userHandle: flat.userHandle!,1352    userName: flat.userName!,1353    userDisplayName: flat.userDisplayName ?? flat.userName!,1354    algorithm,1355    signCount: flat.signCount ?? 0,1356    aaguid: flat.aaguid ?? "",1357    backupEligible: flat.backupEligible === true,1358    backupState: flat.backupState === true,1359    lastUsedAt: flat.lastUsedAt,1360    browser: flat.browser,1361    transports: flat.transports,1362    createdAt: base.createdAt,1363  };1364  // Promote to "login" so the unified Entry view renders. Username gets1365  // populated from the passkey's userName so the entry is searchable in the1366  // existing autofill flow without a separate code path.1367  if (isPasskeyType) base.type = "login";1368  if (!base.username) base.username = flat.userName;1369  if (!base.url) base.url = `https://${flat.rpId}`;1370  return base;1371}137213731374// ---------------------------------------------------------------------------1375// Deduplication1376// ---------------------------------------------------------------------------13771378function entryDupeKey(e: VaultEntry): string {1379  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1380  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins1381  // on different sites that happen to share a username are still distinct;1382  // two passkey credentials for the same RP+user are duplicates regardless1383  // of any login fields they may also carry.1384  if (e.passkey) {1385    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1386  }1387  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1388}13891390/** Returns the number of duplicate entries (those that would be removed). */1391export function countDuplicates(): number {1392  const best = new Map<string, VaultEntry>();1393  let dupeCount = 0;1394  for (const e of entries.filter((e) => !e.deletedAt)) {1395    const key = entryDupeKey(e);1396    const existing = best.get(key);1397    if (!existing) {1398      best.set(key, e);1399    } else {1400      dupeCount++;1401      // Keep the more recently modified one in the map1402      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1403        best.set(key, e);1404      }1405    }1406  }1407  return dupeCount;1408}14091410/**1411 * Soft-deletes duplicate entries, keeping the most recently modified copy of1412 * each group. Returns the number of entries moved to trash.1413 */1414export async function deduplicateEntries(): Promise<number> {1415  if (!derivedKey) throw new Error("vault locked");1416  // Dedup reshapes slot B permanently under the decoy key, which is1417  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1418  if (vaultMode === "decoy") {1419    throw new Error("deduplicate disabled in decoy mode");1420  }14211422  const best = new Map<string, VaultEntry>();1423  for (const e of entries.filter((en) => !en.deletedAt)) {1424    const key = entryDupeKey(e);1425    const existing = best.get(key);1426    if (!existing) {1427      best.set(key, e);1428    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1429      best.set(key, e);1430    }1431  }14321433  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1434  const now = Date.now();1435  let removed = 0;1436  entries = entries.map((e) => {1437    if (!e.deletedAt && !keepIds.has(e.id)) {1438      removed++;1439      return { ...e, deletedAt: now };1440    }1441    return e;1442  });14431444  if (removed > 0) await persistEntries();1445  return removed;1446}14471448// ---------------------------------------------------------------------------1449// Vault history — snapshot BEFORE every vault write so the saved blob is1450// what is about to be lost, not what was just written. Capped at 10. The1451// stored blobs are AES-256-GCM ciphertext under the master password, so1452// history adds zero security surface.1453// ---------------------------------------------------------------------------14541455const HISTORY_KEY = "peach_vault_history";1456const HISTORY_MAX = 10;14571458/**1459 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE1460 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost1461 * state. Identical-to-most-recent blobs are skipped to avoid filling history1462 * with no-op writes.1463 */1464export async function takeVaultSnapshot(1465  reason: VaultSnapshot["reason"],1466): Promise<void> {1467  try {1468    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1469    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1470    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];14711472    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,1473    // pre-createVault). createVault skips snapshots intentionally — there is1474    // no prior state to preserve.1475    if (!slotA) return;14761477    const vault_a = JSON.stringify(slotA);1478    const vault_b = JSON.stringify(slotB ?? slotA);14791480    // Best-effort entry count: only meaningful when the in-memory state is1481    // unlocked AND in real mode. Decoy mode would leak the decoy entry1482    // count; locked mode has no entries to count. Both fall through to null.1483    let entryCount: number | null = null;1484    if (derivedKey && vaultMode === "real") {1485      try {1486        entryCount = entries.filter((e) => !e.deletedAt).length;1487      } catch {1488        entryCount = null;1489      }1490    }14911492    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1493    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1494      ? existingRaw1495      : { snapshots: [] };14961497    // Deduplication: if the most recent snapshot has byte-identical blobs,1498    // skip — saves the same vault N times in a row would otherwise burn the1499    // entire history window with no recovery value.1500    const latest = history.snapshots[0];1501    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1502      return;1503    }15041505    const snapshot: VaultSnapshot = {1506      id: crypto.randomUUID(),1507      savedAt: Date.now(),1508      reason,1509      vault_a,1510      vault_b,1511      entryCount,1512    };15131514    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1515    await chrome.storage.local.set({ [HISTORY_KEY]: history });1516  } catch {1517    // Non-fatal — history must never block a save. A failed snapshot just1518    // means the user loses one rung of the safety ladder for this write.1519  }1520}15211522/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */1523export async function clearVaultHistory(): Promise<void> {1524  try {1525    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1526  } catch {1527    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().1528  }1529}15301531/** Read all snapshots, newest first, for the Settings vault history view. */1532export async function getVaultHistory(): Promise<VaultSnapshot[]> {1533  const raw = await chrome.storage.local.get(HISTORY_KEY);1534  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1535  if (!history || !Array.isArray(history.snapshots)) return [];1536  return history.snapshots;1537}15381539/**1540 * Replace the on-disk vault with the chosen snapshot's blobs and lock the1541 * vault. The current state is itself snapshotted with reason 'restore' first1542 * so the restore is reversible. After this returns, the user must unlock1543 * with the password that matches the restored blob.1544 */1545export async function restoreVaultSnapshot(1546  snapshotId: string,1547): Promise<{ ok: true } | { ok: false; error: string }> {1548  const history = await getVaultHistory();1549  const snapshot = history.find((s) => s.id === snapshotId);1550  if (!snapshot) return { ok: false, error: "snapshot_not_found" };15511552  // Snapshot the about-to-be-replaced current state so the restore is1553  // reversible. takeVaultSnapshot is no-op when the most recent entry is1554  // byte-identical, so a restore-of-a-restore stays clean.1555  await takeVaultSnapshot("restore");15561557  let restoredA: StoredVault;1558  let restoredB: StoredVault;1559  try {1560    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1561    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1562  } catch {1563    return { ok: false, error: "snapshot_corrupt" };1564  }15651566  await writeSlot(SLOT_A, restoredA);1567  await writeSlot(SLOT_B, restoredB);15681569  // Lock so the user must prove they can decrypt the restored blob. If they1570  // restored a pre-password-change snapshot they discover it immediately at1571  // the lock screen rather than silently holding an inaccessible vault.1572  lock();15731574  return { ok: true };1575}15761577/**1578 * Change the master password. Re-encrypts slot A with a fresh salt + new1579 * derived key. Slot B (duress or noise) is left untouched when duress is1580 * configured — it uses an independent key derived from the duress password —1581 * and regenerated as noise when duress is not configured.1582 */1583export async function changePassword(1584  currentPassword: string,1585  newPassword: string1586): Promise<boolean> {1587  if (!derivedKey || vaultMode !== "real") return false;15881589  // Verify current password before allowing any change1590  const valid = await verifyMainPassword(currentPassword);1591  if (!valid) return false;15921593  const slotA = await loadSlot(SLOT_A);1594  if (!slotA) return false;15951596  // Derive fresh key from new password + new random salt1597  const newSalt = generateSalt();1598  const newKey = await deriveKey(newPassword, newSalt);15991600  // Re-encrypt the in-memory entries with the new key1601  const plaintext = encodePayload(entries, duressConfigured);1602  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);16031604  const freshSlotA: StoredVault = {1605    ...slotA,1606    salt: hex(newSalt),1607    nonce,1608    ciphertext,1609    tag,1610  };1611  // Snapshot the OLD-key blob before overwriting it. If the user later1612  // restores from this snapshot they will need the OLD password — the1613  // post-restore lock surfaces that immediately.1614  await takeVaultSnapshot("local_save");1615  await writeSlot(SLOT_A, freshSlotA);16161617  // Swap the in-memory key synchronously before any further await. If we1618  // threw between the Slot A write and this swap, the next persistEntries()1619  // would overwrite Slot A with old-key ciphertext, locking the user out.1620  zeroKey(derivedKey);1621  derivedKey = newKey;16221623  // Regenerate noise for slot B when duress is not in use. Runs after the1624  // key swap so a failure here is a disposable noise regeneration, not a1625  // lockout.1626  if (!duressConfigured) {1627    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1628  }16291630  return true;1631}