vault.ts

42.8 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    const domain = extractDomain(entry.url ?? "");334    if (!domain) continue;335336    const bucket = domainIndex.get(domain) ?? [];337    bucket.push(entry);338    domainIndex.set(domain, bucket);339  }340}341342export async function createVault(password: string): Promise<void> {343  await ensureMigrated();344345  const salt = generateSalt();346  derivedKey = await deriveKey(password, salt);347  entries = [];348  vaultMode = "real";349  duressConfigured = false;350  buildDomainIndexSync(entries);351352  const plaintext = encodePayload(entries, duressConfigured);353  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);354355  const slotA: StoredVault = {356    version: 1,357    salt: hex(salt),358    nonce,359    ciphertext,360    tag,361  };362  await writeSlot(SLOT_A, slotA);363364  // Matching-size noise blob for slot B, so a fresh install shows two365  // similarly-shaped records regardless of whether duress is ever set up.366  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));367368  touchAutoLock();369}370371export async function hasVault(): Promise<boolean> {372  await ensureMigrated();373  return (await loadSlot(SLOT_A)) !== null;374}375376/**377 * Returns whether the real vault has recorded that a duress passphrase is378 * configured. Reliable only after a real-mode unlock — before unlock or in379 * decoy mode, returns false because we cannot read slot A's plaintext.380 */381export async function isDuressEnabled(): Promise<boolean> {382  return vaultMode === "real" && duressConfigured;383}384385export async function hasDecoyVault(): Promise<boolean> {386  return isDuressEnabled();387}388389export async function setupDuress(duressPassword: string): Promise<void> {390  if (!derivedKey || vaultMode !== "real") {391    throw new Error("vault must be unlocked in real mode");392  }393  await ensureMigrated();394395  // Encrypt the decoy payload (empty entries) under the duress key. Pad to396  // at least the current slot A ciphertext size so the two slots look the397  // same at rest at setup time.398  const slotA = await loadSlot(SLOT_A);399  if (!slotA) throw new Error("missing slot A");400401  const decoySalt = generateSalt();402  const decoyKey = await deriveKey(duressPassword, decoySalt);403  try {404    // Pad the decoy plaintext to match slot A's ciphertext length (minus the405    // 16-byte tag) so encrypted sizes align exactly.406    const targetBytes = slotA.ciphertext.length / 2;407    let plaintext = encodePayload([], false);408    const encoded = new TextEncoder().encode(plaintext);409    if (encoded.length < targetBytes) {410      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);411    }412    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);413    await writeSlot(SLOT_B, {414      version: 1,415      salt: hex(decoySalt),416      nonce,417      ciphertext,418      tag,419    });420  } finally {421    zeroKey(decoyKey);422  }423424  // Re-encrypt slot A with the duressConfigured flag set to true. No external425  // flag is written — the bit lives inside the encrypted envelope.426  duressConfigured = true;427  await persistSlotA();428}429430export async function disableDuress(): Promise<void> {431  if (!derivedKey || vaultMode !== "real") {432    throw new Error("vault must be unlocked in real mode");433  }434  await ensureMigrated();435436  // Overwrite slot B with fresh noise that matches slot A's size. The slot437  // remains occupied so its mere existence does not signal that duress was438  // ever configured.439  const slotA = await loadSlot(SLOT_A);440  if (slotA) {441    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));442  }443444  duressConfigured = false;445  await persistSlotA();446}447448export async function verifyMainPassword(password: string): Promise<boolean> {449  await ensureMigrated();450  const slotA = await loadSlot(SLOT_A);451  if (!slotA) return false;452  const key = await deriveKey(password, unhex(slotA.salt));453  try {454    return (await tryDecryptSlot(slotA, key)) !== null;455  } finally {456    zeroKey(key);457  }458}459460export async function unlock(password: string): Promise<boolean> {461  await ensureMigrated();462463  // Load both slots in parallel. Both always exist post-migration; the464  // parallel load and the parallel derivation below keep wall-clock time465  // constant regardless of which slot's passphrase (real or decoy) the466  // caller supplied, so a timing side channel cannot distinguish the467  // duress path from the real-unlock path (DUR-002).468  const [slotA, slotB] = await Promise.all([469    loadSlot(SLOT_A),470    loadSlot(SLOT_B),471  ]);472473  // Derive both keys in parallel, unconditionally.474  const [keyA, keyB] = await Promise.all([475    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),476    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),477  ]);478479  // Attempt both decrypts in parallel.480  const [payloadA, payloadB] = await Promise.all([481    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),482    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),483  ]);484485  if (payloadA && keyA) {486    derivedKey = keyA;487    vaultMode = "real";488    entries = payloadA.entries;489    duressConfigured = payloadA.duressConfigured;490    buildDomainIndexSync(entries);491    await loadSettings();492    touchAutoLock();493    if (keyB) zeroKey(keyB);494    // If the payload came from the pre-envelope (legacy) shape, rewrite495    // it immediately so the next save uses the new format.496    if (hasLegacyShape(payloadA.entries)) {497      await persistSlotA();498    }499    return true;500  }501502  if (payloadB && keyB) {503    derivedKey = keyB;504    vaultMode = "decoy";505    entries = payloadB.entries;506    duressConfigured = false;507    buildDomainIndexSync(entries);508    await loadSettings();509    touchAutoLock();510    if (keyA) zeroKey(keyA);511    return true;512  }513514  if (keyA) zeroKey(keyA);515  if (keyB) zeroKey(keyB);516  return false;517}518519export function lock(): void {520  if (derivedKey) {521    zeroKey(derivedKey);522    derivedKey = null;523  }524525  entries = [];526  domainIndex = new Map();527  vaultMode = "real";528  duressConfigured = false;529  clearAutoLock();530}531532export function isUnlocked(): boolean {533  return derivedKey !== null;534}535536export function getEntries(): VaultEntry[] {537  if (!derivedKey) throw new Error("vault locked");538  return entries.filter((entry) => !entry.deletedAt);539}540541export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {542  if (!derivedKey) throw new Error("vault locked");543544  const now = Date.now();545  const added: VaultEntry[] = [];546  for (const draft of drafts) {547    try {548      const sanitized = sanitizeEntryDraft(draft);549      const nextEntry: VaultEntry = {550        ...sanitized,551        id: crypto.randomUUID(),552        createdAt: now,553        modifiedAt: now,554      };555      entries.push(nextEntry);556      added.push(nextEntry);557    } catch {558      // Skip invalid drafts silently — caller can see diff between input and output length559    }560  }561562  if (added.length > 0) {563    await persistEntries();564  }565566  return added;567}568569/**570 * Restore a set of entries in full fidelity — preserves id, createdAt,571 * modifiedAt, and every other field unlike `batchAddEntries` which572 * assigns fresh identity. Used by the CJK phrase restore path where the573 * backup is the authoritative source of truth. `mode` controls how574 * entries already in the vault are treated.575 */576export async function restoreEntries(577  incoming: VaultEntry[],578  mode: "replace" | "merge"579): Promise<{ added: number; updated: number; skipped: number }> {580  if (!derivedKey) throw new Error("vault locked");581582  // A replace with zero incoming entries is almost certainly a caller bug583  // (e.g. an import that silently dropped everything). Refuse rather than584  // wipe the vault.585  if (mode === "replace" && incoming.length === 0) {586    throw new Error("refuse to replace with empty entry set");587  }588589  let added = 0;590  let updated = 0;591  let skipped = 0;592593  if (mode === "replace") {594    entries = [];595    for (const entry of incoming) {596      const normalized = sanitizeRestoredEntry(entry);597      if (!normalized) {598        skipped += 1;599        continue;600      }601      entries.push(normalized);602      added += 1;603    }604  } else {605    const byId = new Map<string, VaultEntry>();606    for (const e of entries) byId.set(e.id, e);607608    for (const entry of incoming) {609      const normalized = sanitizeRestoredEntry(entry);610      if (!normalized) {611        skipped += 1;612        continue;613      }614      const existing = byId.get(normalized.id);615      if (!existing) {616        entries.push(normalized);617        added += 1;618      } else if (normalized.modifiedAt > existing.modifiedAt) {619        const idx = entries.findIndex((e) => e.id === existing.id);620        if (idx !== -1) {621          entries[idx] = normalized;622          updated += 1;623        }624      } else {625        skipped += 1;626      }627    }628  }629630  await persistEntries();631  return { added, updated, skipped };632}633634/**635 * Validate + normalise a VaultEntry coming from a backup. Accepts636 * anything structurally sound; keeps all metadata verbatim. Returns637 * null for values the live vault shouldn't accept (e.g., missing title).638 */639function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {640  if (!entry || typeof entry !== "object") return null;641  const title = typeof entry.title === "string" ? entry.title.trim() : "";642  if (!title) return null;643  const type = entry.type ?? "login";644  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();645  const now = Date.now();646  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;647  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;648649  if (type === "login" && typeof entry.password !== "string") {650    return null;651  }652  if (653    type === "passkey" &&654    (typeof entry.rpId !== "string" ||655      typeof entry.credentialId !== "string" ||656      !entry.privateKeyJwk)657  ) {658    return null;659  }660661  return {662    ...entry,663    id,664    title,665    type,666    createdAt,667    modifiedAt,668  };669}670671// Soft-deletes every non-deleted entry in one call. The literal string672// argument is a tripwire against accidental calls — any caller that would673// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this674// to a boolean.675export async function clearVaultEntries(676  confirmation: "REPLACE_ALL"677): Promise<void> {678  if (confirmation !== "REPLACE_ALL") {679    throw new Error("clearVaultEntries requires explicit confirmation");680  }681  if (!derivedKey) throw new Error("vault locked");682  for (const entry of entries) {683    if (!entry.deletedAt) {684      entry.deletedAt = Date.now();685      entry.modifiedAt = Date.now();686    }687  }688  await persistEntries();689}690691export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {692  if (!derivedKey) throw new Error("vault locked");693694  const sanitized = sanitizeEntryDraft(entry);695  const now = Date.now();696  const nextEntry: VaultEntry = {697    ...sanitized,698    id: crypto.randomUUID(),699    createdAt: now,700    modifiedAt: now,701  };702703  entries.push(nextEntry);704  await persistEntries();705706  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)707  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {708    checkBreach(nextEntry.password).then(async (count) => {709      if (count > 0) {710        const idx = entries.findIndex((e) => e.id === nextEntry.id);711        if (idx !== -1) {712          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };713          await persistEntries();714        }715      }716    });717  }718719  return nextEntry;720}721722export async function updateEntry(723  id: string,724  patch: Partial<VaultEntry>725): Promise<void> {726  if (!derivedKey) throw new Error("vault locked");727728  const idx = entries.findIndex((entry) => entry.id === id);729  if (idx === -1 || entries[idx].deletedAt) {730    throw new Error("entry not found");731  }732733  const current = entries[idx];734  entries[idx] = {735    ...current,736    ...sanitizeEntryPatch(patch),737    modifiedAt: Date.now(),738  };739740  // sanitizeEntryPatch maps an empty password to `undefined`. For login741  // entries that would silently produce a passwordless record — roll back742  // the merge and refuse.743  if (entries[idx].type === "login" && !entries[idx].password) {744    entries[idx] = current;745    throw new Error("password is required for login entries");746  }747748  // Card entries must retain their card number749  if (entries[idx].type === "card" && !entries[idx].cardNumber) {750    entries[idx] = current;751    throw new Error("card number is required for card entries");752  }753754  await persistEntries();755756  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)757  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {758    const password = entries[idx].password!;759    checkBreach(password).then(async (count) => {760      const i = entries.findIndex((e) => e.id === id);761      if (i !== -1) {762        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };763        await persistEntries();764      }765    });766  }767}768769export async function deleteEntry(id: string): Promise<void> {770  if (!derivedKey) throw new Error("vault locked");771772  const entry = entries.find((item) => item.id === id);773  if (!entry || entry.deletedAt) {774    throw new Error("entry not found");775  }776777  entry.deletedAt = Date.now();778  entry.modifiedAt = Date.now();779  await persistEntries();780}781782export function getDeletedEntries(): VaultEntry[] {783  if (!derivedKey) return [];784  return entries.filter((e) => !!e.deletedAt);785}786787export async function restoreEntry(id: string): Promise<void> {788  if (!derivedKey) throw new Error("vault locked");789  const entry = entries.find((e) => e.id === id && e.deletedAt);790  if (!entry) throw new Error("deleted entry not found");791  delete entry.deletedAt;792  entry.modifiedAt = Date.now();793  await persistEntries();794  buildDomainIndexSync(entries);795}796797export async function purgeEntry(id: string): Promise<void> {798  if (!derivedKey) throw new Error("vault locked");799  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);800  if (idx === -1) throw new Error("deleted entry not found");801  entries.splice(idx, 1);802  await persistEntries();803}804805/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */806export async function purgeExpiredTrash(retentionMs: number): Promise<number> {807  if (!derivedKey || retentionMs === 0) return 0;808  const cutoff = Date.now() - retentionMs;809  const before = entries.length;810  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));811  const purged = before - entries.length;812  if (purged > 0) await persistEntries();813  return purged;814}815816export function queryDomain(domain: string): VaultEntry[] {817  return domainIndex.get(domain) ?? [];818}819820export function getEntryById(id: string): VaultEntry | undefined {821  return entries.find((entry) => entry.id === id && !entry.deletedAt);822}823824async function persistSlotA(): Promise<StoredVault> {825  if (!derivedKey) throw new Error("vault locked");826827  const slotA = await loadSlot(SLOT_A);828  if (!slotA) throw new Error("missing slot A");829830  const plaintext = encodePayload(entries, duressConfigured);831  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);832833  const nextSlotA: StoredVault = {834    ...slotA,835    nonce,836    ciphertext,837    tag,838  };839  await writeSlot(SLOT_A, nextSlotA);840841  // When duress is not configured, keep slot B size-matched with slot A by842  // regenerating its noise. When duress IS configured, leave slot B alone —843  // we don't have the decoy key to re-encrypt it. This is a known trade-off.844  if (!duressConfigured) {845    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));846  }847848  return nextSlotA;849}850851async function persistSlotB(): Promise<void> {852  if (!derivedKey) throw new Error("vault locked");853854  const slotB = await loadSlot(SLOT_B);855  if (!slotB) throw new Error("missing slot B");856857  // Decoy plaintext always reports duressConfigured=false — the decoy user858  // sees a clean slate.859  const plaintext = encodePayload(entries, false);860  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);861862  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });863}864865async function persistEntries(): Promise<void> {866  if (!derivedKey) throw new Error("vault locked");867868  if (vaultMode === "decoy") {869    await persistSlotB();870  } else {871    const freshSlotA = await persistSlotA();872    // Save a snapshot of the real vault after every successful write.873    // Failures are silently swallowed — snapshots must never interrupt saves.874    const activeCount = entries.filter((e) => !e.deletedAt).length;875    void takeSnapshot(freshSlotA, activeCount);876  }877  buildDomainIndexSync(entries);878}879880function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {881  const title = entry.title.trim();882  const type = entry.type ?? "login";883884  if (!title) throw new Error("title is required");885  if (type === "login" && !entry.password?.trim()) {886    throw new Error("password is required");887  }888  if (type === "passkey") {889    if (890      !entry.rpId?.trim() ||891      !entry.rpName?.trim() ||892      !entry.credentialId?.trim() ||893      !entry.publicKeyCose?.trim() ||894      !entry.privateKeyJwk ||895      !entry.userHandle?.trim() ||896      !entry.userName?.trim() ||897      !entry.userDisplayName?.trim() ||898      (entry.algorithm !== -7 && entry.algorithm !== -8)899    ) {900      throw new Error("invalid passkey entry");901    }902  }903  if (type === "card") {904    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {905      throw new Error("cardholder name and card number are required");906    }907  }908909  return {910    type,911    title,912    username: type === "login" ? entry.username?.trim() ?? "" : undefined,913    password: type === "login" ? entry.password : undefined,914    url: type === "login" ? entry.url?.trim() ?? "" : undefined,915    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,916    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,917    breach: type === "login" ? entry.breach : undefined,918    canary: type === "login" ? entry.canary : undefined,919    tags: entry.tags,920    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,921    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,922    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,923    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,924    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,925    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,926    userName: type === "passkey" ? entry.userName?.trim() : undefined,927    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,928    algorithm: type === "passkey" ? entry.algorithm : undefined,929    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,930    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,931    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,932    backupState: type === "passkey" ? entry.backupState === true : undefined,933    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,934    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,935    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,936    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,937    expYear: type === "card" ? entry.expYear?.trim() : undefined,938    cvc: type === "card" ? entry.cvc?.trim() : undefined,939    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,940    network: type === "card" ? entry.network : undefined,941    last4: type === "card" ? entry.last4?.trim() : undefined,942  };943}944945function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {946  const nextPatch: Partial<VaultEntry> = {};947948  if ("type" in patch) {949    nextPatch.type = patch.type;950  }951952  if ("title" in patch) {953    const title = patch.title?.trim() ?? "";954    if (!title) throw new Error("title is required");955    nextPatch.title = title;956  }957958  if ("url" in patch) {959    nextPatch.url = patch.url?.trim() ?? "";960  }961962  if ("username" in patch) {963    nextPatch.username = patch.username?.trim() ?? "";964  }965966  if ("password" in patch) {967    const password = patch.password ?? "";968    // Allow clearing a password (e.g. on passkey-only entries)969    nextPatch.password = password || undefined;970  }971972  if ("totp" in patch) {973    nextPatch.totp = patch.totp?.trim() || undefined;974  }975976  if ("notes" in patch) {977    nextPatch.notes = patch.notes?.trim() || undefined;978  }979980  if ("breach" in patch) {981    nextPatch.breach = patch.breach;982  }983984  if ("canary" in patch) {985    nextPatch.canary = patch.canary;986  }987988  if ("rpId" in patch) {989    nextPatch.rpId = patch.rpId?.trim();990  }991992  if ("rpName" in patch) {993    nextPatch.rpName = patch.rpName?.trim();994  }995996  if ("credentialId" in patch) {997    nextPatch.credentialId = patch.credentialId?.trim();998  }9991000  if ("publicKeyCose" in patch) {1001    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1002  }10031004  if ("privateKeyJwk" in patch) {1005    nextPatch.privateKeyJwk = patch.privateKeyJwk;1006  }10071008  if ("userHandle" in patch) {1009    nextPatch.userHandle = patch.userHandle?.trim();1010  }10111012  if ("userName" in patch) {1013    nextPatch.userName = patch.userName?.trim();1014  }10151016  if ("userDisplayName" in patch) {1017    nextPatch.userDisplayName = patch.userDisplayName?.trim();1018  }10191020  if ("algorithm" in patch) {1021    nextPatch.algorithm = patch.algorithm;1022  }10231024  if ("signCount" in patch) {1025    nextPatch.signCount = patch.signCount;1026  }10271028  if ("aaguid" in patch) {1029    nextPatch.aaguid = patch.aaguid?.trim();1030  }10311032  if ("backupEligible" in patch) {1033    nextPatch.backupEligible = patch.backupEligible;1034  }10351036  if ("backupState" in patch) {1037    nextPatch.backupState = patch.backupState;1038  }10391040  if ("lastUsedAt" in patch) {1041    nextPatch.lastUsedAt = patch.lastUsedAt;1042  }10431044  if ("tags" in patch) {1045    nextPatch.tags = patch.tags;1046  }10471048  if ("cardholderName" in patch) {1049    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1050  }1051  if ("cardNumber" in patch) {1052    const n = patch.cardNumber ?? "";1053    nextPatch.cardNumber = n || undefined;1054  }1055  if ("expMonth" in patch) {1056    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1057  }1058  if ("expYear" in patch) {1059    nextPatch.expYear = patch.expYear?.trim() || undefined;1060  }1061  if ("cvc" in patch) {1062    nextPatch.cvc = patch.cvc?.trim() || undefined;1063  }1064  if ("cardNickname" in patch) {1065    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1066  }1067  if ("network" in patch) {1068    nextPatch.network = patch.network;1069  }1070  if ("last4" in patch) {1071    nextPatch.last4 = patch.last4?.trim() || undefined;1072  }10731074  return nextPatch;1075}10761077function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1078  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1079  const modifiedAt =1080    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;10811082  return {1083    id: entry.id,1084    type: "type" in entry ? entry.type : "login",1085    title: entry.title,1086    username: entry.username,1087    password: entry.password,1088    url: entry.url,1089    totp: entry.totp,1090    notes: entry.notes,1091    breach: entry.breach,1092    canary: "canary" in entry ? entry.canary : undefined,1093    rpId: "rpId" in entry ? entry.rpId : undefined,1094    rpName: "rpName" in entry ? entry.rpName : undefined,1095    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1096    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1097    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1098    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1099    userName: "userName" in entry ? entry.userName : undefined,1100    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1101    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1102    signCount: "signCount" in entry ? entry.signCount : undefined,1103    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1104    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1105    backupState: "backupState" in entry ? entry.backupState : undefined,1106    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1107    createdAt,1108    modifiedAt,1109    deletedAt: entry.deletedAt,1110    tags: "tags" in entry ? entry.tags : undefined,1111    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1112    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1113    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1114    expYear: "expYear" in entry ? entry.expYear : undefined,1115    cvc: "cvc" in entry ? entry.cvc : undefined,1116    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1117    network: "network" in entry ? entry.network : undefined,1118    last4: "last4" in entry ? entry.last4 : undefined,1119  };1120}11211122function hasLegacyShape(parsed: VaultEntry[]): boolean {1123  return parsed.some(1124    (entry) => "created" in entry || "updated" in entry1125  );1126}11271128// ---------------------------------------------------------------------------1129// Deduplication1130// ---------------------------------------------------------------------------11311132function entryDupeKey(e: VaultEntry): string {1133  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1134  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1135  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1136}11371138/** Returns the number of duplicate entries (those that would be removed). */1139export function countDuplicates(): number {1140  const best = new Map<string, VaultEntry>();1141  let dupeCount = 0;1142  for (const e of entries.filter((e) => !e.deletedAt)) {1143    const key = entryDupeKey(e);1144    const existing = best.get(key);1145    if (!existing) {1146      best.set(key, e);1147    } else {1148      dupeCount++;1149      // Keep the more recently modified one in the map1150      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1151        best.set(key, e);1152      }1153    }1154  }1155  return dupeCount;1156}11571158/**1159 * Soft-deletes duplicate entries, keeping the most recently modified copy of1160 * each group. Returns the number of entries moved to trash.1161 */1162export async function deduplicateEntries(): Promise<number> {1163  if (!derivedKey) throw new Error("vault locked");1164  // Dedup reshapes slot B permanently under the decoy key, which is1165  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1166  if (vaultMode === "decoy") {1167    throw new Error("deduplicate disabled in decoy mode");1168  }11691170  const best = new Map<string, VaultEntry>();1171  for (const e of entries.filter((en) => !en.deletedAt)) {1172    const key = entryDupeKey(e);1173    const existing = best.get(key);1174    if (!existing) {1175      best.set(key, e);1176    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1177      best.set(key, e);1178    }1179  }11801181  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1182  const now = Date.now();1183  let removed = 0;1184  entries = entries.map((e) => {1185    if (!e.deletedAt && !keepIds.has(e.id)) {1186      removed++;1187      return { ...e, deletedAt: now };1188    }1189    return e;1190  });11911192  if (removed > 0) await persistEntries();1193  return removed;1194}11951196// ---------------------------------------------------------------------------1197// Vault snapshot system — last 6 encrypted states, used for self-recovery1198// ---------------------------------------------------------------------------11991200const SNAPSHOT_KEY = "peach_vault_snapshots";1201const MAX_SNAPSHOTS = 6;12021203interface VaultSnapshot {1204  timestamp: number;1205  entryCount: number;1206  slot: StoredVault;1207}12081209/** Public summary (no sensitive data) returned to the popup. */1210export interface SnapshotSummary {1211  index: number;1212  timestamp: number;1213  entryCount: number;1214}12151216async function takeSnapshot(1217  slot: StoredVault,1218  entryCount: number1219): Promise<void> {1220  try {1221    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1222    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1223    const next: VaultSnapshot[] = [1224      { timestamp: Date.now(), entryCount, slot },1225      ...existing,1226    ].slice(0, MAX_SNAPSHOTS);1227    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1228  } catch {1229    // Non-fatal — snapshot failure must never interrupt vault saves1230  }1231}12321233export async function listSnapshots(): Promise<SnapshotSummary[]> {1234  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1235  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1236  return snapshots.map((s, i) => ({1237    index: i,1238    timestamp: s.timestamp,1239    entryCount: s.entryCount,1240  }));1241}12421243/**1244 * Restore a snapshot by index. The password must decrypt that snapshot1245 * (i.e., the same master password used when the snapshot was taken).1246 * On success the vault is unlocked in-memory with the restored entries1247 * — no re-login required.1248 */1249export async function restoreFromSnapshot(1250  index: number,1251  password: string1252): Promise<boolean> {1253  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1254  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1255  const snapshot = snapshots[index];1256  if (!snapshot) return false;12571258  // Verify the password decrypts this snapshot1259  const key = await deriveKey(password, unhex(snapshot.slot.salt));1260  const payload = await tryDecryptSlot(snapshot.slot, key);1261  if (!payload) {1262    zeroKey(key);1263    return false;1264  }12651266  // Write the snapshot back to slot A. Regenerate noise for slot B.1267  await writeSlot(SLOT_A, snapshot.slot);1268  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));12691270  // Swap in-memory state to the restored vault — caller gets immediate access1271  if (derivedKey) zeroKey(derivedKey);1272  derivedKey = key;1273  entries = payload.entries;1274  duressConfigured = payload.duressConfigured;1275  vaultMode = "real";1276  buildDomainIndexSync(entries);1277  await loadSettings();1278  touchAutoLock();12791280  return true;1281}12821283/**1284 * Change the master password. Re-encrypts slot A with a fresh salt + new1285 * derived key. Slot B (duress or noise) is left untouched when duress is1286 * configured — it uses an independent key derived from the duress password —1287 * and regenerated as noise when duress is not configured.1288 */1289export async function changePassword(1290  currentPassword: string,1291  newPassword: string1292): Promise<boolean> {1293  if (!derivedKey || vaultMode !== "real") return false;12941295  // Verify current password before allowing any change1296  const valid = await verifyMainPassword(currentPassword);1297  if (!valid) return false;12981299  const slotA = await loadSlot(SLOT_A);1300  if (!slotA) return false;13011302  // Derive fresh key from new password + new random salt1303  const newSalt = generateSalt();1304  const newKey = await deriveKey(newPassword, newSalt);13051306  // Re-encrypt the in-memory entries with the new key1307  const plaintext = encodePayload(entries, duressConfigured);1308  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);13091310  const freshSlotA: StoredVault = {1311    ...slotA,1312    salt: hex(newSalt),1313    nonce,1314    ciphertext,1315    tag,1316  };1317  await writeSlot(SLOT_A, freshSlotA);13181319  // Swap the in-memory key synchronously before any further await. If we1320  // threw between the Slot A write and this swap, the next persistEntries()1321  // would overwrite Slot A with old-key ciphertext, locking the user out.1322  zeroKey(derivedKey);1323  derivedKey = newKey;13241325  // Snapshot now that in-memory key and disk are consistent. Pass the1326  // slot content we just wrote rather than re-reading from storage so the1327  // snapshot is immune to any microtask-coalesced read-after-write race.1328  const activeCount = entries.filter((e) => !e.deletedAt).length;1329  void takeSnapshot(freshSlotA, activeCount);13301331  // Regenerate noise for slot B when duress is not in use. Runs after the1332  // key swap so a failure here is a disposable noise regeneration, not a1333  // lockout.1334  if (!duressConfigured) {1335    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1336  }13371338  return true;1339}