vault.ts

44.9 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  StoredVault,19  VaultEntry,20} from "../shared/types";21import { decrypt, encrypt, zeroKey } from "./aes";22import { clearAutoLock, touchAutoLock } from "./alarms";2324type EntryDraft = Omit<25  VaultEntry,26  "id" | "createdAt" | "modifiedAt" | "deletedAt"27>;2829// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B30// either holds the decoy vault (when duress is configured) or random noise31// that looks identical at rest. No external indicator is stored — duress32// configuration is recorded only inside the encrypted payload of slot A.33const SLOT_A = "peach_vault_a";34const SLOT_B = "peach_vault_b";3536// Legacy keys, migrated away on first access.37const LEGACY_MAIN = "peach_vault";38const LEGACY_DECOY = "peach_decoy_vault";39const LEGACY_DURESS_FLAG = "peach_duress";4041// Plaintext is padded to the next multiple of this size before encryption.42// This coarsens ciphertext-length leakage into 1 KiB buckets.43const PAD_BLOCK = 1024;4445let derivedKey: Uint8Array | null = null;46let entries: VaultEntry[] = [];47let domainIndex: Map<string, VaultEntry[]> = new Map();48let vaultMode: "real" | "decoy" = "real";49// Cached post-unlock view of whether the real vault records that duress is50// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock51// this stays false because we never see slot A's plaintext.52let duressConfigured = false;5354let migrationRan = false;5556// ---------------------------------------------------------------------------57// Settings (persisted inside the encrypted vault store as a sidecar key)58// ---------------------------------------------------------------------------5960export interface VaultSettings {61  accentColor?: AccentColorName;62  theme?: "dark" | "light";63  autoLockMinutes?: number;64  clipboardClearSeconds?: number;65  lockOnClose?: boolean;66  breachMonitoring?: boolean;67  passkeyProviderEnabled?: boolean;68  trashRetentionDays?: number;69  manualSummonDomains?: Record<string, ManualSummonRecord>;70  declinedCardFingerprints?: string[];71  /**72   * User overrides for keyboard shortcuts. Keys are ShortcutAction names73   * (see src/shared/shortcuts.ts). Missing actions fall through to74   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the75   * canonical lookup. Storing only the deltas keeps the vault payload76   * small and lets default updates land for users who never rebind.77   */78  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;79}8081let settings: VaultSettings = {};8283export function getSettings(): VaultSettings {84  return settings;85}8687export async function ensureSettingsLoaded(): Promise<VaultSettings> {88  await loadSettings();89  return settings;90}9192export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {93  await loadSettings();94  settings = { ...settings, ...patch };95  await persistSettings();96}9798async function persistSettings(): Promise<void> {99  try {100    await chrome.storage.local.set({ peach_settings: settings });101  } catch (err) {102    throw mapStorageError(err);103  }104}105106function mapStorageError(err: unknown): Error {107  const message = err instanceof Error ? err.message : String(err);108  if (/QUOTA_BYTES|quota/i.test(message)) {109    return new Error("STORAGE_QUOTA_EXCEEDED");110  }111  return err instanceof Error ? err : new Error(message);112}113114async function loadSettings(): Promise<void> {115  const result = await chrome.storage.local.get("peach_settings");116  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};117}118119// ---------------------------------------------------------------------------120// HIBP breach check (k-anonymity model)121// ---------------------------------------------------------------------------122123export async function checkBreach(password: string): Promise<number> {124  const encoder = new TextEncoder();125  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));126  const hashHex = Array.from(new Uint8Array(hashBuffer))127    .map((b) => b.toString(16).padStart(2, "0"))128    .join("")129    .toUpperCase();130131  const prefix = hashHex.slice(0, 5);132  const suffix = hashHex.slice(5);133134  try {135    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);136    if (!res.ok) return 0;137    const text = await res.text();138    const match = text.split("\r\n").find((l) => l.startsWith(suffix));139    return match ? parseInt(match.split(":")[1], 10) : 0;140  } catch {141    return 0; // Network unavailable — don't block142  }143}144145export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {146  const fingerprint = await cardFingerprint(last4, expMonth, expYear);147  await loadSettings();148  settings = {149    ...settings,150    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],151  };152  await persistSettings();153}154155export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {156  await loadSettings();157  const fingerprint = await cardFingerprint(last4, expMonth, expYear);158  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);159}160161async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {162  const encoder = new TextEncoder();163  const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));164  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");165}166167function hex(buf: Uint8Array): string {168  return Array.from(buf)169    .map((b) => b.toString(16).padStart(2, "0"))170    .join("");171}172173function unhex(s: string): Uint8Array {174  const out = new Uint8Array(s.length / 2);175  for (let i = 0; i < s.length; i += 2) {176    out[i / 2] = parseInt(s.slice(i, i + 2), 16);177  }178  return out;179}180181// ---------------------------------------------------------------------------182// Storage schema migration183// ---------------------------------------------------------------------------184185async function loadSlot(key: string): Promise<StoredVault | null> {186  const result = await chrome.storage.local.get(key);187  return (result as Record<string, StoredVault | undefined>)[key] ?? null;188}189190async function writeSlot(key: string, vault: StoredVault): Promise<void> {191  try {192    await chrome.storage.local.set({ [key]: vault });193  } catch (err) {194    throw mapStorageError(err);195  }196}197198/**199 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault200 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +201 * peach_vault_b). Runs at most once per session; safe to call repeatedly.202 */203async function ensureMigrated(): Promise<void> {204  if (migrationRan) return;205  migrationRan = true;206207  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];208  const raw = await chrome.storage.local.get(keys);209  const slotAExists = raw[SLOT_A] !== undefined;210  const slotBExists = raw[SLOT_B] !== undefined;211  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;212  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;213214  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.215  if (!slotAExists && !legacyMain) return;216217  // Already migrated.218  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;219220  if (legacyMain && !slotAExists) {221    await writeSlot(SLOT_A, legacyMain);222  }223224  // If slot B isn't set, populate it: use legacy decoy when present, else noise.225  if (!slotBExists) {226    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;227    if (legacyDecoy) {228      await writeSlot(SLOT_B, legacyDecoy);229    } else if (target) {230      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));231    }232  }233234  const toRemove: string[] = [];235  if (legacyMain) toRemove.push(LEGACY_MAIN);236  if (legacyDecoy) toRemove.push(LEGACY_DECOY);237  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);238  if (toRemove.length > 0) {239    await chrome.storage.local.remove(toRemove);240  }241}242243// ---------------------------------------------------------------------------244// Padding + noise helpers245// ---------------------------------------------------------------------------246247/**248 * Pads the plaintext string with trailing spaces so that the encoded byte249 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing250 * whitespace after a complete document, so the padding is transparent on251 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.252 */253function padPlaintext(json: string): string {254  const encoded = new TextEncoder().encode(json);255  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);256  const padLen = target - encoded.length;257  return json + " ".repeat(padLen);258}259260/**261 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The262 * ciphertext length matches the given byte count. Because GCM ciphertext is263 * pseudo-random, this blob is indistinguishable at rest from a real264 * encrypted vault without the key.265 */266function generateNoiseBlob(ciphertextBytes: number): StoredVault {267  const salt = crypto.getRandomValues(new Uint8Array(16));268  const nonce = crypto.getRandomValues(new Uint8Array(12));269  const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));270  const tag = crypto.getRandomValues(new Uint8Array(16));271  return {272    version: 1,273    salt: hex(salt),274    nonce: hex(nonce),275    ciphertext: hex(cipher),276    tag: hex(tag),277  };278}279280// ---------------------------------------------------------------------------281// Encrypted plaintext envelope282// ---------------------------------------------------------------------------283284type VaultPayload = {285  entries: VaultEntry[];286  duressConfigured?: boolean;287};288289type DecryptedPayload = {290  entries: VaultEntry[];291  duressConfigured: boolean;292};293294function encodePayload(entries: VaultEntry[], duress: boolean): string {295  const envelope: VaultPayload = { entries, duressConfigured: duress };296  return padPlaintext(JSON.stringify(envelope));297}298299function decodePayload(plain: string): DecryptedPayload {300  const parsed = JSON.parse(plain) as301    | Array<VaultEntry | LegacyVaultEntry>302    | VaultPayload;303304  if (Array.isArray(parsed)) {305    // Pre-A-2 plaintext: bare array of entries, no duress flag.306    return {307      entries: parsed.map(normalizeEntry),308      duressConfigured: false,309    };310  }311312  return {313    entries: (parsed.entries ?? []).map(normalizeEntry),314    duressConfigured: parsed.duressConfigured === true,315  };316}317318async function tryDecryptSlot(319  vault: StoredVault,320  key: Uint8Array321): Promise<DecryptedPayload | null> {322  try {323    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);324    return decodePayload(plain);325  } catch {326    return null;327  }328}329330// ---------------------------------------------------------------------------331// Core operations332// ---------------------------------------------------------------------------333334function buildDomainIndexSync(all: VaultEntry[]): void {335  domainIndex = new Map();336337  for (const entry of all) {338    if (entry.deletedAt) continue;339    if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;340341    // An entry can match multiple sites via `additionalUrls`. Index each342    // hostname so `queryDomain` returns the entry regardless of which alias343    // the user is currently visiting. Dedupe per-entry so a single entry344    // never appears twice in one bucket.345    const domains = new Set<string>();346    const primary = extractDomain(entry.url ?? "");347    if (primary) domains.add(primary);348    for (const extra of entry.additionalUrls ?? []) {349      const d = extractDomain(extra);350      if (d) domains.add(d);351    }352    if (domains.size === 0) continue;353354    for (const domain of domains) {355      const bucket = domainIndex.get(domain) ?? [];356      bucket.push(entry);357      domainIndex.set(domain, bucket);358    }359  }360}361362export async function createVault(password: string): Promise<void> {363  await ensureMigrated();364365  const salt = generateSalt();366  derivedKey = await deriveKey(password, salt);367  entries = [];368  vaultMode = "real";369  duressConfigured = false;370  buildDomainIndexSync(entries);371372  const plaintext = encodePayload(entries, duressConfigured);373  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);374375  const slotA: StoredVault = {376    version: 1,377    salt: hex(salt),378    nonce,379    ciphertext,380    tag,381  };382  await writeSlot(SLOT_A, slotA);383384  // Matching-size noise blob for slot B, so a fresh install shows two385  // similarly-shaped records regardless of whether duress is ever set up.386  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));387388  touchAutoLock();389}390391export async function hasVault(): Promise<boolean> {392  await ensureMigrated();393  return (await loadSlot(SLOT_A)) !== null;394}395396/**397 * Returns whether the real vault has recorded that a duress passphrase is398 * configured. Reliable only after a real-mode unlock — before unlock or in399 * decoy mode, returns false because we cannot read slot A's plaintext.400 */401export async function isDuressEnabled(): Promise<boolean> {402  return vaultMode === "real" && duressConfigured;403}404405export async function hasDecoyVault(): Promise<boolean> {406  return isDuressEnabled();407}408409export async function setupDuress(duressPassword: string): Promise<void> {410  if (!derivedKey || vaultMode !== "real") {411    throw new Error("vault must be unlocked in real mode");412  }413  await ensureMigrated();414415  // Encrypt the decoy payload (empty entries) under the duress key. Pad to416  // at least the current slot A ciphertext size so the two slots look the417  // same at rest at setup time.418  const slotA = await loadSlot(SLOT_A);419  if (!slotA) throw new Error("missing slot A");420421  const decoySalt = generateSalt();422  const decoyKey = await deriveKey(duressPassword, decoySalt);423  try {424    // Pad the decoy plaintext to match slot A's ciphertext length (minus the425    // 16-byte tag) so encrypted sizes align exactly.426    const targetBytes = slotA.ciphertext.length / 2;427    let plaintext = encodePayload([], false);428    const encoded = new TextEncoder().encode(plaintext);429    if (encoded.length < targetBytes) {430      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);431    }432    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);433    await writeSlot(SLOT_B, {434      version: 1,435      salt: hex(decoySalt),436      nonce,437      ciphertext,438      tag,439    });440  } finally {441    zeroKey(decoyKey);442  }443444  // Re-encrypt slot A with the duressConfigured flag set to true. No external445  // flag is written — the bit lives inside the encrypted envelope.446  duressConfigured = true;447  await persistSlotA();448}449450export async function disableDuress(): Promise<void> {451  if (!derivedKey || vaultMode !== "real") {452    throw new Error("vault must be unlocked in real mode");453  }454  await ensureMigrated();455456  // Overwrite slot B with fresh noise that matches slot A's size. The slot457  // remains occupied so its mere existence does not signal that duress was458  // ever configured.459  const slotA = await loadSlot(SLOT_A);460  if (slotA) {461    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));462  }463464  duressConfigured = false;465  await persistSlotA();466}467468export async function verifyMainPassword(password: string): Promise<boolean> {469  await ensureMigrated();470  const slotA = await loadSlot(SLOT_A);471  if (!slotA) return false;472  const key = await deriveKey(password, unhex(slotA.salt));473  try {474    return (await tryDecryptSlot(slotA, key)) !== null;475  } finally {476    zeroKey(key);477  }478}479480export async function unlock(password: string): Promise<boolean> {481  await ensureMigrated();482483  // Load both slots in parallel. Both always exist post-migration; the484  // parallel load and the parallel derivation below keep wall-clock time485  // constant regardless of which slot's passphrase (real or decoy) the486  // caller supplied, so a timing side channel cannot distinguish the487  // duress path from the real-unlock path (DUR-002).488  const [slotA, slotB] = await Promise.all([489    loadSlot(SLOT_A),490    loadSlot(SLOT_B),491  ]);492493  // Derive both keys in parallel, unconditionally.494  const [keyA, keyB] = await Promise.all([495    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),496    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),497  ]);498499  // Attempt both decrypts in parallel.500  const [payloadA, payloadB] = await Promise.all([501    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),502    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),503  ]);504505  if (payloadA && keyA) {506    derivedKey = keyA;507    vaultMode = "real";508    entries = payloadA.entries;509    duressConfigured = payloadA.duressConfigured;510    buildDomainIndexSync(entries);511    await loadSettings();512    touchAutoLock();513    if (keyB) zeroKey(keyB);514    // If the payload came from the pre-envelope (legacy) shape, rewrite515    // it immediately so the next save uses the new format.516    if (hasLegacyShape(payloadA.entries)) {517      await persistSlotA();518    }519    return true;520  }521522  if (payloadB && keyB) {523    derivedKey = keyB;524    vaultMode = "decoy";525    entries = payloadB.entries;526    duressConfigured = false;527    buildDomainIndexSync(entries);528    await loadSettings();529    touchAutoLock();530    if (keyA) zeroKey(keyA);531    return true;532  }533534  if (keyA) zeroKey(keyA);535  if (keyB) zeroKey(keyB);536  return false;537}538539export function lock(): void {540  if (derivedKey) {541    zeroKey(derivedKey);542    derivedKey = null;543  }544545  entries = [];546  domainIndex = new Map();547  vaultMode = "real";548  duressConfigured = false;549  clearAutoLock();550}551552export function isUnlocked(): boolean {553  return derivedKey !== null;554}555556export function getEntries(): VaultEntry[] {557  if (!derivedKey) throw new Error("vault locked");558  return entries.filter((entry) => !entry.deletedAt);559}560561export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {562  if (!derivedKey) throw new Error("vault locked");563564  const now = Date.now();565  const added: VaultEntry[] = [];566  for (const draft of drafts) {567    try {568      const sanitized = sanitizeEntryDraft(draft);569      const nextEntry: VaultEntry = {570        ...sanitized,571        id: crypto.randomUUID(),572        createdAt: now,573        modifiedAt: now,574      };575      entries.push(nextEntry);576      added.push(nextEntry);577    } catch {578      // Skip invalid drafts silently — caller can see diff between input and output length579    }580  }581582  if (added.length > 0) {583    await persistEntries();584  }585586  return added;587}588589/**590 * Restore a set of entries in full fidelity — preserves id, createdAt,591 * modifiedAt, and every other field unlike `batchAddEntries` which592 * assigns fresh identity. Used by the CJK phrase restore path where the593 * backup is the authoritative source of truth. `mode` controls how594 * entries already in the vault are treated.595 */596export async function restoreEntries(597  incoming: VaultEntry[],598  mode: "replace" | "merge"599): Promise<{ added: number; updated: number; skipped: number }> {600  if (!derivedKey) throw new Error("vault locked");601602  // A replace with zero incoming entries is almost certainly a caller bug603  // (e.g. an import that silently dropped everything). Refuse rather than604  // wipe the vault.605  if (mode === "replace" && incoming.length === 0) {606    throw new Error("refuse to replace with empty entry set");607  }608609  let added = 0;610  let updated = 0;611  let skipped = 0;612613  if (mode === "replace") {614    entries = [];615    for (const entry of incoming) {616      const normalized = sanitizeRestoredEntry(entry);617      if (!normalized) {618        skipped += 1;619        continue;620      }621      entries.push(normalized);622      added += 1;623    }624  } else {625    const byId = new Map<string, VaultEntry>();626    for (const e of entries) byId.set(e.id, e);627628    for (const entry of incoming) {629      const normalized = sanitizeRestoredEntry(entry);630      if (!normalized) {631        skipped += 1;632        continue;633      }634      const existing = byId.get(normalized.id);635      if (!existing) {636        entries.push(normalized);637        added += 1;638      } else if (normalized.modifiedAt > existing.modifiedAt) {639        const idx = entries.findIndex((e) => e.id === existing.id);640        if (idx !== -1) {641          entries[idx] = normalized;642          updated += 1;643        }644      } else {645        skipped += 1;646      }647    }648  }649650  await persistEntries();651  return { added, updated, skipped };652}653654/**655 * Validate + normalise a VaultEntry coming from a backup. Accepts656 * anything structurally sound; keeps all metadata verbatim. Returns657 * null for values the live vault shouldn't accept (e.g., missing title).658 */659function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {660  if (!entry || typeof entry !== "object") return null;661  const title = typeof entry.title === "string" ? entry.title.trim() : "";662  if (!title) return null;663  const type = entry.type ?? "login";664  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();665  const now = Date.now();666  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;667  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;668669  if (type === "login" && typeof entry.password !== "string") {670    return null;671  }672  if (673    type === "passkey" &&674    (typeof entry.rpId !== "string" ||675      typeof entry.credentialId !== "string" ||676      !entry.privateKeyJwk)677  ) {678    return null;679  }680681  return {682    ...entry,683    id,684    title,685    type,686    createdAt,687    modifiedAt,688  };689}690691// Soft-deletes every non-deleted entry in one call. The literal string692// argument is a tripwire against accidental calls — any caller that would693// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this694// to a boolean.695export async function clearVaultEntries(696  confirmation: "REPLACE_ALL"697): Promise<void> {698  if (confirmation !== "REPLACE_ALL") {699    throw new Error("clearVaultEntries requires explicit confirmation");700  }701  if (!derivedKey) throw new Error("vault locked");702  for (const entry of entries) {703    if (!entry.deletedAt) {704      entry.deletedAt = Date.now();705      entry.modifiedAt = Date.now();706    }707  }708  await persistEntries();709}710711export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {712  if (!derivedKey) throw new Error("vault locked");713714  const sanitized = sanitizeEntryDraft(entry);715  const now = Date.now();716  const nextEntry: VaultEntry = {717    ...sanitized,718    id: crypto.randomUUID(),719    createdAt: now,720    modifiedAt: now,721  };722723  entries.push(nextEntry);724  await persistEntries();725726  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)727  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {728    checkBreach(nextEntry.password).then(async (count) => {729      if (count > 0) {730        const idx = entries.findIndex((e) => e.id === nextEntry.id);731        if (idx !== -1) {732          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };733          await persistEntries();734        }735      }736    });737  }738739  return nextEntry;740}741742export async function updateEntry(743  id: string,744  patch: Partial<VaultEntry>745): Promise<void> {746  if (!derivedKey) throw new Error("vault locked");747748  const idx = entries.findIndex((entry) => entry.id === id);749  if (idx === -1 || entries[idx].deletedAt) {750    throw new Error("entry not found");751  }752753  const current = entries[idx];754  entries[idx] = {755    ...current,756    ...sanitizeEntryPatch(patch),757    modifiedAt: Date.now(),758  };759760  // sanitizeEntryPatch maps an empty password to `undefined`. For login761  // entries that would silently produce a passwordless record — roll back762  // the merge and refuse.763  if (entries[idx].type === "login" && !entries[idx].password) {764    entries[idx] = current;765    throw new Error("password is required for login entries");766  }767768  // Card entries must retain their card number769  if (entries[idx].type === "card" && !entries[idx].cardNumber) {770    entries[idx] = current;771    throw new Error("card number is required for card entries");772  }773774  await persistEntries();775776  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)777  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {778    const password = entries[idx].password!;779    checkBreach(password).then(async (count) => {780      const i = entries.findIndex((e) => e.id === id);781      if (i !== -1) {782        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };783        await persistEntries();784      }785    });786  }787}788789export async function deleteEntry(id: string): Promise<void> {790  if (!derivedKey) throw new Error("vault locked");791792  const entry = entries.find((item) => item.id === id);793  if (!entry || entry.deletedAt) {794    throw new Error("entry not found");795  }796797  entry.deletedAt = Date.now();798  entry.modifiedAt = Date.now();799  await persistEntries();800}801802export function getDeletedEntries(): VaultEntry[] {803  if (!derivedKey) return [];804  return entries.filter((e) => !!e.deletedAt);805}806807export async function restoreEntry(id: string): Promise<void> {808  if (!derivedKey) throw new Error("vault locked");809  const entry = entries.find((e) => e.id === id && e.deletedAt);810  if (!entry) throw new Error("deleted entry not found");811  delete entry.deletedAt;812  entry.modifiedAt = Date.now();813  await persistEntries();814  buildDomainIndexSync(entries);815}816817export async function purgeEntry(id: string): Promise<void> {818  if (!derivedKey) throw new Error("vault locked");819  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);820  if (idx === -1) throw new Error("deleted entry not found");821  entries.splice(idx, 1);822  await persistEntries();823}824825/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */826export async function purgeExpiredTrash(retentionMs: number): Promise<number> {827  if (!derivedKey || retentionMs === 0) return 0;828  const cutoff = Date.now() - retentionMs;829  const before = entries.length;830  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));831  const purged = before - entries.length;832  if (purged > 0) await persistEntries();833  return purged;834}835836export function queryDomain(domain: string): VaultEntry[] {837  return domainIndex.get(domain) ?? [];838}839840export function getEntryById(id: string): VaultEntry | undefined {841  return entries.find((entry) => entry.id === id && !entry.deletedAt);842}843844async function persistSlotA(): Promise<StoredVault> {845  if (!derivedKey) throw new Error("vault locked");846847  const slotA = await loadSlot(SLOT_A);848  if (!slotA) throw new Error("missing slot A");849850  const plaintext = encodePayload(entries, duressConfigured);851  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);852853  const nextSlotA: StoredVault = {854    ...slotA,855    nonce,856    ciphertext,857    tag,858  };859  await writeSlot(SLOT_A, nextSlotA);860861  // When duress is not configured, keep slot B size-matched with slot A by862  // regenerating its noise. When duress IS configured, leave slot B alone —863  // we don't have the decoy key to re-encrypt it. This is a known trade-off.864  if (!duressConfigured) {865    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));866  }867868  return nextSlotA;869}870871async function persistSlotB(): Promise<void> {872  if (!derivedKey) throw new Error("vault locked");873874  const slotB = await loadSlot(SLOT_B);875  if (!slotB) throw new Error("missing slot B");876877  // Decoy plaintext always reports duressConfigured=false — the decoy user878  // sees a clean slate.879  const plaintext = encodePayload(entries, false);880  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);881882  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });883}884885async function persistEntries(): Promise<void> {886  if (!derivedKey) throw new Error("vault locked");887888  if (vaultMode === "decoy") {889    await persistSlotB();890  } else {891    const freshSlotA = await persistSlotA();892    // Save a snapshot of the real vault after every successful write.893    // Failures are silently swallowed — snapshots must never interrupt saves.894    const activeCount = entries.filter((e) => !e.deletedAt).length;895    void takeSnapshot(freshSlotA, activeCount);896  }897  buildDomainIndexSync(entries);898}899900function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {901  if (!Array.isArray(values)) return undefined;902  const seen = new Set<string>();903  const cleaned: string[] = [];904  for (const value of values) {905    const trimmed = typeof value === "string" ? value.trim() : "";906    if (!trimmed) continue;907    const key = trimmed.toLowerCase();908    if (seen.has(key)) continue;909    seen.add(key);910    cleaned.push(trimmed);911  }912  return cleaned.length > 0 ? cleaned : undefined;913}914915function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {916  const title = entry.title.trim();917  const type = entry.type ?? "login";918919  if (!title) throw new Error("title is required");920  if (type === "login" && !entry.password?.trim()) {921    throw new Error("password is required");922  }923  if (type === "passkey") {924    if (925      !entry.rpId?.trim() ||926      !entry.rpName?.trim() ||927      !entry.credentialId?.trim() ||928      !entry.publicKeyCose?.trim() ||929      !entry.privateKeyJwk ||930      !entry.userHandle?.trim() ||931      !entry.userName?.trim() ||932      !entry.userDisplayName?.trim() ||933      (entry.algorithm !== -7 && entry.algorithm !== -8)934    ) {935      throw new Error("invalid passkey entry");936    }937  }938  if (type === "card") {939    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {940      throw new Error("cardholder name and card number are required");941    }942  }943944  return {945    type,946    title,947    username: type === "login" ? entry.username?.trim() ?? "" : undefined,948    password: type === "login" ? entry.password : undefined,949    url: type === "login" ? entry.url?.trim() ?? "" : undefined,950    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,951    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,952    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,953    breach: type === "login" ? entry.breach : undefined,954    canary: type === "login" ? entry.canary : undefined,955    tags: entry.tags,956    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,957    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,958    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,959    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,960    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,961    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,962    userName: type === "passkey" ? entry.userName?.trim() : undefined,963    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,964    algorithm: type === "passkey" ? entry.algorithm : undefined,965    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,966    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,967    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,968    backupState: type === "passkey" ? entry.backupState === true : undefined,969    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,970    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,971    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,972    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,973    expYear: type === "card" ? entry.expYear?.trim() : undefined,974    cvc: type === "card" ? entry.cvc?.trim() : undefined,975    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,976    network: type === "card" ? entry.network : undefined,977    last4: type === "card" ? entry.last4?.trim() : undefined,978  };979}980981function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {982  const nextPatch: Partial<VaultEntry> = {};983984  if ("type" in patch) {985    nextPatch.type = patch.type;986  }987988  if ("title" in patch) {989    const title = patch.title?.trim() ?? "";990    if (!title) throw new Error("title is required");991    nextPatch.title = title;992  }993994  if ("url" in patch) {995    nextPatch.url = patch.url?.trim() ?? "";996  }997998  if ("additionalUrls" in patch) {999    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1000    const seen = new Set<string>();1001    const cleaned: string[] = [];1002    for (const value of incoming) {1003      const trimmed = typeof value === "string" ? value.trim() : "";1004      if (!trimmed) continue;1005      const key = trimmed.toLowerCase();1006      if (seen.has(key)) continue;1007      seen.add(key);1008      cleaned.push(trimmed);1009    }1010    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1011  }10121013  if ("username" in patch) {1014    nextPatch.username = patch.username?.trim() ?? "";1015  }10161017  if ("password" in patch) {1018    const password = patch.password ?? "";1019    // Allow clearing a password (e.g. on passkey-only entries)1020    nextPatch.password = password || undefined;1021  }10221023  if ("totp" in patch) {1024    nextPatch.totp = patch.totp?.trim() || undefined;1025  }10261027  if ("notes" in patch) {1028    nextPatch.notes = patch.notes?.trim() || undefined;1029  }10301031  if ("breach" in patch) {1032    nextPatch.breach = patch.breach;1033  }10341035  if ("canary" in patch) {1036    nextPatch.canary = patch.canary;1037  }10381039  if ("rpId" in patch) {1040    nextPatch.rpId = patch.rpId?.trim();1041  }10421043  if ("rpName" in patch) {1044    nextPatch.rpName = patch.rpName?.trim();1045  }10461047  if ("credentialId" in patch) {1048    nextPatch.credentialId = patch.credentialId?.trim();1049  }10501051  if ("publicKeyCose" in patch) {1052    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1053  }10541055  if ("privateKeyJwk" in patch) {1056    nextPatch.privateKeyJwk = patch.privateKeyJwk;1057  }10581059  if ("userHandle" in patch) {1060    nextPatch.userHandle = patch.userHandle?.trim();1061  }10621063  if ("userName" in patch) {1064    nextPatch.userName = patch.userName?.trim();1065  }10661067  if ("userDisplayName" in patch) {1068    nextPatch.userDisplayName = patch.userDisplayName?.trim();1069  }10701071  if ("algorithm" in patch) {1072    nextPatch.algorithm = patch.algorithm;1073  }10741075  if ("signCount" in patch) {1076    nextPatch.signCount = patch.signCount;1077  }10781079  if ("aaguid" in patch) {1080    nextPatch.aaguid = patch.aaguid?.trim();1081  }10821083  if ("backupEligible" in patch) {1084    nextPatch.backupEligible = patch.backupEligible;1085  }10861087  if ("backupState" in patch) {1088    nextPatch.backupState = patch.backupState;1089  }10901091  if ("lastUsedAt" in patch) {1092    nextPatch.lastUsedAt = patch.lastUsedAt;1093  }10941095  if ("tags" in patch) {1096    nextPatch.tags = patch.tags;1097  }10981099  if ("cardholderName" in patch) {1100    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1101  }1102  if ("cardNumber" in patch) {1103    const n = patch.cardNumber ?? "";1104    nextPatch.cardNumber = n || undefined;1105  }1106  if ("expMonth" in patch) {1107    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1108  }1109  if ("expYear" in patch) {1110    nextPatch.expYear = patch.expYear?.trim() || undefined;1111  }1112  if ("cvc" in patch) {1113    nextPatch.cvc = patch.cvc?.trim() || undefined;1114  }1115  if ("cardNickname" in patch) {1116    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1117  }1118  if ("network" in patch) {1119    nextPatch.network = patch.network;1120  }1121  if ("last4" in patch) {1122    nextPatch.last4 = patch.last4?.trim() || undefined;1123  }11241125  return nextPatch;1126}11271128function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1129  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1130  const modifiedAt =1131    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;11321133  return {1134    id: entry.id,1135    type: "type" in entry ? entry.type : "login",1136    title: entry.title,1137    username: entry.username,1138    password: entry.password,1139    url: entry.url,1140    totp: entry.totp,1141    notes: entry.notes,1142    breach: entry.breach,1143    canary: "canary" in entry ? entry.canary : undefined,1144    rpId: "rpId" in entry ? entry.rpId : undefined,1145    rpName: "rpName" in entry ? entry.rpName : undefined,1146    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1147    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1148    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1149    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1150    userName: "userName" in entry ? entry.userName : undefined,1151    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1152    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1153    signCount: "signCount" in entry ? entry.signCount : undefined,1154    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1155    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1156    backupState: "backupState" in entry ? entry.backupState : undefined,1157    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1158    createdAt,1159    modifiedAt,1160    deletedAt: entry.deletedAt,1161    tags: "tags" in entry ? entry.tags : undefined,1162    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1163    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1164    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1165    expYear: "expYear" in entry ? entry.expYear : undefined,1166    cvc: "cvc" in entry ? entry.cvc : undefined,1167    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1168    network: "network" in entry ? entry.network : undefined,1169    last4: "last4" in entry ? entry.last4 : undefined,1170  };1171}11721173function hasLegacyShape(parsed: VaultEntry[]): boolean {1174  return parsed.some(1175    (entry) => "created" in entry || "updated" in entry1176  );1177}11781179// ---------------------------------------------------------------------------1180// Deduplication1181// ---------------------------------------------------------------------------11821183function entryDupeKey(e: VaultEntry): string {1184  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1185  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1186  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1187}11881189/** Returns the number of duplicate entries (those that would be removed). */1190export function countDuplicates(): number {1191  const best = new Map<string, VaultEntry>();1192  let dupeCount = 0;1193  for (const e of entries.filter((e) => !e.deletedAt)) {1194    const key = entryDupeKey(e);1195    const existing = best.get(key);1196    if (!existing) {1197      best.set(key, e);1198    } else {1199      dupeCount++;1200      // Keep the more recently modified one in the map1201      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1202        best.set(key, e);1203      }1204    }1205  }1206  return dupeCount;1207}12081209/**1210 * Soft-deletes duplicate entries, keeping the most recently modified copy of1211 * each group. Returns the number of entries moved to trash.1212 */1213export async function deduplicateEntries(): Promise<number> {1214  if (!derivedKey) throw new Error("vault locked");1215  // Dedup reshapes slot B permanently under the decoy key, which is1216  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1217  if (vaultMode === "decoy") {1218    throw new Error("deduplicate disabled in decoy mode");1219  }12201221  const best = new Map<string, VaultEntry>();1222  for (const e of entries.filter((en) => !en.deletedAt)) {1223    const key = entryDupeKey(e);1224    const existing = best.get(key);1225    if (!existing) {1226      best.set(key, e);1227    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1228      best.set(key, e);1229    }1230  }12311232  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1233  const now = Date.now();1234  let removed = 0;1235  entries = entries.map((e) => {1236    if (!e.deletedAt && !keepIds.has(e.id)) {1237      removed++;1238      return { ...e, deletedAt: now };1239    }1240    return e;1241  });12421243  if (removed > 0) await persistEntries();1244  return removed;1245}12461247// ---------------------------------------------------------------------------1248// Vault snapshot system — last 6 encrypted states, used for self-recovery1249// ---------------------------------------------------------------------------12501251const SNAPSHOT_KEY = "peach_vault_snapshots";1252const MAX_SNAPSHOTS = 6;12531254interface VaultSnapshot {1255  timestamp: number;1256  entryCount: number;1257  slot: StoredVault;1258}12591260/** Public summary (no sensitive data) returned to the popup. */1261export interface SnapshotSummary {1262  index: number;1263  timestamp: number;1264  entryCount: number;1265}12661267async function takeSnapshot(1268  slot: StoredVault,1269  entryCount: number1270): Promise<void> {1271  try {1272    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1273    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1274    const next: VaultSnapshot[] = [1275      { timestamp: Date.now(), entryCount, slot },1276      ...existing,1277    ].slice(0, MAX_SNAPSHOTS);1278    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1279  } catch {1280    // Non-fatal — snapshot failure must never interrupt vault saves1281  }1282}12831284export async function listSnapshots(): Promise<SnapshotSummary[]> {1285  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1286  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1287  return snapshots.map((s, i) => ({1288    index: i,1289    timestamp: s.timestamp,1290    entryCount: s.entryCount,1291  }));1292}12931294/**1295 * Restore a snapshot by index. The password must decrypt that snapshot1296 * (i.e., the same master password used when the snapshot was taken).1297 * On success the vault is unlocked in-memory with the restored entries1298 * — no re-login required.1299 */1300export async function restoreFromSnapshot(1301  index: number,1302  password: string1303): Promise<boolean> {1304  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1305  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1306  const snapshot = snapshots[index];1307  if (!snapshot) return false;13081309  // Verify the password decrypts this snapshot1310  const key = await deriveKey(password, unhex(snapshot.slot.salt));1311  const payload = await tryDecryptSlot(snapshot.slot, key);1312  if (!payload) {1313    zeroKey(key);1314    return false;1315  }13161317  // Write the snapshot back to slot A. Regenerate noise for slot B.1318  await writeSlot(SLOT_A, snapshot.slot);1319  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));13201321  // Swap in-memory state to the restored vault — caller gets immediate access1322  if (derivedKey) zeroKey(derivedKey);1323  derivedKey = key;1324  entries = payload.entries;1325  duressConfigured = payload.duressConfigured;1326  vaultMode = "real";1327  buildDomainIndexSync(entries);1328  await loadSettings();1329  touchAutoLock();13301331  return true;1332}13331334/**1335 * Change the master password. Re-encrypts slot A with a fresh salt + new1336 * derived key. Slot B (duress or noise) is left untouched when duress is1337 * configured — it uses an independent key derived from the duress password —1338 * and regenerated as noise when duress is not configured.1339 */1340export async function changePassword(1341  currentPassword: string,1342  newPassword: string1343): Promise<boolean> {1344  if (!derivedKey || vaultMode !== "real") return false;13451346  // Verify current password before allowing any change1347  const valid = await verifyMainPassword(currentPassword);1348  if (!valid) return false;13491350  const slotA = await loadSlot(SLOT_A);1351  if (!slotA) return false;13521353  // Derive fresh key from new password + new random salt1354  const newSalt = generateSalt();1355  const newKey = await deriveKey(newPassword, newSalt);13561357  // Re-encrypt the in-memory entries with the new key1358  const plaintext = encodePayload(entries, duressConfigured);1359  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);13601361  const freshSlotA: StoredVault = {1362    ...slotA,1363    salt: hex(newSalt),1364    nonce,1365    ciphertext,1366    tag,1367  };1368  await writeSlot(SLOT_A, freshSlotA);13691370  // Swap the in-memory key synchronously before any further await. If we1371  // threw between the Slot A write and this swap, the next persistEntries()1372  // would overwrite Slot A with old-key ciphertext, locking the user out.1373  zeroKey(derivedKey);1374  derivedKey = newKey;13751376  // Snapshot now that in-memory key and disk are consistent. Pass the1377  // slot content we just wrote rather than re-reading from storage so the1378  // snapshot is immune to any microtask-coalesced read-after-write race.1379  const activeCount = entries.filter((e) => !e.deletedAt).length;1380  void takeSnapshot(freshSlotA, activeCount);13811382  // Regenerate noise for slot B when duress is not in use. Runs after the1383  // key swap so a failure here is a disposable noise regeneration, not a1384  // lockout.1385  if (!duressConfigured) {1386    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1387  }13881389  return true;1390}