vault.ts

44.4 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}7273let settings: VaultSettings = {};7475export function getSettings(): VaultSettings {76  return settings;77}7879export async function ensureSettingsLoaded(): Promise<VaultSettings> {80  await loadSettings();81  return settings;82}8384export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {85  await loadSettings();86  settings = { ...settings, ...patch };87  await persistSettings();88}8990async function persistSettings(): Promise<void> {91  try {92    await chrome.storage.local.set({ peach_settings: settings });93  } catch (err) {94    throw mapStorageError(err);95  }96}9798function mapStorageError(err: unknown): Error {99  const message = err instanceof Error ? err.message : String(err);100  if (/QUOTA_BYTES|quota/i.test(message)) {101    return new Error("STORAGE_QUOTA_EXCEEDED");102  }103  return err instanceof Error ? err : new Error(message);104}105106async function loadSettings(): Promise<void> {107  const result = await chrome.storage.local.get("peach_settings");108  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};109}110111// ---------------------------------------------------------------------------112// HIBP breach check (k-anonymity model)113// ---------------------------------------------------------------------------114115export async function checkBreach(password: string): Promise<number> {116  const encoder = new TextEncoder();117  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));118  const hashHex = Array.from(new Uint8Array(hashBuffer))119    .map((b) => b.toString(16).padStart(2, "0"))120    .join("")121    .toUpperCase();122123  const prefix = hashHex.slice(0, 5);124  const suffix = hashHex.slice(5);125126  try {127    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);128    if (!res.ok) return 0;129    const text = await res.text();130    const match = text.split("\r\n").find((l) => l.startsWith(suffix));131    return match ? parseInt(match.split(":")[1], 10) : 0;132  } catch {133    return 0; // Network unavailable — don't block134  }135}136137export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {138  const fingerprint = await cardFingerprint(last4, expMonth, expYear);139  await loadSettings();140  settings = {141    ...settings,142    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],143  };144  await persistSettings();145}146147export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {148  await loadSettings();149  const fingerprint = await cardFingerprint(last4, expMonth, expYear);150  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);151}152153async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {154  const encoder = new TextEncoder();155  const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));156  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");157}158159function hex(buf: Uint8Array): string {160  return Array.from(buf)161    .map((b) => b.toString(16).padStart(2, "0"))162    .join("");163}164165function unhex(s: string): Uint8Array {166  const out = new Uint8Array(s.length / 2);167  for (let i = 0; i < s.length; i += 2) {168    out[i / 2] = parseInt(s.slice(i, i + 2), 16);169  }170  return out;171}172173// ---------------------------------------------------------------------------174// Storage schema migration175// ---------------------------------------------------------------------------176177async function loadSlot(key: string): Promise<StoredVault | null> {178  const result = await chrome.storage.local.get(key);179  return (result as Record<string, StoredVault | undefined>)[key] ?? null;180}181182async function writeSlot(key: string, vault: StoredVault): Promise<void> {183  try {184    await chrome.storage.local.set({ [key]: vault });185  } catch (err) {186    throw mapStorageError(err);187  }188}189190/**191 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault192 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +193 * peach_vault_b). Runs at most once per session; safe to call repeatedly.194 */195async function ensureMigrated(): Promise<void> {196  if (migrationRan) return;197  migrationRan = true;198199  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];200  const raw = await chrome.storage.local.get(keys);201  const slotAExists = raw[SLOT_A] !== undefined;202  const slotBExists = raw[SLOT_B] !== undefined;203  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;204  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;205206  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.207  if (!slotAExists && !legacyMain) return;208209  // Already migrated.210  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;211212  if (legacyMain && !slotAExists) {213    await writeSlot(SLOT_A, legacyMain);214  }215216  // If slot B isn't set, populate it: use legacy decoy when present, else noise.217  if (!slotBExists) {218    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;219    if (legacyDecoy) {220      await writeSlot(SLOT_B, legacyDecoy);221    } else if (target) {222      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));223    }224  }225226  const toRemove: string[] = [];227  if (legacyMain) toRemove.push(LEGACY_MAIN);228  if (legacyDecoy) toRemove.push(LEGACY_DECOY);229  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);230  if (toRemove.length > 0) {231    await chrome.storage.local.remove(toRemove);232  }233}234235// ---------------------------------------------------------------------------236// Padding + noise helpers237// ---------------------------------------------------------------------------238239/**240 * Pads the plaintext string with trailing spaces so that the encoded byte241 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing242 * whitespace after a complete document, so the padding is transparent on243 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.244 */245function padPlaintext(json: string): string {246  const encoded = new TextEncoder().encode(json);247  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);248  const padLen = target - encoded.length;249  return json + " ".repeat(padLen);250}251252/**253 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The254 * ciphertext length matches the given byte count. Because GCM ciphertext is255 * pseudo-random, this blob is indistinguishable at rest from a real256 * encrypted vault without the key.257 */258function generateNoiseBlob(ciphertextBytes: number): StoredVault {259  const salt = crypto.getRandomValues(new Uint8Array(16));260  const nonce = crypto.getRandomValues(new Uint8Array(12));261  const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));262  const tag = crypto.getRandomValues(new Uint8Array(16));263  return {264    version: 1,265    salt: hex(salt),266    nonce: hex(nonce),267    ciphertext: hex(cipher),268    tag: hex(tag),269  };270}271272// ---------------------------------------------------------------------------273// Encrypted plaintext envelope274// ---------------------------------------------------------------------------275276type VaultPayload = {277  entries: VaultEntry[];278  duressConfigured?: boolean;279};280281type DecryptedPayload = {282  entries: VaultEntry[];283  duressConfigured: boolean;284};285286function encodePayload(entries: VaultEntry[], duress: boolean): string {287  const envelope: VaultPayload = { entries, duressConfigured: duress };288  return padPlaintext(JSON.stringify(envelope));289}290291function decodePayload(plain: string): DecryptedPayload {292  const parsed = JSON.parse(plain) as293    | Array<VaultEntry | LegacyVaultEntry>294    | VaultPayload;295296  if (Array.isArray(parsed)) {297    // Pre-A-2 plaintext: bare array of entries, no duress flag.298    return {299      entries: parsed.map(normalizeEntry),300      duressConfigured: false,301    };302  }303304  return {305    entries: (parsed.entries ?? []).map(normalizeEntry),306    duressConfigured: parsed.duressConfigured === true,307  };308}309310async function tryDecryptSlot(311  vault: StoredVault,312  key: Uint8Array313): Promise<DecryptedPayload | null> {314  try {315    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);316    return decodePayload(plain);317  } catch {318    return null;319  }320}321322// ---------------------------------------------------------------------------323// Core operations324// ---------------------------------------------------------------------------325326function buildDomainIndexSync(all: VaultEntry[]): void {327  domainIndex = new Map();328329  for (const entry of all) {330    if (entry.deletedAt) continue;331    if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;332333    // An entry can match multiple sites via `additionalUrls`. Index each334    // hostname so `queryDomain` returns the entry regardless of which alias335    // the user is currently visiting. Dedupe per-entry so a single entry336    // never appears twice in one bucket.337    const domains = new Set<string>();338    const primary = extractDomain(entry.url ?? "");339    if (primary) domains.add(primary);340    for (const extra of entry.additionalUrls ?? []) {341      const d = extractDomain(extra);342      if (d) domains.add(d);343    }344    if (domains.size === 0) continue;345346    for (const domain of domains) {347      const bucket = domainIndex.get(domain) ?? [];348      bucket.push(entry);349      domainIndex.set(domain, bucket);350    }351  }352}353354export async function createVault(password: string): Promise<void> {355  await ensureMigrated();356357  const salt = generateSalt();358  derivedKey = await deriveKey(password, salt);359  entries = [];360  vaultMode = "real";361  duressConfigured = false;362  buildDomainIndexSync(entries);363364  const plaintext = encodePayload(entries, duressConfigured);365  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);366367  const slotA: StoredVault = {368    version: 1,369    salt: hex(salt),370    nonce,371    ciphertext,372    tag,373  };374  await writeSlot(SLOT_A, slotA);375376  // Matching-size noise blob for slot B, so a fresh install shows two377  // similarly-shaped records regardless of whether duress is ever set up.378  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));379380  touchAutoLock();381}382383export async function hasVault(): Promise<boolean> {384  await ensureMigrated();385  return (await loadSlot(SLOT_A)) !== null;386}387388/**389 * Returns whether the real vault has recorded that a duress passphrase is390 * configured. Reliable only after a real-mode unlock — before unlock or in391 * decoy mode, returns false because we cannot read slot A's plaintext.392 */393export async function isDuressEnabled(): Promise<boolean> {394  return vaultMode === "real" && duressConfigured;395}396397export async function hasDecoyVault(): Promise<boolean> {398  return isDuressEnabled();399}400401export async function setupDuress(duressPassword: string): Promise<void> {402  if (!derivedKey || vaultMode !== "real") {403    throw new Error("vault must be unlocked in real mode");404  }405  await ensureMigrated();406407  // Encrypt the decoy payload (empty entries) under the duress key. Pad to408  // at least the current slot A ciphertext size so the two slots look the409  // same at rest at setup time.410  const slotA = await loadSlot(SLOT_A);411  if (!slotA) throw new Error("missing slot A");412413  const decoySalt = generateSalt();414  const decoyKey = await deriveKey(duressPassword, decoySalt);415  try {416    // Pad the decoy plaintext to match slot A's ciphertext length (minus the417    // 16-byte tag) so encrypted sizes align exactly.418    const targetBytes = slotA.ciphertext.length / 2;419    let plaintext = encodePayload([], false);420    const encoded = new TextEncoder().encode(plaintext);421    if (encoded.length < targetBytes) {422      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);423    }424    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);425    await writeSlot(SLOT_B, {426      version: 1,427      salt: hex(decoySalt),428      nonce,429      ciphertext,430      tag,431    });432  } finally {433    zeroKey(decoyKey);434  }435436  // Re-encrypt slot A with the duressConfigured flag set to true. No external437  // flag is written — the bit lives inside the encrypted envelope.438  duressConfigured = true;439  await persistSlotA();440}441442export async function disableDuress(): Promise<void> {443  if (!derivedKey || vaultMode !== "real") {444    throw new Error("vault must be unlocked in real mode");445  }446  await ensureMigrated();447448  // Overwrite slot B with fresh noise that matches slot A's size. The slot449  // remains occupied so its mere existence does not signal that duress was450  // ever configured.451  const slotA = await loadSlot(SLOT_A);452  if (slotA) {453    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));454  }455456  duressConfigured = false;457  await persistSlotA();458}459460export async function verifyMainPassword(password: string): Promise<boolean> {461  await ensureMigrated();462  const slotA = await loadSlot(SLOT_A);463  if (!slotA) return false;464  const key = await deriveKey(password, unhex(slotA.salt));465  try {466    return (await tryDecryptSlot(slotA, key)) !== null;467  } finally {468    zeroKey(key);469  }470}471472export async function unlock(password: string): Promise<boolean> {473  await ensureMigrated();474475  // Load both slots in parallel. Both always exist post-migration; the476  // parallel load and the parallel derivation below keep wall-clock time477  // constant regardless of which slot's passphrase (real or decoy) the478  // caller supplied, so a timing side channel cannot distinguish the479  // duress path from the real-unlock path (DUR-002).480  const [slotA, slotB] = await Promise.all([481    loadSlot(SLOT_A),482    loadSlot(SLOT_B),483  ]);484485  // Derive both keys in parallel, unconditionally.486  const [keyA, keyB] = await Promise.all([487    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),488    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),489  ]);490491  // Attempt both decrypts in parallel.492  const [payloadA, payloadB] = await Promise.all([493    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),494    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),495  ]);496497  if (payloadA && keyA) {498    derivedKey = keyA;499    vaultMode = "real";500    entries = payloadA.entries;501    duressConfigured = payloadA.duressConfigured;502    buildDomainIndexSync(entries);503    await loadSettings();504    touchAutoLock();505    if (keyB) zeroKey(keyB);506    // If the payload came from the pre-envelope (legacy) shape, rewrite507    // it immediately so the next save uses the new format.508    if (hasLegacyShape(payloadA.entries)) {509      await persistSlotA();510    }511    return true;512  }513514  if (payloadB && keyB) {515    derivedKey = keyB;516    vaultMode = "decoy";517    entries = payloadB.entries;518    duressConfigured = false;519    buildDomainIndexSync(entries);520    await loadSettings();521    touchAutoLock();522    if (keyA) zeroKey(keyA);523    return true;524  }525526  if (keyA) zeroKey(keyA);527  if (keyB) zeroKey(keyB);528  return false;529}530531export function lock(): void {532  if (derivedKey) {533    zeroKey(derivedKey);534    derivedKey = null;535  }536537  entries = [];538  domainIndex = new Map();539  vaultMode = "real";540  duressConfigured = false;541  clearAutoLock();542}543544export function isUnlocked(): boolean {545  return derivedKey !== null;546}547548export function getEntries(): VaultEntry[] {549  if (!derivedKey) throw new Error("vault locked");550  return entries.filter((entry) => !entry.deletedAt);551}552553export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {554  if (!derivedKey) throw new Error("vault locked");555556  const now = Date.now();557  const added: VaultEntry[] = [];558  for (const draft of drafts) {559    try {560      const sanitized = sanitizeEntryDraft(draft);561      const nextEntry: VaultEntry = {562        ...sanitized,563        id: crypto.randomUUID(),564        createdAt: now,565        modifiedAt: now,566      };567      entries.push(nextEntry);568      added.push(nextEntry);569    } catch {570      // Skip invalid drafts silently — caller can see diff between input and output length571    }572  }573574  if (added.length > 0) {575    await persistEntries();576  }577578  return added;579}580581/**582 * Restore a set of entries in full fidelity — preserves id, createdAt,583 * modifiedAt, and every other field unlike `batchAddEntries` which584 * assigns fresh identity. Used by the CJK phrase restore path where the585 * backup is the authoritative source of truth. `mode` controls how586 * entries already in the vault are treated.587 */588export async function restoreEntries(589  incoming: VaultEntry[],590  mode: "replace" | "merge"591): Promise<{ added: number; updated: number; skipped: number }> {592  if (!derivedKey) throw new Error("vault locked");593594  // A replace with zero incoming entries is almost certainly a caller bug595  // (e.g. an import that silently dropped everything). Refuse rather than596  // wipe the vault.597  if (mode === "replace" && incoming.length === 0) {598    throw new Error("refuse to replace with empty entry set");599  }600601  let added = 0;602  let updated = 0;603  let skipped = 0;604605  if (mode === "replace") {606    entries = [];607    for (const entry of incoming) {608      const normalized = sanitizeRestoredEntry(entry);609      if (!normalized) {610        skipped += 1;611        continue;612      }613      entries.push(normalized);614      added += 1;615    }616  } else {617    const byId = new Map<string, VaultEntry>();618    for (const e of entries) byId.set(e.id, e);619620    for (const entry of incoming) {621      const normalized = sanitizeRestoredEntry(entry);622      if (!normalized) {623        skipped += 1;624        continue;625      }626      const existing = byId.get(normalized.id);627      if (!existing) {628        entries.push(normalized);629        added += 1;630      } else if (normalized.modifiedAt > existing.modifiedAt) {631        const idx = entries.findIndex((e) => e.id === existing.id);632        if (idx !== -1) {633          entries[idx] = normalized;634          updated += 1;635        }636      } else {637        skipped += 1;638      }639    }640  }641642  await persistEntries();643  return { added, updated, skipped };644}645646/**647 * Validate + normalise a VaultEntry coming from a backup. Accepts648 * anything structurally sound; keeps all metadata verbatim. Returns649 * null for values the live vault shouldn't accept (e.g., missing title).650 */651function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {652  if (!entry || typeof entry !== "object") return null;653  const title = typeof entry.title === "string" ? entry.title.trim() : "";654  if (!title) return null;655  const type = entry.type ?? "login";656  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();657  const now = Date.now();658  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;659  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;660661  if (type === "login" && typeof entry.password !== "string") {662    return null;663  }664  if (665    type === "passkey" &&666    (typeof entry.rpId !== "string" ||667      typeof entry.credentialId !== "string" ||668      !entry.privateKeyJwk)669  ) {670    return null;671  }672673  return {674    ...entry,675    id,676    title,677    type,678    createdAt,679    modifiedAt,680  };681}682683// Soft-deletes every non-deleted entry in one call. The literal string684// argument is a tripwire against accidental calls — any caller that would685// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this686// to a boolean.687export async function clearVaultEntries(688  confirmation: "REPLACE_ALL"689): Promise<void> {690  if (confirmation !== "REPLACE_ALL") {691    throw new Error("clearVaultEntries requires explicit confirmation");692  }693  if (!derivedKey) throw new Error("vault locked");694  for (const entry of entries) {695    if (!entry.deletedAt) {696      entry.deletedAt = Date.now();697      entry.modifiedAt = Date.now();698    }699  }700  await persistEntries();701}702703export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {704  if (!derivedKey) throw new Error("vault locked");705706  const sanitized = sanitizeEntryDraft(entry);707  const now = Date.now();708  const nextEntry: VaultEntry = {709    ...sanitized,710    id: crypto.randomUUID(),711    createdAt: now,712    modifiedAt: now,713  };714715  entries.push(nextEntry);716  await persistEntries();717718  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)719  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {720    checkBreach(nextEntry.password).then(async (count) => {721      if (count > 0) {722        const idx = entries.findIndex((e) => e.id === nextEntry.id);723        if (idx !== -1) {724          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };725          await persistEntries();726        }727      }728    });729  }730731  return nextEntry;732}733734export async function updateEntry(735  id: string,736  patch: Partial<VaultEntry>737): Promise<void> {738  if (!derivedKey) throw new Error("vault locked");739740  const idx = entries.findIndex((entry) => entry.id === id);741  if (idx === -1 || entries[idx].deletedAt) {742    throw new Error("entry not found");743  }744745  const current = entries[idx];746  entries[idx] = {747    ...current,748    ...sanitizeEntryPatch(patch),749    modifiedAt: Date.now(),750  };751752  // sanitizeEntryPatch maps an empty password to `undefined`. For login753  // entries that would silently produce a passwordless record — roll back754  // the merge and refuse.755  if (entries[idx].type === "login" && !entries[idx].password) {756    entries[idx] = current;757    throw new Error("password is required for login entries");758  }759760  // Card entries must retain their card number761  if (entries[idx].type === "card" && !entries[idx].cardNumber) {762    entries[idx] = current;763    throw new Error("card number is required for card entries");764  }765766  await persistEntries();767768  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)769  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {770    const password = entries[idx].password!;771    checkBreach(password).then(async (count) => {772      const i = entries.findIndex((e) => e.id === id);773      if (i !== -1) {774        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };775        await persistEntries();776      }777    });778  }779}780781export async function deleteEntry(id: string): Promise<void> {782  if (!derivedKey) throw new Error("vault locked");783784  const entry = entries.find((item) => item.id === id);785  if (!entry || entry.deletedAt) {786    throw new Error("entry not found");787  }788789  entry.deletedAt = Date.now();790  entry.modifiedAt = Date.now();791  await persistEntries();792}793794export function getDeletedEntries(): VaultEntry[] {795  if (!derivedKey) return [];796  return entries.filter((e) => !!e.deletedAt);797}798799export async function restoreEntry(id: string): Promise<void> {800  if (!derivedKey) throw new Error("vault locked");801  const entry = entries.find((e) => e.id === id && e.deletedAt);802  if (!entry) throw new Error("deleted entry not found");803  delete entry.deletedAt;804  entry.modifiedAt = Date.now();805  await persistEntries();806  buildDomainIndexSync(entries);807}808809export async function purgeEntry(id: string): Promise<void> {810  if (!derivedKey) throw new Error("vault locked");811  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);812  if (idx === -1) throw new Error("deleted entry not found");813  entries.splice(idx, 1);814  await persistEntries();815}816817/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */818export async function purgeExpiredTrash(retentionMs: number): Promise<number> {819  if (!derivedKey || retentionMs === 0) return 0;820  const cutoff = Date.now() - retentionMs;821  const before = entries.length;822  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));823  const purged = before - entries.length;824  if (purged > 0) await persistEntries();825  return purged;826}827828export function queryDomain(domain: string): VaultEntry[] {829  return domainIndex.get(domain) ?? [];830}831832export function getEntryById(id: string): VaultEntry | undefined {833  return entries.find((entry) => entry.id === id && !entry.deletedAt);834}835836async function persistSlotA(): Promise<StoredVault> {837  if (!derivedKey) throw new Error("vault locked");838839  const slotA = await loadSlot(SLOT_A);840  if (!slotA) throw new Error("missing slot A");841842  const plaintext = encodePayload(entries, duressConfigured);843  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);844845  const nextSlotA: StoredVault = {846    ...slotA,847    nonce,848    ciphertext,849    tag,850  };851  await writeSlot(SLOT_A, nextSlotA);852853  // When duress is not configured, keep slot B size-matched with slot A by854  // regenerating its noise. When duress IS configured, leave slot B alone —855  // we don't have the decoy key to re-encrypt it. This is a known trade-off.856  if (!duressConfigured) {857    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));858  }859860  return nextSlotA;861}862863async function persistSlotB(): Promise<void> {864  if (!derivedKey) throw new Error("vault locked");865866  const slotB = await loadSlot(SLOT_B);867  if (!slotB) throw new Error("missing slot B");868869  // Decoy plaintext always reports duressConfigured=false — the decoy user870  // sees a clean slate.871  const plaintext = encodePayload(entries, false);872  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);873874  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });875}876877async function persistEntries(): Promise<void> {878  if (!derivedKey) throw new Error("vault locked");879880  if (vaultMode === "decoy") {881    await persistSlotB();882  } else {883    const freshSlotA = await persistSlotA();884    // Save a snapshot of the real vault after every successful write.885    // Failures are silently swallowed — snapshots must never interrupt saves.886    const activeCount = entries.filter((e) => !e.deletedAt).length;887    void takeSnapshot(freshSlotA, activeCount);888  }889  buildDomainIndexSync(entries);890}891892function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {893  if (!Array.isArray(values)) return undefined;894  const seen = new Set<string>();895  const cleaned: string[] = [];896  for (const value of values) {897    const trimmed = typeof value === "string" ? value.trim() : "";898    if (!trimmed) continue;899    const key = trimmed.toLowerCase();900    if (seen.has(key)) continue;901    seen.add(key);902    cleaned.push(trimmed);903  }904  return cleaned.length > 0 ? cleaned : undefined;905}906907function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {908  const title = entry.title.trim();909  const type = entry.type ?? "login";910911  if (!title) throw new Error("title is required");912  if (type === "login" && !entry.password?.trim()) {913    throw new Error("password is required");914  }915  if (type === "passkey") {916    if (917      !entry.rpId?.trim() ||918      !entry.rpName?.trim() ||919      !entry.credentialId?.trim() ||920      !entry.publicKeyCose?.trim() ||921      !entry.privateKeyJwk ||922      !entry.userHandle?.trim() ||923      !entry.userName?.trim() ||924      !entry.userDisplayName?.trim() ||925      (entry.algorithm !== -7 && entry.algorithm !== -8)926    ) {927      throw new Error("invalid passkey entry");928    }929  }930  if (type === "card") {931    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {932      throw new Error("cardholder name and card number are required");933    }934  }935936  return {937    type,938    title,939    username: type === "login" ? entry.username?.trim() ?? "" : undefined,940    password: type === "login" ? entry.password : undefined,941    url: type === "login" ? entry.url?.trim() ?? "" : undefined,942    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,943    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,944    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,945    breach: type === "login" ? entry.breach : undefined,946    canary: type === "login" ? entry.canary : undefined,947    tags: entry.tags,948    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,949    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,950    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,951    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,952    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,953    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,954    userName: type === "passkey" ? entry.userName?.trim() : undefined,955    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,956    algorithm: type === "passkey" ? entry.algorithm : undefined,957    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,958    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,959    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,960    backupState: type === "passkey" ? entry.backupState === true : undefined,961    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,962    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,963    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,964    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,965    expYear: type === "card" ? entry.expYear?.trim() : undefined,966    cvc: type === "card" ? entry.cvc?.trim() : undefined,967    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,968    network: type === "card" ? entry.network : undefined,969    last4: type === "card" ? entry.last4?.trim() : undefined,970  };971}972973function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {974  const nextPatch: Partial<VaultEntry> = {};975976  if ("type" in patch) {977    nextPatch.type = patch.type;978  }979980  if ("title" in patch) {981    const title = patch.title?.trim() ?? "";982    if (!title) throw new Error("title is required");983    nextPatch.title = title;984  }985986  if ("url" in patch) {987    nextPatch.url = patch.url?.trim() ?? "";988  }989990  if ("additionalUrls" in patch) {991    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];992    const seen = new Set<string>();993    const cleaned: string[] = [];994    for (const value of incoming) {995      const trimmed = typeof value === "string" ? value.trim() : "";996      if (!trimmed) continue;997      const key = trimmed.toLowerCase();998      if (seen.has(key)) continue;999      seen.add(key);1000      cleaned.push(trimmed);1001    }1002    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1003  }10041005  if ("username" in patch) {1006    nextPatch.username = patch.username?.trim() ?? "";1007  }10081009  if ("password" in patch) {1010    const password = patch.password ?? "";1011    // Allow clearing a password (e.g. on passkey-only entries)1012    nextPatch.password = password || undefined;1013  }10141015  if ("totp" in patch) {1016    nextPatch.totp = patch.totp?.trim() || undefined;1017  }10181019  if ("notes" in patch) {1020    nextPatch.notes = patch.notes?.trim() || undefined;1021  }10221023  if ("breach" in patch) {1024    nextPatch.breach = patch.breach;1025  }10261027  if ("canary" in patch) {1028    nextPatch.canary = patch.canary;1029  }10301031  if ("rpId" in patch) {1032    nextPatch.rpId = patch.rpId?.trim();1033  }10341035  if ("rpName" in patch) {1036    nextPatch.rpName = patch.rpName?.trim();1037  }10381039  if ("credentialId" in patch) {1040    nextPatch.credentialId = patch.credentialId?.trim();1041  }10421043  if ("publicKeyCose" in patch) {1044    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1045  }10461047  if ("privateKeyJwk" in patch) {1048    nextPatch.privateKeyJwk = patch.privateKeyJwk;1049  }10501051  if ("userHandle" in patch) {1052    nextPatch.userHandle = patch.userHandle?.trim();1053  }10541055  if ("userName" in patch) {1056    nextPatch.userName = patch.userName?.trim();1057  }10581059  if ("userDisplayName" in patch) {1060    nextPatch.userDisplayName = patch.userDisplayName?.trim();1061  }10621063  if ("algorithm" in patch) {1064    nextPatch.algorithm = patch.algorithm;1065  }10661067  if ("signCount" in patch) {1068    nextPatch.signCount = patch.signCount;1069  }10701071  if ("aaguid" in patch) {1072    nextPatch.aaguid = patch.aaguid?.trim();1073  }10741075  if ("backupEligible" in patch) {1076    nextPatch.backupEligible = patch.backupEligible;1077  }10781079  if ("backupState" in patch) {1080    nextPatch.backupState = patch.backupState;1081  }10821083  if ("lastUsedAt" in patch) {1084    nextPatch.lastUsedAt = patch.lastUsedAt;1085  }10861087  if ("tags" in patch) {1088    nextPatch.tags = patch.tags;1089  }10901091  if ("cardholderName" in patch) {1092    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1093  }1094  if ("cardNumber" in patch) {1095    const n = patch.cardNumber ?? "";1096    nextPatch.cardNumber = n || undefined;1097  }1098  if ("expMonth" in patch) {1099    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1100  }1101  if ("expYear" in patch) {1102    nextPatch.expYear = patch.expYear?.trim() || undefined;1103  }1104  if ("cvc" in patch) {1105    nextPatch.cvc = patch.cvc?.trim() || undefined;1106  }1107  if ("cardNickname" in patch) {1108    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1109  }1110  if ("network" in patch) {1111    nextPatch.network = patch.network;1112  }1113  if ("last4" in patch) {1114    nextPatch.last4 = patch.last4?.trim() || undefined;1115  }11161117  return nextPatch;1118}11191120function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1121  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1122  const modifiedAt =1123    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;11241125  return {1126    id: entry.id,1127    type: "type" in entry ? entry.type : "login",1128    title: entry.title,1129    username: entry.username,1130    password: entry.password,1131    url: entry.url,1132    totp: entry.totp,1133    notes: entry.notes,1134    breach: entry.breach,1135    canary: "canary" in entry ? entry.canary : undefined,1136    rpId: "rpId" in entry ? entry.rpId : undefined,1137    rpName: "rpName" in entry ? entry.rpName : undefined,1138    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1139    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1140    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1141    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1142    userName: "userName" in entry ? entry.userName : undefined,1143    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1144    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1145    signCount: "signCount" in entry ? entry.signCount : undefined,1146    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1147    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1148    backupState: "backupState" in entry ? entry.backupState : undefined,1149    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1150    createdAt,1151    modifiedAt,1152    deletedAt: entry.deletedAt,1153    tags: "tags" in entry ? entry.tags : undefined,1154    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1155    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1156    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1157    expYear: "expYear" in entry ? entry.expYear : undefined,1158    cvc: "cvc" in entry ? entry.cvc : undefined,1159    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1160    network: "network" in entry ? entry.network : undefined,1161    last4: "last4" in entry ? entry.last4 : undefined,1162  };1163}11641165function hasLegacyShape(parsed: VaultEntry[]): boolean {1166  return parsed.some(1167    (entry) => "created" in entry || "updated" in entry1168  );1169}11701171// ---------------------------------------------------------------------------1172// Deduplication1173// ---------------------------------------------------------------------------11741175function entryDupeKey(e: VaultEntry): string {1176  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1177  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1178  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1179}11801181/** Returns the number of duplicate entries (those that would be removed). */1182export function countDuplicates(): number {1183  const best = new Map<string, VaultEntry>();1184  let dupeCount = 0;1185  for (const e of entries.filter((e) => !e.deletedAt)) {1186    const key = entryDupeKey(e);1187    const existing = best.get(key);1188    if (!existing) {1189      best.set(key, e);1190    } else {1191      dupeCount++;1192      // Keep the more recently modified one in the map1193      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1194        best.set(key, e);1195      }1196    }1197  }1198  return dupeCount;1199}12001201/**1202 * Soft-deletes duplicate entries, keeping the most recently modified copy of1203 * each group. Returns the number of entries moved to trash.1204 */1205export async function deduplicateEntries(): Promise<number> {1206  if (!derivedKey) throw new Error("vault locked");1207  // Dedup reshapes slot B permanently under the decoy key, which is1208  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1209  if (vaultMode === "decoy") {1210    throw new Error("deduplicate disabled in decoy mode");1211  }12121213  const best = new Map<string, VaultEntry>();1214  for (const e of entries.filter((en) => !en.deletedAt)) {1215    const key = entryDupeKey(e);1216    const existing = best.get(key);1217    if (!existing) {1218      best.set(key, e);1219    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1220      best.set(key, e);1221    }1222  }12231224  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1225  const now = Date.now();1226  let removed = 0;1227  entries = entries.map((e) => {1228    if (!e.deletedAt && !keepIds.has(e.id)) {1229      removed++;1230      return { ...e, deletedAt: now };1231    }1232    return e;1233  });12341235  if (removed > 0) await persistEntries();1236  return removed;1237}12381239// ---------------------------------------------------------------------------1240// Vault snapshot system — last 6 encrypted states, used for self-recovery1241// ---------------------------------------------------------------------------12421243const SNAPSHOT_KEY = "peach_vault_snapshots";1244const MAX_SNAPSHOTS = 6;12451246interface VaultSnapshot {1247  timestamp: number;1248  entryCount: number;1249  slot: StoredVault;1250}12511252/** Public summary (no sensitive data) returned to the popup. */1253export interface SnapshotSummary {1254  index: number;1255  timestamp: number;1256  entryCount: number;1257}12581259async function takeSnapshot(1260  slot: StoredVault,1261  entryCount: number1262): Promise<void> {1263  try {1264    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1265    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1266    const next: VaultSnapshot[] = [1267      { timestamp: Date.now(), entryCount, slot },1268      ...existing,1269    ].slice(0, MAX_SNAPSHOTS);1270    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1271  } catch {1272    // Non-fatal — snapshot failure must never interrupt vault saves1273  }1274}12751276export async function listSnapshots(): Promise<SnapshotSummary[]> {1277  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1278  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1279  return snapshots.map((s, i) => ({1280    index: i,1281    timestamp: s.timestamp,1282    entryCount: s.entryCount,1283  }));1284}12851286/**1287 * Restore a snapshot by index. The password must decrypt that snapshot1288 * (i.e., the same master password used when the snapshot was taken).1289 * On success the vault is unlocked in-memory with the restored entries1290 * — no re-login required.1291 */1292export async function restoreFromSnapshot(1293  index: number,1294  password: string1295): Promise<boolean> {1296  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1297  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1298  const snapshot = snapshots[index];1299  if (!snapshot) return false;13001301  // Verify the password decrypts this snapshot1302  const key = await deriveKey(password, unhex(snapshot.slot.salt));1303  const payload = await tryDecryptSlot(snapshot.slot, key);1304  if (!payload) {1305    zeroKey(key);1306    return false;1307  }13081309  // Write the snapshot back to slot A. Regenerate noise for slot B.1310  await writeSlot(SLOT_A, snapshot.slot);1311  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));13121313  // Swap in-memory state to the restored vault — caller gets immediate access1314  if (derivedKey) zeroKey(derivedKey);1315  derivedKey = key;1316  entries = payload.entries;1317  duressConfigured = payload.duressConfigured;1318  vaultMode = "real";1319  buildDomainIndexSync(entries);1320  await loadSettings();1321  touchAutoLock();13221323  return true;1324}13251326/**1327 * Change the master password. Re-encrypts slot A with a fresh salt + new1328 * derived key. Slot B (duress or noise) is left untouched when duress is1329 * configured — it uses an independent key derived from the duress password —1330 * and regenerated as noise when duress is not configured.1331 */1332export async function changePassword(1333  currentPassword: string,1334  newPassword: string1335): Promise<boolean> {1336  if (!derivedKey || vaultMode !== "real") return false;13371338  // Verify current password before allowing any change1339  const valid = await verifyMainPassword(currentPassword);1340  if (!valid) return false;13411342  const slotA = await loadSlot(SLOT_A);1343  if (!slotA) return false;13441345  // Derive fresh key from new password + new random salt1346  const newSalt = generateSalt();1347  const newKey = await deriveKey(newPassword, newSalt);13481349  // Re-encrypt the in-memory entries with the new key1350  const plaintext = encodePayload(entries, duressConfigured);1351  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);13521353  const freshSlotA: StoredVault = {1354    ...slotA,1355    salt: hex(newSalt),1356    nonce,1357    ciphertext,1358    tag,1359  };1360  await writeSlot(SLOT_A, freshSlotA);13611362  // Swap the in-memory key synchronously before any further await. If we1363  // threw between the Slot A write and this swap, the next persistEntries()1364  // would overwrite Slot A with old-key ciphertext, locking the user out.1365  zeroKey(derivedKey);1366  derivedKey = newKey;13671368  // Snapshot now that in-memory key and disk are consistent. Pass the1369  // slot content we just wrote rather than re-reading from storage so the1370  // snapshot is immune to any microtask-coalesced read-after-write race.1371  const activeCount = entries.filter((e) => !e.deletedAt).length;1372  void takeSnapshot(freshSlotA, activeCount);13731374  // Regenerate noise for slot B when duress is not in use. Runs after the1375  // key swap so a failure here is a disposable noise regeneration, not a1376  // lockout.1377  if (!duressConfigured) {1378    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1379  }13801381  return true;1382}