vault.ts

56.1 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}704705// Opens the vault using a pre-derived AES-256 key (skipping Argon2id).706// Used by Android biometric unlock — the key was derived on the native side.707export async function unlockWithKey(708  masterKeyHex: string,709): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {710  try {711    const masterKeyBytes = unhex(masterKeyHex);712    const stored = await loadSlot(SLOT_A);713    if (!stored) return { ok: false, error: "invalid_key" };714    const payload = await tryDecryptSlot(stored, masterKeyBytes);715    if (!payload) return { ok: false, error: "invalid_key" };716717    derivedKey = masterKeyBytes;718    vaultMode = "real";719    entries = payload.entries;720    duressConfigured = payload.duressConfigured;721    buildDomainIndexSync(entries);722    await loadSettings();723    touchAutoLock();724    return { ok: true, entries };725  } catch {726    return { ok: false, error: "invalid_key" };727  }728}729730// Returns the current in-memory derived key as a hex string.731// Used by the biometric enable flow in Settings to store the key in Android Keystore.732export function getDerivedKeyHex(): string {733  if (!derivedKey) throw new Error("vault_not_unlocked");734  return hex(derivedKey);735}736737export function getEntries(): VaultEntry[] {738  if (!derivedKey) throw new Error("vault locked");739  return entries.filter((entry) => !entry.deletedAt);740}741742const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;743744export function getIdentities(): SavedIdentity[] {745  if (!derivedKey) throw new Error("vault locked");746  const active = entries.filter((e) => !e.deletedAt && e.type === "login");747748  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();749  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();750751  for (const entry of active) {752    const value = entry.username?.trim();753    if (!value) continue;754    const map = EMAIL_RE.test(value) ? emailMap : usernameMap;755    const existing = map.get(value);756    const domain = extractDomain(entry.url ?? "") ?? "";757    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;758    if (!existing || modifiedAt > existing.lastUsed) {759      map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });760    } else {761      map.set(value, { ...existing, count: existing.count + 1 });762    }763  }764765  const toIdentity = (type: "email" | "username") =>766    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({767      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,768    });769770  return [771    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),772    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),773  ];774}775776export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {777  if (!derivedKey) throw new Error("vault locked");778779  const now = Date.now();780  const added: VaultEntry[] = [];781  for (const draft of drafts) {782    try {783      const sanitized = sanitizeEntryDraft(draft);784      const nextEntry: VaultEntry = {785        ...sanitized,786        id: crypto.randomUUID(),787        createdAt: now,788        modifiedAt: now,789      };790      entries.push(nextEntry);791      added.push(nextEntry);792    } catch {793      // Skip invalid drafts silently — caller can see diff between input and output length794    }795  }796797  if (added.length > 0) {798    await persistEntries();799  }800801  return added;802}803804/**805 * Restore a set of entries in full fidelity — preserves id, createdAt,806 * modifiedAt, and every other field unlike `batchAddEntries` which807 * assigns fresh identity. Used by restore paths where the backup is the808 * authoritative source of truth. `mode` controls how entries already in809 * the vault are treated.810 */811export async function restoreEntries(812  incoming: VaultEntry[],813  mode: "replace" | "merge"814): Promise<{ added: number; updated: number; skipped: number }> {815  if (!derivedKey) throw new Error("vault locked");816817  // A replace with zero incoming entries is almost certainly a caller bug818  // (e.g. an import that silently dropped everything). Refuse rather than819  // wipe the vault.820  if (mode === "replace" && incoming.length === 0) {821    throw new Error("refuse to replace with empty entry set");822  }823824  let added = 0;825  let updated = 0;826  let skipped = 0;827828  if (mode === "replace") {829    entries = [];830    for (const entry of incoming) {831      const normalized = sanitizeRestoredEntry(entry);832      if (!normalized) {833        skipped += 1;834        continue;835      }836      entries.push(normalized);837      added += 1;838    }839  } else {840    const byId = new Map<string, VaultEntry>();841    for (const e of entries) byId.set(e.id, e);842843    for (const entry of incoming) {844      const normalized = sanitizeRestoredEntry(entry);845      if (!normalized) {846        skipped += 1;847        continue;848      }849      const existing = byId.get(normalized.id);850      if (!existing) {851        entries.push(normalized);852        added += 1;853      } else if (normalized.modifiedAt > existing.modifiedAt) {854        const idx = entries.findIndex((e) => e.id === existing.id);855        if (idx !== -1) {856          entries[idx] = normalized;857          updated += 1;858        }859      } else {860        skipped += 1;861      }862    }863  }864865  await persistEntries("import");866  return { added, updated, skipped };867}868869/**870 * Validate + normalise a VaultEntry coming from a backup. Accepts871 * anything structurally sound; keeps all metadata verbatim. Returns872 * null for values the live vault shouldn't accept (e.g., missing title).873 */874function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {875  if (!entry || typeof entry !== "object") return null;876  const title = typeof entry.title === "string" ? entry.title.trim() : "";877  if (!title) return null;878  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();879  const now = Date.now();880  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;881  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;882883  // Migrate legacy passkey shape on import (same logic as on-disk read).884  // Imports from pre-v2.11.0 vault backups still carry type:"passkey" with885  // flat fields; bring them into the new shape immediately.886  const migrated: VaultEntry = migrateLegacyPasskey(887    {888      ...entry,889      id,890      title,891      type: entry.type ?? "login",892      createdAt,893      modifiedAt,894    },895    entry896  );897898  // Login entries from backup must carry a password (or be passkey-only,899  // in which case the passkey sub-object replaces the password requirement).900  if (901    migrated.type === "login" &&902    !migrated.passkey &&903    typeof migrated.password !== "string"904  ) {905    return null;906  }907  if (migrated.passkey && !migrated.passkey.credentialId) {908    return null;909  }910911  return migrated;912}913914// Soft-deletes every non-deleted entry in one call. The literal string915// argument is a tripwire against accidental calls — any caller that would916// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this917// to a boolean.918export async function clearVaultEntries(919  confirmation: "REPLACE_ALL"920): Promise<void> {921  if (confirmation !== "REPLACE_ALL") {922    throw new Error("clearVaultEntries requires explicit confirmation");923  }924  if (!derivedKey) throw new Error("vault locked");925  for (const entry of entries) {926    if (!entry.deletedAt) {927      entry.deletedAt = Date.now();928      entry.modifiedAt = Date.now();929    }930  }931  await persistEntries();932}933934export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {935  if (!derivedKey) throw new Error("vault locked");936937  const sanitized = sanitizeEntryDraft(entry);938  const now = Date.now();939  const nextEntry: VaultEntry = {940    ...sanitized,941    id: crypto.randomUUID(),942    createdAt: now,943    modifiedAt: now,944    // Stamp passwordChangedAt at creation so age tracking is accurate from945    // day one. Non-login entries carry the same stamp harmlessly.946    passwordChangedAt: sanitized.password ? now : undefined,947  };948949  entries.push(nextEntry);950  await persistEntries();951952  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)953  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {954    checkBreach(nextEntry.password).then(async (count) => {955      if (count > 0) {956        const idx = entries.findIndex((e) => e.id === nextEntry.id);957        if (idx !== -1) {958          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };959          await persistEntries();960        }961      }962    });963  }964965  return nextEntry;966}967968export async function updateEntry(969  id: string,970  patch: Partial<VaultEntry>971): Promise<void> {972  if (!derivedKey) throw new Error("vault locked");973974  const idx = entries.findIndex((entry) => entry.id === id);975  if (idx === -1 || entries[idx].deletedAt) {976    throw new Error("entry not found");977  }978979  const current = entries[idx];980  const sanitizedPatch = sanitizeEntryPatch(patch);981  // Refresh passwordChangedAt only when the password value actually changes —982  // a metadata-only update (title, tags, etc.) must not reset the age clock983  // that drives the Pro health rotation recommendation.984  const passwordChanged =985    "password" in patch &&986    typeof sanitizedPatch.password === "string" &&987    sanitizedPatch.password !== current.password;988  entries[idx] = {989    ...current,990    ...sanitizedPatch,991    modifiedAt: Date.now(),992    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),993  };994995  // A login entry must carry SOMETHING fillable — either a password or a996  // passkey credential. An empty-password login with no passkey is dead997  // state. Permit the password-clear when a passkey is attached (that's998  // the legitimate "drop the password, keep the passkey" path).999  if (1000    entries[idx].type === "login" &&1001    !entries[idx].password &&1002    !entries[idx].passkey1003  ) {1004    entries[idx] = current;1005    throw new Error("password or passkey required");1006  }10071008  // Card entries must retain their card number1009  if (entries[idx].type === "card" && !entries[idx].cardNumber) {1010    entries[idx] = current;1011    throw new Error("card number is required for card entries");1012  }10131014  await persistEntries();10151016  // Breach check when password changes: clear immediately so old breach doesn't linger,1017  // then async-check the new password and only re-set if it's also found.1018  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1019    entries[idx] = { ...entries[idx], breach: undefined };1020    await persistEntries();1021    const password = entries[idx].password;1022    if (password) {1023      checkBreach(password).then(async (count) => {1024        const i = entries.findIndex((e) => e.id === id);1025        if (i !== -1 && count > 0) {1026          entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1027          await persistEntries();1028        }1029      });1030    }1031  }1032}10331034export async function deleteEntry(id: string): Promise<void> {1035  if (!derivedKey) throw new Error("vault locked");10361037  const entry = entries.find((item) => item.id === id);1038  if (!entry || entry.deletedAt) {1039    throw new Error("entry not found");1040  }10411042  entry.deletedAt = Date.now();1043  entry.modifiedAt = Date.now();1044  await persistEntries();1045}10461047export function getDeletedEntries(): VaultEntry[] {1048  if (!derivedKey) return [];1049  return entries.filter((e) => !!e.deletedAt);1050}10511052export async function restoreEntry(id: string): Promise<void> {1053  if (!derivedKey) throw new Error("vault locked");1054  const entry = entries.find((e) => e.id === id && e.deletedAt);1055  if (!entry) throw new Error("deleted entry not found");1056  delete entry.deletedAt;1057  entry.modifiedAt = Date.now();1058  await persistEntries();1059  buildDomainIndexSync(entries);1060}10611062export async function purgeEntry(id: string): Promise<void> {1063  if (!derivedKey) throw new Error("vault locked");1064  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1065  if (idx === -1) throw new Error("deleted entry not found");1066  entries.splice(idx, 1);1067  await persistEntries();1068}10691070/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */1071export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1072  if (!derivedKey || retentionMs === 0) return 0;1073  const cutoff = Date.now() - retentionMs;1074  const before = entries.length;1075  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1076  const purged = before - entries.length;1077  if (purged > 0) await persistEntries();1078  return purged;1079}10801081export function queryDomain(domain: string): VaultEntry[] {1082  return domainIndex.get(domain) ?? [];1083}10841085export function getEntryById(id: string): VaultEntry | undefined {1086  return entries.find((entry) => entry.id === id && !entry.deletedAt);1087}10881089async function persistSlotA(1090  reason: VaultSnapshot["reason"] = "local_save",1091): Promise<StoredVault> {1092  if (!derivedKey) throw new Error("vault locked");10931094  const slotA = await loadSlot(SLOT_A);1095  if (!slotA) throw new Error("missing slot A");10961097  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures1098  // are swallowed by takeVaultSnapshot — history must never block a save.1099  await takeVaultSnapshot(reason);11001101  const plaintext = encodePayload(entries, duressConfigured);1102  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);11031104  const nextSlotA: StoredVault = {1105    ...slotA,1106    nonce,1107    ciphertext,1108    tag,1109  };1110  await writeSlot(SLOT_A, nextSlotA);11111112  // When duress is not configured, keep slot B size-matched with slot A by1113  // regenerating its noise. When duress IS configured, leave slot B alone —1114  // we don't have the decoy key to re-encrypt it. This is a known trade-off.1115  if (!duressConfigured) {1116    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1117  }11181119  return nextSlotA;1120}11211122async function persistSlotB(): Promise<void> {1123  if (!derivedKey) throw new Error("vault locked");11241125  const slotB = await loadSlot(SLOT_B);1126  if (!slotB) throw new Error("missing slot B");11271128  // Decoy plaintext always reports duressConfigured=false — the decoy user1129  // sees a clean slate.1130  const plaintext = encodePayload(entries, false);1131  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);11321133  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1134}11351136async function persistEntries(1137  reason: VaultSnapshot["reason"] = "local_save",1138): Promise<void> {1139  if (!derivedKey) throw new Error("vault locked");11401141  if (vaultMode === "decoy") {1142    await persistSlotB();1143  } else {1144    await persistSlotA(reason);1145  }1146  buildDomainIndexSync(entries);1147}11481149function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1150  if (!Array.isArray(values)) return undefined;1151  const seen = new Set<string>();1152  const cleaned: string[] = [];1153  for (const value of values) {1154    const trimmed = typeof value === "string" ? value.trim() : "";1155    if (!trimmed) continue;1156    const key = trimmed.toLowerCase();1157    if (seen.has(key)) continue;1158    seen.add(key);1159    cleaned.push(trimmed);1160  }1161  return cleaned.length > 0 ? cleaned : undefined;1162}11631164function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1165  const title = entry.title.trim();1166  // Accept legacy `type:"passkey"` drafts but rewrite to "login" — the new1167  // model is "any entry, optionally with a passkey sub-object." Imports and1168  // any in-flight callers from before v2.11.0 keep working without throwing.1169  let type: VaultEntry["type"] = entry.type ?? "login";1170  if (type === "passkey") type = "login";11711172  if (!title) throw new Error("title is required");1173  // A login entry needs either a password or a passkey credential to be useful.1174  // Passkey-only entries (passkey present, no password) are valid.1175  if (type === "login" && !entry.password?.trim() && !entry.passkey) {1176    throw new Error("password or passkey required");1177  }1178  if (entry.passkey) {1179    const p = entry.passkey;1180    if (1181      !p.rpId ||1182      !p.credentialId ||1183      !p.publicKeyCose ||1184      !p.userHandle ||1185      !p.userName ||1186      (p.algorithm !== -7 && p.algorithm !== -8)1187    ) {1188      throw new Error("invalid passkey credential");1189    }1190  }1191  if (type === "card") {1192    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1193      throw new Error("cardholder name and card number are required");1194    }1195  }11961197  return {1198    type,1199    title,1200    username: type === "login" ? entry.username?.trim() ?? "" : undefined,1201    password: type === "login" ? entry.password : undefined,1202    url: type === "login" ? entry.url?.trim() ?? "" : undefined,1203    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1204    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1205    notes: entry.notes?.trim() || undefined,1206    breach: type === "login" ? entry.breach : undefined,1207    canary: type === "login" ? entry.canary : undefined,1208    tags: entry.tags,1209    passkey: entry.passkey,1210    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1211    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1212    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1213    expYear: type === "card" ? entry.expYear?.trim() : undefined,1214    cvc: type === "card" ? entry.cvc?.trim() : undefined,1215    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1216    network: type === "card" ? entry.network : undefined,1217    last4: type === "card" ? entry.last4?.trim() : undefined,1218  };1219}12201221function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1222  const nextPatch: Partial<VaultEntry> = {};12231224  if ("type" in patch) {1225    nextPatch.type = patch.type;1226  }12271228  if ("title" in patch) {1229    const title = patch.title?.trim() ?? "";1230    if (!title) throw new Error("title is required");1231    nextPatch.title = title;1232  }12331234  if ("url" in patch) {1235    nextPatch.url = patch.url?.trim() ?? "";1236  }12371238  if ("additionalUrls" in patch) {1239    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1240    const seen = new Set<string>();1241    const cleaned: string[] = [];1242    for (const value of incoming) {1243      const trimmed = typeof value === "string" ? value.trim() : "";1244      if (!trimmed) continue;1245      const key = trimmed.toLowerCase();1246      if (seen.has(key)) continue;1247      seen.add(key);1248      cleaned.push(trimmed);1249    }1250    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1251  }12521253  if ("username" in patch) {1254    nextPatch.username = patch.username?.trim() ?? "";1255  }12561257  if ("password" in patch) {1258    const password = patch.password ?? "";1259    // Allow clearing a password (e.g. on passkey-only entries)1260    nextPatch.password = password || undefined;1261  }12621263  if ("totp" in patch) {1264    nextPatch.totp = patch.totp?.trim() || undefined;1265  }12661267  if ("notes" in patch) {1268    nextPatch.notes = patch.notes?.trim() || undefined;1269  }12701271  if ("breach" in patch) {1272    nextPatch.breach = patch.breach;1273  }12741275  if ("canary" in patch) {1276    nextPatch.canary = patch.canary;1277  }12781279  if ("passkey" in patch) {1280    // Passkey sub-object replaces all the legacy flat passkey fields. A1281    // patch with `passkey: undefined` removes the credential from the entry1282    // (used by "remove passkey" without deleting the whole entry).1283    nextPatch.passkey = patch.passkey;1284  }12851286  if ("tags" in patch) {1287    nextPatch.tags = patch.tags;1288  }12891290  if ("cardholderName" in patch) {1291    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1292  }1293  if ("cardNumber" in patch) {1294    const n = patch.cardNumber ?? "";1295    nextPatch.cardNumber = n || undefined;1296  }1297  if ("expMonth" in patch) {1298    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1299  }1300  if ("expYear" in patch) {1301    nextPatch.expYear = patch.expYear?.trim() || undefined;1302  }1303  if ("cvc" in patch) {1304    nextPatch.cvc = patch.cvc?.trim() || undefined;1305  }1306  if ("cardNickname" in patch) {1307    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1308  }1309  if ("network" in patch) {1310    nextPatch.network = patch.network;1311  }1312  if ("last4" in patch) {1313    nextPatch.last4 = patch.last4?.trim() || undefined;1314  }13151316  return nextPatch;1317}13181319function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1320  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1321  const modifiedAt =1322    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;13231324  const base: VaultEntry = {1325    id: entry.id,1326    type: "type" in entry ? entry.type : "login",1327    title: entry.title,1328    username: entry.username,1329    password: entry.password,1330    url: entry.url,1331    totp: entry.totp,1332    notes: entry.notes,1333    breach: entry.breach,1334    canary: "canary" in entry ? entry.canary : undefined,1335    passkey: "passkey" in entry ? entry.passkey : undefined,1336    createdAt,1337    modifiedAt,1338    deletedAt: entry.deletedAt,1339    tags: "tags" in entry ? entry.tags : undefined,1340    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1341    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1342    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1343    expYear: "expYear" in entry ? entry.expYear : undefined,1344    cvc: "cvc" in entry ? entry.cvc : undefined,1345    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1346    network: "network" in entry ? entry.network : undefined,1347    last4: "last4" in entry ? entry.last4 : undefined,1348  };13491350  return migrateLegacyPasskey(base, entry);1351}13521353/**1354 * v2.11.0 read migration. Pre-v2.11.0 passkey entries used `type:"passkey"`1355 * with flat top-level fields (rpId, credentialId, etc.). This converts them1356 * to the new shape — `type:"login"` with a `passkey` sub-object — so all1357 * downstream consumers can rely on the new model. The result is rewritten1358 * to disk on next vault save (hasLegacyShape detects the un-migrated case).1359 */1360function migrateLegacyPasskey(1361  base: VaultEntry,1362  raw: VaultEntry | LegacyVaultEntry1363): VaultEntry {1364  if (base.passkey) return base;1365  const flat = raw as Partial<VaultEntry>;1366  const isPasskeyType = base.type === "passkey";1367  const hasFlatPasskeyFields =1368    typeof flat.rpId === "string" &&1369    typeof flat.credentialId === "string" &&1370    typeof flat.publicKeyCose === "string" &&1371    typeof flat.userHandle === "string" &&1372    typeof flat.userName === "string";1373  if (!isPasskeyType && !hasFlatPasskeyFields) return base;1374  if (!hasFlatPasskeyFields) return base;13751376  const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1377  base.passkey = {1378    rpId: flat.rpId!,1379    rpName: flat.rpName ?? flat.rpId!,1380    credentialId: flat.credentialId!,1381    publicKeyCose: flat.publicKeyCose!,1382    privateKeyJwk: flat.privateKeyJwk,1383    userHandle: flat.userHandle!,1384    userName: flat.userName!,1385    userDisplayName: flat.userDisplayName ?? flat.userName!,1386    algorithm,1387    signCount: flat.signCount ?? 0,1388    aaguid: flat.aaguid ?? "",1389    backupEligible: flat.backupEligible === true,1390    backupState: flat.backupState === true,1391    lastUsedAt: flat.lastUsedAt,1392    browser: flat.browser,1393    transports: flat.transports,1394    createdAt: base.createdAt,1395  };1396  // Promote to "login" so the unified Entry view renders. Username gets1397  // populated from the passkey's userName so the entry is searchable in the1398  // existing autofill flow without a separate code path.1399  if (isPasskeyType) base.type = "login";1400  if (!base.username) base.username = flat.userName;1401  if (!base.url) base.url = `https://${flat.rpId}`;1402  return base;1403}140414051406// ---------------------------------------------------------------------------1407// Deduplication1408// ---------------------------------------------------------------------------14091410function entryDupeKey(e: VaultEntry): string {1411  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1412  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins1413  // on different sites that happen to share a username are still distinct;1414  // two passkey credentials for the same RP+user are duplicates regardless1415  // of any login fields they may also carry.1416  if (e.passkey) {1417    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1418  }1419  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1420}14211422/** Returns the number of duplicate entries (those that would be removed). */1423export function countDuplicates(): number {1424  const best = new Map<string, VaultEntry>();1425  let dupeCount = 0;1426  for (const e of entries.filter((e) => !e.deletedAt)) {1427    const key = entryDupeKey(e);1428    const existing = best.get(key);1429    if (!existing) {1430      best.set(key, e);1431    } else {1432      dupeCount++;1433      // Keep the more recently modified one in the map1434      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1435        best.set(key, e);1436      }1437    }1438  }1439  return dupeCount;1440}14411442/**1443 * Soft-deletes duplicate entries, keeping the most recently modified copy of1444 * each group. Returns the number of entries moved to trash.1445 */1446export async function deduplicateEntries(): Promise<number> {1447  if (!derivedKey) throw new Error("vault locked");1448  // Dedup reshapes slot B permanently under the decoy key, which is1449  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1450  if (vaultMode === "decoy") {1451    throw new Error("deduplicate disabled in decoy mode");1452  }14531454  const best = new Map<string, VaultEntry>();1455  for (const e of entries.filter((en) => !en.deletedAt)) {1456    const key = entryDupeKey(e);1457    const existing = best.get(key);1458    if (!existing) {1459      best.set(key, e);1460    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1461      best.set(key, e);1462    }1463  }14641465  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1466  const now = Date.now();1467  let removed = 0;1468  entries = entries.map((e) => {1469    if (!e.deletedAt && !keepIds.has(e.id)) {1470      removed++;1471      return { ...e, deletedAt: now };1472    }1473    return e;1474  });14751476  if (removed > 0) await persistEntries();1477  return removed;1478}14791480// ---------------------------------------------------------------------------1481// Vault history — snapshot BEFORE every vault write so the saved blob is1482// what is about to be lost, not what was just written. Capped at 10. The1483// stored blobs are AES-256-GCM ciphertext under the master password, so1484// history adds zero security surface.1485// ---------------------------------------------------------------------------14861487const HISTORY_KEY = "peach_vault_history";1488const HISTORY_MAX = 10;14891490/**1491 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE1492 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost1493 * state. Identical-to-most-recent blobs are skipped to avoid filling history1494 * with no-op writes.1495 */1496export async function takeVaultSnapshot(1497  reason: VaultSnapshot["reason"],1498): Promise<void> {1499  try {1500    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1501    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1502    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];15031504    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,1505    // pre-createVault). createVault skips snapshots intentionally — there is1506    // no prior state to preserve.1507    if (!slotA) return;15081509    const vault_a = JSON.stringify(slotA);1510    const vault_b = JSON.stringify(slotB ?? slotA);15111512    // Best-effort entry count: only meaningful when the in-memory state is1513    // unlocked AND in real mode. Decoy mode would leak the decoy entry1514    // count; locked mode has no entries to count. Both fall through to null.1515    let entryCount: number | null = null;1516    if (derivedKey && vaultMode === "real") {1517      try {1518        entryCount = entries.filter((e) => !e.deletedAt).length;1519      } catch {1520        entryCount = null;1521      }1522    }15231524    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1525    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1526      ? existingRaw1527      : { snapshots: [] };15281529    // Deduplication: if the most recent snapshot has byte-identical blobs,1530    // skip — saves the same vault N times in a row would otherwise burn the1531    // entire history window with no recovery value.1532    const latest = history.snapshots[0];1533    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1534      return;1535    }15361537    const snapshot: VaultSnapshot = {1538      id: crypto.randomUUID(),1539      savedAt: Date.now(),1540      reason,1541      vault_a,1542      vault_b,1543      entryCount,1544    };15451546    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1547    await chrome.storage.local.set({ [HISTORY_KEY]: history });1548  } catch {1549    // Non-fatal — history must never block a save. A failed snapshot just1550    // means the user loses one rung of the safety ladder for this write.1551  }1552}15531554/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */1555export async function clearVaultHistory(): Promise<void> {1556  try {1557    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1558  } catch {1559    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().1560  }1561}15621563/** Read all snapshots, newest first, for the Settings vault history view. */1564export async function getVaultHistory(): Promise<VaultSnapshot[]> {1565  const raw = await chrome.storage.local.get(HISTORY_KEY);1566  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1567  if (!history || !Array.isArray(history.snapshots)) return [];1568  return history.snapshots;1569}15701571/**1572 * Replace the on-disk vault with the chosen snapshot's blobs and lock the1573 * vault. The current state is itself snapshotted with reason 'restore' first1574 * so the restore is reversible. After this returns, the user must unlock1575 * with the password that matches the restored blob.1576 */1577export async function restoreVaultSnapshot(1578  snapshotId: string,1579): Promise<{ ok: true } | { ok: false; error: string }> {1580  const history = await getVaultHistory();1581  const snapshot = history.find((s) => s.id === snapshotId);1582  if (!snapshot) return { ok: false, error: "snapshot_not_found" };15831584  // Snapshot the about-to-be-replaced current state so the restore is1585  // reversible. takeVaultSnapshot is no-op when the most recent entry is1586  // byte-identical, so a restore-of-a-restore stays clean.1587  await takeVaultSnapshot("restore");15881589  let restoredA: StoredVault;1590  let restoredB: StoredVault;1591  try {1592    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1593    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1594  } catch {1595    return { ok: false, error: "snapshot_corrupt" };1596  }15971598  await writeSlot(SLOT_A, restoredA);1599  await writeSlot(SLOT_B, restoredB);16001601  // Lock so the user must prove they can decrypt the restored blob. If they1602  // restored a pre-password-change snapshot they discover it immediately at1603  // the lock screen rather than silently holding an inaccessible vault.1604  lock();16051606  return { ok: true };1607}16081609/**1610 * Change the master password. Re-encrypts slot A with a fresh salt + new1611 * derived key. Slot B (duress or noise) is left untouched when duress is1612 * configured — it uses an independent key derived from the duress password —1613 * and regenerated as noise when duress is not configured.1614 */1615export async function changePassword(1616  currentPassword: string,1617  newPassword: string1618): Promise<boolean> {1619  if (!derivedKey || vaultMode !== "real") return false;16201621  // Verify current password before allowing any change1622  const valid = await verifyMainPassword(currentPassword);1623  if (!valid) return false;16241625  const slotA = await loadSlot(SLOT_A);1626  if (!slotA) return false;16271628  // Derive fresh key from new password + new random salt1629  const newSalt = generateSalt();1630  const newKey = await deriveKey(newPassword, newSalt);16311632  // Re-encrypt the in-memory entries with the new key1633  const plaintext = encodePayload(entries, duressConfigured);1634  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);16351636  const freshSlotA: StoredVault = {1637    ...slotA,1638    salt: hex(newSalt),1639    nonce,1640    ciphertext,1641    tag,1642  };1643  // Snapshot the OLD-key blob before overwriting it. If the user later1644  // restores from this snapshot they will need the OLD password — the1645  // post-restore lock surfaces that immediately.1646  await takeVaultSnapshot("local_save");1647  await writeSlot(SLOT_A, freshSlotA);16481649  // Swap the in-memory key synchronously before any further await. If we1650  // threw between the Slot A write and this swap, the next persistEntries()1651  // would overwrite Slot A with old-key ciphertext, locking the user out.1652  zeroKey(derivedKey);1653  derivedKey = newKey;16541655  // Regenerate noise for slot B when duress is not in use. Runs after the1656  // key swap so a failure here is a disposable noise regeneration, not a1657  // lockout.1658  if (!duressConfigured) {1659    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1660  }16611662  return true;1663}