vault.ts

56.3 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 } | { ok: false; error: string }> {710  try {711    const keyBytes = unhex(masterKeyHex);712    const masterKey = await crypto.subtle.importKey(713      "raw",714      keyBytes,715      { name: "AES-GCM" },716      false,717      ["decrypt"],718    );719    const payload = await tryDecryptSlot(await loadSlot(SLOT_A), masterKey);720    if (!payload) return { ok: false, error: "invalid_key" };721722    derivedKey = masterKey;723    vaultMode = "real";724    entries = payload.entries;725    duressConfigured = payload.duressConfigured;726    buildDomainIndexSync(entries);727    await loadSettings();728    touchAutoLock();729    return { ok: true };730  } catch {731    return { ok: false, error: "invalid_key" };732  }733}734735// Returns the current in-memory derived key as a hex string.736// Used by the biometric enable flow in Settings to store the key in Android Keystore.737export function getDerivedKeyHex(): string {738  if (!derivedKey) throw new Error("vault_not_unlocked");739  return hex(derivedKeyBytes());740}741742function derivedKeyBytes(): Uint8Array {743  const buf = derivedKey!.exportKey("raw") as ArrayBuffer;744  return new Uint8Array(buf);745}746747export function getEntries(): VaultEntry[] {748  if (!derivedKey) throw new Error("vault locked");749  return entries.filter((entry) => !entry.deletedAt);750}751752const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;753754export function getIdentities(): SavedIdentity[] {755  if (!derivedKey) throw new Error("vault locked");756  const active = entries.filter((e) => !e.deletedAt && e.type === "login");757758  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();759  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();760761  for (const entry of active) {762    const value = entry.username?.trim();763    if (!value) continue;764    const map = EMAIL_RE.test(value) ? emailMap : usernameMap;765    const existing = map.get(value);766    const domain = extractDomain(entry.url ?? "") ?? "";767    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;768    if (!existing || modifiedAt > existing.lastUsed) {769      map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });770    } else {771      map.set(value, { ...existing, count: existing.count + 1 });772    }773  }774775  const toIdentity = (type: "email" | "username") =>776    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({777      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,778    });779780  return [781    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),782    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),783  ];784}785786export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {787  if (!derivedKey) throw new Error("vault locked");788789  const now = Date.now();790  const added: VaultEntry[] = [];791  for (const draft of drafts) {792    try {793      const sanitized = sanitizeEntryDraft(draft);794      const nextEntry: VaultEntry = {795        ...sanitized,796        id: crypto.randomUUID(),797        createdAt: now,798        modifiedAt: now,799      };800      entries.push(nextEntry);801      added.push(nextEntry);802    } catch {803      // Skip invalid drafts silently — caller can see diff between input and output length804    }805  }806807  if (added.length > 0) {808    await persistEntries();809  }810811  return added;812}813814/**815 * Restore a set of entries in full fidelity — preserves id, createdAt,816 * modifiedAt, and every other field unlike `batchAddEntries` which817 * assigns fresh identity. Used by restore paths where the backup is the818 * authoritative source of truth. `mode` controls how entries already in819 * the vault are treated.820 */821export async function restoreEntries(822  incoming: VaultEntry[],823  mode: "replace" | "merge"824): Promise<{ added: number; updated: number; skipped: number }> {825  if (!derivedKey) throw new Error("vault locked");826827  // A replace with zero incoming entries is almost certainly a caller bug828  // (e.g. an import that silently dropped everything). Refuse rather than829  // wipe the vault.830  if (mode === "replace" && incoming.length === 0) {831    throw new Error("refuse to replace with empty entry set");832  }833834  let added = 0;835  let updated = 0;836  let skipped = 0;837838  if (mode === "replace") {839    entries = [];840    for (const entry of incoming) {841      const normalized = sanitizeRestoredEntry(entry);842      if (!normalized) {843        skipped += 1;844        continue;845      }846      entries.push(normalized);847      added += 1;848    }849  } else {850    const byId = new Map<string, VaultEntry>();851    for (const e of entries) byId.set(e.id, e);852853    for (const entry of incoming) {854      const normalized = sanitizeRestoredEntry(entry);855      if (!normalized) {856        skipped += 1;857        continue;858      }859      const existing = byId.get(normalized.id);860      if (!existing) {861        entries.push(normalized);862        added += 1;863      } else if (normalized.modifiedAt > existing.modifiedAt) {864        const idx = entries.findIndex((e) => e.id === existing.id);865        if (idx !== -1) {866          entries[idx] = normalized;867          updated += 1;868        }869      } else {870        skipped += 1;871      }872    }873  }874875  await persistEntries("import");876  return { added, updated, skipped };877}878879/**880 * Validate + normalise a VaultEntry coming from a backup. Accepts881 * anything structurally sound; keeps all metadata verbatim. Returns882 * null for values the live vault shouldn't accept (e.g., missing title).883 */884function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {885  if (!entry || typeof entry !== "object") return null;886  const title = typeof entry.title === "string" ? entry.title.trim() : "";887  if (!title) return null;888  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();889  const now = Date.now();890  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;891  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;892893  // Migrate legacy passkey shape on import (same logic as on-disk read).894  // Imports from pre-v2.11.0 vault backups still carry type:"passkey" with895  // flat fields; bring them into the new shape immediately.896  const migrated: VaultEntry = migrateLegacyPasskey(897    {898      ...entry,899      id,900      title,901      type: entry.type ?? "login",902      createdAt,903      modifiedAt,904    },905    entry906  );907908  // Login entries from backup must carry a password (or be passkey-only,909  // in which case the passkey sub-object replaces the password requirement).910  if (911    migrated.type === "login" &&912    !migrated.passkey &&913    typeof migrated.password !== "string"914  ) {915    return null;916  }917  if (migrated.passkey && !migrated.passkey.credentialId) {918    return null;919  }920921  return migrated;922}923924// Soft-deletes every non-deleted entry in one call. The literal string925// argument is a tripwire against accidental calls — any caller that would926// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this927// to a boolean.928export async function clearVaultEntries(929  confirmation: "REPLACE_ALL"930): Promise<void> {931  if (confirmation !== "REPLACE_ALL") {932    throw new Error("clearVaultEntries requires explicit confirmation");933  }934  if (!derivedKey) throw new Error("vault locked");935  for (const entry of entries) {936    if (!entry.deletedAt) {937      entry.deletedAt = Date.now();938      entry.modifiedAt = Date.now();939    }940  }941  await persistEntries();942}943944export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {945  if (!derivedKey) throw new Error("vault locked");946947  const sanitized = sanitizeEntryDraft(entry);948  const now = Date.now();949  const nextEntry: VaultEntry = {950    ...sanitized,951    id: crypto.randomUUID(),952    createdAt: now,953    modifiedAt: now,954    // Stamp passwordChangedAt at creation so age tracking is accurate from955    // day one. Non-login entries carry the same stamp harmlessly.956    passwordChangedAt: sanitized.password ? now : undefined,957  };958959  entries.push(nextEntry);960  await persistEntries();961962  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)963  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {964    checkBreach(nextEntry.password).then(async (count) => {965      if (count > 0) {966        const idx = entries.findIndex((e) => e.id === nextEntry.id);967        if (idx !== -1) {968          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };969          await persistEntries();970        }971      }972    });973  }974975  return nextEntry;976}977978export async function updateEntry(979  id: string,980  patch: Partial<VaultEntry>981): Promise<void> {982  if (!derivedKey) throw new Error("vault locked");983984  const idx = entries.findIndex((entry) => entry.id === id);985  if (idx === -1 || entries[idx].deletedAt) {986    throw new Error("entry not found");987  }988989  const current = entries[idx];990  const sanitizedPatch = sanitizeEntryPatch(patch);991  // Refresh passwordChangedAt only when the password value actually changes —992  // a metadata-only update (title, tags, etc.) must not reset the age clock993  // that drives the Pro health rotation recommendation.994  const passwordChanged =995    "password" in patch &&996    typeof sanitizedPatch.password === "string" &&997    sanitizedPatch.password !== current.password;998  entries[idx] = {999    ...current,1000    ...sanitizedPatch,1001    modifiedAt: Date.now(),1002    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1003  };10041005  // A login entry must carry SOMETHING fillable — either a password or a1006  // passkey credential. An empty-password login with no passkey is dead1007  // state. Permit the password-clear when a passkey is attached (that's1008  // the legitimate "drop the password, keep the passkey" path).1009  if (1010    entries[idx].type === "login" &&1011    !entries[idx].password &&1012    !entries[idx].passkey1013  ) {1014    entries[idx] = current;1015    throw new Error("password or passkey required");1016  }10171018  // Card entries must retain their card number1019  if (entries[idx].type === "card" && !entries[idx].cardNumber) {1020    entries[idx] = current;1021    throw new Error("card number is required for card entries");1022  }10231024  await persistEntries();10251026  // Breach check when password changes: clear immediately so old breach doesn't linger,1027  // then async-check the new password and only re-set if it's also found.1028  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1029    entries[idx] = { ...entries[idx], breach: undefined };1030    await persistEntries();1031    const password = entries[idx].password;1032    if (password) {1033      checkBreach(password).then(async (count) => {1034        const i = entries.findIndex((e) => e.id === id);1035        if (i !== -1 && count > 0) {1036          entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1037          await persistEntries();1038        }1039      });1040    }1041  }1042}10431044export async function deleteEntry(id: string): Promise<void> {1045  if (!derivedKey) throw new Error("vault locked");10461047  const entry = entries.find((item) => item.id === id);1048  if (!entry || entry.deletedAt) {1049    throw new Error("entry not found");1050  }10511052  entry.deletedAt = Date.now();1053  entry.modifiedAt = Date.now();1054  await persistEntries();1055}10561057export function getDeletedEntries(): VaultEntry[] {1058  if (!derivedKey) return [];1059  return entries.filter((e) => !!e.deletedAt);1060}10611062export async function restoreEntry(id: string): Promise<void> {1063  if (!derivedKey) throw new Error("vault locked");1064  const entry = entries.find((e) => e.id === id && e.deletedAt);1065  if (!entry) throw new Error("deleted entry not found");1066  delete entry.deletedAt;1067  entry.modifiedAt = Date.now();1068  await persistEntries();1069  buildDomainIndexSync(entries);1070}10711072export async function purgeEntry(id: string): Promise<void> {1073  if (!derivedKey) throw new Error("vault locked");1074  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1075  if (idx === -1) throw new Error("deleted entry not found");1076  entries.splice(idx, 1);1077  await persistEntries();1078}10791080/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */1081export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1082  if (!derivedKey || retentionMs === 0) return 0;1083  const cutoff = Date.now() - retentionMs;1084  const before = entries.length;1085  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1086  const purged = before - entries.length;1087  if (purged > 0) await persistEntries();1088  return purged;1089}10901091export function queryDomain(domain: string): VaultEntry[] {1092  return domainIndex.get(domain) ?? [];1093}10941095export function getEntryById(id: string): VaultEntry | undefined {1096  return entries.find((entry) => entry.id === id && !entry.deletedAt);1097}10981099async function persistSlotA(1100  reason: VaultSnapshot["reason"] = "local_save",1101): Promise<StoredVault> {1102  if (!derivedKey) throw new Error("vault locked");11031104  const slotA = await loadSlot(SLOT_A);1105  if (!slotA) throw new Error("missing slot A");11061107  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures1108  // are swallowed by takeVaultSnapshot — history must never block a save.1109  await takeVaultSnapshot(reason);11101111  const plaintext = encodePayload(entries, duressConfigured);1112  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);11131114  const nextSlotA: StoredVault = {1115    ...slotA,1116    nonce,1117    ciphertext,1118    tag,1119  };1120  await writeSlot(SLOT_A, nextSlotA);11211122  // When duress is not configured, keep slot B size-matched with slot A by1123  // regenerating its noise. When duress IS configured, leave slot B alone —1124  // we don't have the decoy key to re-encrypt it. This is a known trade-off.1125  if (!duressConfigured) {1126    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1127  }11281129  return nextSlotA;1130}11311132async function persistSlotB(): Promise<void> {1133  if (!derivedKey) throw new Error("vault locked");11341135  const slotB = await loadSlot(SLOT_B);1136  if (!slotB) throw new Error("missing slot B");11371138  // Decoy plaintext always reports duressConfigured=false — the decoy user1139  // sees a clean slate.1140  const plaintext = encodePayload(entries, false);1141  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);11421143  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1144}11451146async function persistEntries(1147  reason: VaultSnapshot["reason"] = "local_save",1148): Promise<void> {1149  if (!derivedKey) throw new Error("vault locked");11501151  if (vaultMode === "decoy") {1152    await persistSlotB();1153  } else {1154    await persistSlotA(reason);1155  }1156  buildDomainIndexSync(entries);1157}11581159function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1160  if (!Array.isArray(values)) return undefined;1161  const seen = new Set<string>();1162  const cleaned: string[] = [];1163  for (const value of values) {1164    const trimmed = typeof value === "string" ? value.trim() : "";1165    if (!trimmed) continue;1166    const key = trimmed.toLowerCase();1167    if (seen.has(key)) continue;1168    seen.add(key);1169    cleaned.push(trimmed);1170  }1171  return cleaned.length > 0 ? cleaned : undefined;1172}11731174function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1175  const title = entry.title.trim();1176  // Accept legacy `type:"passkey"` drafts but rewrite to "login" — the new1177  // model is "any entry, optionally with a passkey sub-object." Imports and1178  // any in-flight callers from before v2.11.0 keep working without throwing.1179  let type: VaultEntry["type"] = entry.type ?? "login";1180  if (type === "passkey") type = "login";11811182  if (!title) throw new Error("title is required");1183  // A login entry needs either a password or a passkey credential to be useful.1184  // Passkey-only entries (passkey present, no password) are valid.1185  if (type === "login" && !entry.password?.trim() && !entry.passkey) {1186    throw new Error("password or passkey required");1187  }1188  if (entry.passkey) {1189    const p = entry.passkey;1190    if (1191      !p.rpId ||1192      !p.credentialId ||1193      !p.publicKeyCose ||1194      !p.userHandle ||1195      !p.userName ||1196      (p.algorithm !== -7 && p.algorithm !== -8)1197    ) {1198      throw new Error("invalid passkey credential");1199    }1200  }1201  if (type === "card") {1202    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1203      throw new Error("cardholder name and card number are required");1204    }1205  }12061207  return {1208    type,1209    title,1210    username: type === "login" ? entry.username?.trim() ?? "" : undefined,1211    password: type === "login" ? entry.password : undefined,1212    url: type === "login" ? entry.url?.trim() ?? "" : undefined,1213    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1214    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1215    notes: entry.notes?.trim() || undefined,1216    breach: type === "login" ? entry.breach : undefined,1217    canary: type === "login" ? entry.canary : undefined,1218    tags: entry.tags,1219    passkey: entry.passkey,1220    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1221    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1222    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1223    expYear: type === "card" ? entry.expYear?.trim() : undefined,1224    cvc: type === "card" ? entry.cvc?.trim() : undefined,1225    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1226    network: type === "card" ? entry.network : undefined,1227    last4: type === "card" ? entry.last4?.trim() : undefined,1228  };1229}12301231function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1232  const nextPatch: Partial<VaultEntry> = {};12331234  if ("type" in patch) {1235    nextPatch.type = patch.type;1236  }12371238  if ("title" in patch) {1239    const title = patch.title?.trim() ?? "";1240    if (!title) throw new Error("title is required");1241    nextPatch.title = title;1242  }12431244  if ("url" in patch) {1245    nextPatch.url = patch.url?.trim() ?? "";1246  }12471248  if ("additionalUrls" in patch) {1249    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1250    const seen = new Set<string>();1251    const cleaned: string[] = [];1252    for (const value of incoming) {1253      const trimmed = typeof value === "string" ? value.trim() : "";1254      if (!trimmed) continue;1255      const key = trimmed.toLowerCase();1256      if (seen.has(key)) continue;1257      seen.add(key);1258      cleaned.push(trimmed);1259    }1260    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1261  }12621263  if ("username" in patch) {1264    nextPatch.username = patch.username?.trim() ?? "";1265  }12661267  if ("password" in patch) {1268    const password = patch.password ?? "";1269    // Allow clearing a password (e.g. on passkey-only entries)1270    nextPatch.password = password || undefined;1271  }12721273  if ("totp" in patch) {1274    nextPatch.totp = patch.totp?.trim() || undefined;1275  }12761277  if ("notes" in patch) {1278    nextPatch.notes = patch.notes?.trim() || undefined;1279  }12801281  if ("breach" in patch) {1282    nextPatch.breach = patch.breach;1283  }12841285  if ("canary" in patch) {1286    nextPatch.canary = patch.canary;1287  }12881289  if ("passkey" in patch) {1290    // Passkey sub-object replaces all the legacy flat passkey fields. A1291    // patch with `passkey: undefined` removes the credential from the entry1292    // (used by "remove passkey" without deleting the whole entry).1293    nextPatch.passkey = patch.passkey;1294  }12951296  if ("tags" in patch) {1297    nextPatch.tags = patch.tags;1298  }12991300  if ("cardholderName" in patch) {1301    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1302  }1303  if ("cardNumber" in patch) {1304    const n = patch.cardNumber ?? "";1305    nextPatch.cardNumber = n || undefined;1306  }1307  if ("expMonth" in patch) {1308    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1309  }1310  if ("expYear" in patch) {1311    nextPatch.expYear = patch.expYear?.trim() || undefined;1312  }1313  if ("cvc" in patch) {1314    nextPatch.cvc = patch.cvc?.trim() || undefined;1315  }1316  if ("cardNickname" in patch) {1317    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1318  }1319  if ("network" in patch) {1320    nextPatch.network = patch.network;1321  }1322  if ("last4" in patch) {1323    nextPatch.last4 = patch.last4?.trim() || undefined;1324  }13251326  return nextPatch;1327}13281329function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1330  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1331  const modifiedAt =1332    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;13331334  const base: VaultEntry = {1335    id: entry.id,1336    type: "type" in entry ? entry.type : "login",1337    title: entry.title,1338    username: entry.username,1339    password: entry.password,1340    url: entry.url,1341    totp: entry.totp,1342    notes: entry.notes,1343    breach: entry.breach,1344    canary: "canary" in entry ? entry.canary : undefined,1345    passkey: "passkey" in entry ? entry.passkey : undefined,1346    createdAt,1347    modifiedAt,1348    deletedAt: entry.deletedAt,1349    tags: "tags" in entry ? entry.tags : undefined,1350    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1351    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1352    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1353    expYear: "expYear" in entry ? entry.expYear : undefined,1354    cvc: "cvc" in entry ? entry.cvc : undefined,1355    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1356    network: "network" in entry ? entry.network : undefined,1357    last4: "last4" in entry ? entry.last4 : undefined,1358  };13591360  return migrateLegacyPasskey(base, entry);1361}13621363/**1364 * v2.11.0 read migration. Pre-v2.11.0 passkey entries used `type:"passkey"`1365 * with flat top-level fields (rpId, credentialId, etc.). This converts them1366 * to the new shape — `type:"login"` with a `passkey` sub-object — so all1367 * downstream consumers can rely on the new model. The result is rewritten1368 * to disk on next vault save (hasLegacyShape detects the un-migrated case).1369 */1370function migrateLegacyPasskey(1371  base: VaultEntry,1372  raw: VaultEntry | LegacyVaultEntry1373): VaultEntry {1374  if (base.passkey) return base;1375  const flat = raw as Partial<VaultEntry>;1376  const isPasskeyType = base.type === "passkey";1377  const hasFlatPasskeyFields =1378    typeof flat.rpId === "string" &&1379    typeof flat.credentialId === "string" &&1380    typeof flat.publicKeyCose === "string" &&1381    typeof flat.userHandle === "string" &&1382    typeof flat.userName === "string";1383  if (!isPasskeyType && !hasFlatPasskeyFields) return base;1384  if (!hasFlatPasskeyFields) return base;13851386  const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1387  base.passkey = {1388    rpId: flat.rpId!,1389    rpName: flat.rpName ?? flat.rpId!,1390    credentialId: flat.credentialId!,1391    publicKeyCose: flat.publicKeyCose!,1392    privateKeyJwk: flat.privateKeyJwk,1393    userHandle: flat.userHandle!,1394    userName: flat.userName!,1395    userDisplayName: flat.userDisplayName ?? flat.userName!,1396    algorithm,1397    signCount: flat.signCount ?? 0,1398    aaguid: flat.aaguid ?? "",1399    backupEligible: flat.backupEligible === true,1400    backupState: flat.backupState === true,1401    lastUsedAt: flat.lastUsedAt,1402    browser: flat.browser,1403    transports: flat.transports,1404    createdAt: base.createdAt,1405  };1406  // Promote to "login" so the unified Entry view renders. Username gets1407  // populated from the passkey's userName so the entry is searchable in the1408  // existing autofill flow without a separate code path.1409  if (isPasskeyType) base.type = "login";1410  if (!base.username) base.username = flat.userName;1411  if (!base.url) base.url = `https://${flat.rpId}`;1412  return base;1413}141414151416// ---------------------------------------------------------------------------1417// Deduplication1418// ---------------------------------------------------------------------------14191420function entryDupeKey(e: VaultEntry): string {1421  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1422  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins1423  // on different sites that happen to share a username are still distinct;1424  // two passkey credentials for the same RP+user are duplicates regardless1425  // of any login fields they may also carry.1426  if (e.passkey) {1427    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1428  }1429  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1430}14311432/** Returns the number of duplicate entries (those that would be removed). */1433export function countDuplicates(): number {1434  const best = new Map<string, VaultEntry>();1435  let dupeCount = 0;1436  for (const e of entries.filter((e) => !e.deletedAt)) {1437    const key = entryDupeKey(e);1438    const existing = best.get(key);1439    if (!existing) {1440      best.set(key, e);1441    } else {1442      dupeCount++;1443      // Keep the more recently modified one in the map1444      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1445        best.set(key, e);1446      }1447    }1448  }1449  return dupeCount;1450}14511452/**1453 * Soft-deletes duplicate entries, keeping the most recently modified copy of1454 * each group. Returns the number of entries moved to trash.1455 */1456export async function deduplicateEntries(): Promise<number> {1457  if (!derivedKey) throw new Error("vault locked");1458  // Dedup reshapes slot B permanently under the decoy key, which is1459  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1460  if (vaultMode === "decoy") {1461    throw new Error("deduplicate disabled in decoy mode");1462  }14631464  const best = new Map<string, VaultEntry>();1465  for (const e of entries.filter((en) => !en.deletedAt)) {1466    const key = entryDupeKey(e);1467    const existing = best.get(key);1468    if (!existing) {1469      best.set(key, e);1470    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1471      best.set(key, e);1472    }1473  }14741475  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1476  const now = Date.now();1477  let removed = 0;1478  entries = entries.map((e) => {1479    if (!e.deletedAt && !keepIds.has(e.id)) {1480      removed++;1481      return { ...e, deletedAt: now };1482    }1483    return e;1484  });14851486  if (removed > 0) await persistEntries();1487  return removed;1488}14891490// ---------------------------------------------------------------------------1491// Vault history — snapshot BEFORE every vault write so the saved blob is1492// what is about to be lost, not what was just written. Capped at 10. The1493// stored blobs are AES-256-GCM ciphertext under the master password, so1494// history adds zero security surface.1495// ---------------------------------------------------------------------------14961497const HISTORY_KEY = "peach_vault_history";1498const HISTORY_MAX = 10;14991500/**1501 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE1502 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost1503 * state. Identical-to-most-recent blobs are skipped to avoid filling history1504 * with no-op writes.1505 */1506export async function takeVaultSnapshot(1507  reason: VaultSnapshot["reason"],1508): Promise<void> {1509  try {1510    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1511    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1512    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];15131514    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,1515    // pre-createVault). createVault skips snapshots intentionally — there is1516    // no prior state to preserve.1517    if (!slotA) return;15181519    const vault_a = JSON.stringify(slotA);1520    const vault_b = JSON.stringify(slotB ?? slotA);15211522    // Best-effort entry count: only meaningful when the in-memory state is1523    // unlocked AND in real mode. Decoy mode would leak the decoy entry1524    // count; locked mode has no entries to count. Both fall through to null.1525    let entryCount: number | null = null;1526    if (derivedKey && vaultMode === "real") {1527      try {1528        entryCount = entries.filter((e) => !e.deletedAt).length;1529      } catch {1530        entryCount = null;1531      }1532    }15331534    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1535    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1536      ? existingRaw1537      : { snapshots: [] };15381539    // Deduplication: if the most recent snapshot has byte-identical blobs,1540    // skip — saves the same vault N times in a row would otherwise burn the1541    // entire history window with no recovery value.1542    const latest = history.snapshots[0];1543    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1544      return;1545    }15461547    const snapshot: VaultSnapshot = {1548      id: crypto.randomUUID(),1549      savedAt: Date.now(),1550      reason,1551      vault_a,1552      vault_b,1553      entryCount,1554    };15551556    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1557    await chrome.storage.local.set({ [HISTORY_KEY]: history });1558  } catch {1559    // Non-fatal — history must never block a save. A failed snapshot just1560    // means the user loses one rung of the safety ladder for this write.1561  }1562}15631564/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */1565export async function clearVaultHistory(): Promise<void> {1566  try {1567    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1568  } catch {1569    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().1570  }1571}15721573/** Read all snapshots, newest first, for the Settings vault history view. */1574export async function getVaultHistory(): Promise<VaultSnapshot[]> {1575  const raw = await chrome.storage.local.get(HISTORY_KEY);1576  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1577  if (!history || !Array.isArray(history.snapshots)) return [];1578  return history.snapshots;1579}15801581/**1582 * Replace the on-disk vault with the chosen snapshot's blobs and lock the1583 * vault. The current state is itself snapshotted with reason 'restore' first1584 * so the restore is reversible. After this returns, the user must unlock1585 * with the password that matches the restored blob.1586 */1587export async function restoreVaultSnapshot(1588  snapshotId: string,1589): Promise<{ ok: true } | { ok: false; error: string }> {1590  const history = await getVaultHistory();1591  const snapshot = history.find((s) => s.id === snapshotId);1592  if (!snapshot) return { ok: false, error: "snapshot_not_found" };15931594  // Snapshot the about-to-be-replaced current state so the restore is1595  // reversible. takeVaultSnapshot is no-op when the most recent entry is1596  // byte-identical, so a restore-of-a-restore stays clean.1597  await takeVaultSnapshot("restore");15981599  let restoredA: StoredVault;1600  let restoredB: StoredVault;1601  try {1602    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1603    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1604  } catch {1605    return { ok: false, error: "snapshot_corrupt" };1606  }16071608  await writeSlot(SLOT_A, restoredA);1609  await writeSlot(SLOT_B, restoredB);16101611  // Lock so the user must prove they can decrypt the restored blob. If they1612  // restored a pre-password-change snapshot they discover it immediately at1613  // the lock screen rather than silently holding an inaccessible vault.1614  lock();16151616  return { ok: true };1617}16181619/**1620 * Change the master password. Re-encrypts slot A with a fresh salt + new1621 * derived key. Slot B (duress or noise) is left untouched when duress is1622 * configured — it uses an independent key derived from the duress password —1623 * and regenerated as noise when duress is not configured.1624 */1625export async function changePassword(1626  currentPassword: string,1627  newPassword: string1628): Promise<boolean> {1629  if (!derivedKey || vaultMode !== "real") return false;16301631  // Verify current password before allowing any change1632  const valid = await verifyMainPassword(currentPassword);1633  if (!valid) return false;16341635  const slotA = await loadSlot(SLOT_A);1636  if (!slotA) return false;16371638  // Derive fresh key from new password + new random salt1639  const newSalt = generateSalt();1640  const newKey = await deriveKey(newPassword, newSalt);16411642  // Re-encrypt the in-memory entries with the new key1643  const plaintext = encodePayload(entries, duressConfigured);1644  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);16451646  const freshSlotA: StoredVault = {1647    ...slotA,1648    salt: hex(newSalt),1649    nonce,1650    ciphertext,1651    tag,1652  };1653  // Snapshot the OLD-key blob before overwriting it. If the user later1654  // restores from this snapshot they will need the OLD password — the1655  // post-restore lock surfaces that immediately.1656  await takeVaultSnapshot("local_save");1657  await writeSlot(SLOT_A, freshSlotA);16581659  // Swap the in-memory key synchronously before any further await. If we1660  // threw between the Slot A write and this swap, the next persistEntries()1661  // would overwrite Slot A with old-key ciphertext, locking the user out.1662  zeroKey(derivedKey);1663  derivedKey = newKey;16641665  // Regenerate noise for slot B when duress is not in use. Runs after the1666  // key swap so a failure here is a disposable noise regeneration, not a1667  // lockout.1668  if (!duressConfigured) {1669    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1670  }16711672  return true;1673}