vault.ts

45.5 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import type {15  AccentColorName,16  LegacyVaultEntry,17  ManualSummonRecord,18  StoredVault,19  VaultEntry,20} from "../shared/types";21import { decrypt, encrypt, zeroKey } from "./aes";22import { clearAutoLock, touchAutoLock } from "./alarms";2324type EntryDraft = Omit<25  VaultEntry,26  "id" | "createdAt" | "modifiedAt" | "deletedAt"27>;2829// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B30// either holds the decoy vault (when duress is configured) or random noise31// that looks identical at rest. No external indicator is stored — duress32// configuration is recorded only inside the encrypted payload of slot A.33const SLOT_A = "peach_vault_a";34const SLOT_B = "peach_vault_b";3536// Legacy keys, migrated away on first access.37const LEGACY_MAIN = "peach_vault";38const LEGACY_DECOY = "peach_decoy_vault";39const LEGACY_DURESS_FLAG = "peach_duress";4041// Plaintext is padded to the next multiple of this size before encryption.42// This coarsens ciphertext-length leakage into 1 KiB buckets.43const PAD_BLOCK = 1024;4445let derivedKey: Uint8Array | null = null;46let entries: VaultEntry[] = [];47let domainIndex: Map<string, VaultEntry[]> = new Map();48let vaultMode: "real" | "decoy" = "real";49// Cached post-unlock view of whether the real vault records that duress is50// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock51// this stays false because we never see slot A's plaintext.52let duressConfigured = false;5354let migrationRan = false;5556// ---------------------------------------------------------------------------57// Settings (persisted inside the encrypted vault store as a sidecar key)58// ---------------------------------------------------------------------------5960export interface VaultSettings {61  accentColor?: AccentColorName;62  theme?: "dark" | "light";63  autoLockMinutes?: number;64  clipboardClearSeconds?: number;65  lockOnClose?: boolean;66  breachMonitoring?: boolean;67  passkeyProviderEnabled?: boolean;68  trashRetentionDays?: number;69  manualSummonDomains?: Record<string, ManualSummonRecord>;70  declinedCardFingerprints?: string[];71  /**72   * User overrides for keyboard shortcuts. Keys are ShortcutAction names73   * (see src/shared/shortcuts.ts). Missing actions fall through to74   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the75   * canonical lookup. Storing only the deltas keeps the vault payload76   * small and lets default updates land for users who never rebind.77   */78  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;79}8081let settings: VaultSettings = {};8283export function getSettings(): VaultSettings {84  return settings;85}8687export async function ensureSettingsLoaded(): Promise<VaultSettings> {88  await loadSettings();89  return settings;90}9192export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {93  await loadSettings();94  settings = { ...settings, ...patch };95  await persistSettings();96}9798async function persistSettings(): Promise<void> {99  try {100    await chrome.storage.local.set({ peach_settings: settings });101  } catch (err) {102    throw mapStorageError(err);103  }104}105106function mapStorageError(err: unknown): Error {107  const message = err instanceof Error ? err.message : String(err);108  if (/QUOTA_BYTES|quota/i.test(message)) {109    return new Error("STORAGE_QUOTA_EXCEEDED");110  }111  return err instanceof Error ? err : new Error(message);112}113114async function loadSettings(): Promise<void> {115  const result = await chrome.storage.local.get("peach_settings");116  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};117}118119// ---------------------------------------------------------------------------120// HIBP breach check (k-anonymity model)121// ---------------------------------------------------------------------------122123export async function checkBreach(password: string): Promise<number> {124  const encoder = new TextEncoder();125  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));126  const hashHex = Array.from(new Uint8Array(hashBuffer))127    .map((b) => b.toString(16).padStart(2, "0"))128    .join("")129    .toUpperCase();130131  const prefix = hashHex.slice(0, 5);132  const suffix = hashHex.slice(5);133134  try {135    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);136    if (!res.ok) return 0;137    const text = await res.text();138    const match = text.split("\r\n").find((l) => l.startsWith(suffix));139    return match ? parseInt(match.split(":")[1], 10) : 0;140  } catch {141    return 0; // Network unavailable — don't block142  }143}144145export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {146  const fingerprint = await cardFingerprint(last4, expMonth, expYear);147  await loadSettings();148  settings = {149    ...settings,150    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],151  };152  await persistSettings();153}154155export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {156  await loadSettings();157  const fingerprint = await cardFingerprint(last4, expMonth, expYear);158  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);159}160161async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {162  const encoder = new TextEncoder();163  const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));164  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");165}166167function hex(buf: Uint8Array): string {168  return Array.from(buf)169    .map((b) => b.toString(16).padStart(2, "0"))170    .join("");171}172173function unhex(s: string): Uint8Array {174  const out = new Uint8Array(s.length / 2);175  for (let i = 0; i < s.length; i += 2) {176    out[i / 2] = parseInt(s.slice(i, i + 2), 16);177  }178  return out;179}180181// ---------------------------------------------------------------------------182// Storage schema migration183// ---------------------------------------------------------------------------184185async function loadSlot(key: string): Promise<StoredVault | null> {186  const result = await chrome.storage.local.get(key);187  return (result as Record<string, StoredVault | undefined>)[key] ?? null;188}189190async function writeSlot(key: string, vault: StoredVault): Promise<void> {191  try {192    await chrome.storage.local.set({ [key]: vault });193  } catch (err) {194    throw mapStorageError(err);195  }196}197198/**199 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault200 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +201 * peach_vault_b). Runs at most once per session; safe to call repeatedly.202 */203async function ensureMigrated(): Promise<void> {204  if (migrationRan) return;205  migrationRan = true;206207  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];208  const raw = await chrome.storage.local.get(keys);209  const slotAExists = raw[SLOT_A] !== undefined;210  const slotBExists = raw[SLOT_B] !== undefined;211  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;212  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;213214  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.215  if (!slotAExists && !legacyMain) return;216217  // Already migrated.218  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;219220  if (legacyMain && !slotAExists) {221    await writeSlot(SLOT_A, legacyMain);222  }223224  // If slot B isn't set, populate it: use legacy decoy when present, else noise.225  if (!slotBExists) {226    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;227    if (legacyDecoy) {228      await writeSlot(SLOT_B, legacyDecoy);229    } else if (target) {230      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));231    }232  }233234  const toRemove: string[] = [];235  if (legacyMain) toRemove.push(LEGACY_MAIN);236  if (legacyDecoy) toRemove.push(LEGACY_DECOY);237  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);238  if (toRemove.length > 0) {239    await chrome.storage.local.remove(toRemove);240  }241}242243// ---------------------------------------------------------------------------244// Padding + noise helpers245// ---------------------------------------------------------------------------246247/**248 * Pads the plaintext string with trailing spaces so that the encoded byte249 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing250 * whitespace after a complete document, so the padding is transparent on251 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.252 */253function padPlaintext(json: string): string {254  const encoded = new TextEncoder().encode(json);255  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);256  const padLen = target - encoded.length;257  return json + " ".repeat(padLen);258}259260/**261 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The262 * ciphertext length matches the given byte count. Because GCM ciphertext is263 * pseudo-random, this blob is indistinguishable at rest from a real264 * encrypted vault without the key.265 */266function generateNoiseBlob(ciphertextBytes: number): StoredVault {267  const salt = crypto.getRandomValues(new Uint8Array(16));268  const nonce = crypto.getRandomValues(new Uint8Array(12));269  const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));270  const tag = crypto.getRandomValues(new Uint8Array(16));271  return {272    version: 1,273    salt: hex(salt),274    nonce: hex(nonce),275    ciphertext: hex(cipher),276    tag: hex(tag),277  };278}279280// ---------------------------------------------------------------------------281// Encrypted plaintext envelope282// ---------------------------------------------------------------------------283284type VaultPayload = {285  entries: VaultEntry[];286  duressConfigured?: boolean;287};288289type DecryptedPayload = {290  entries: VaultEntry[];291  duressConfigured: boolean;292};293294function encodePayload(entries: VaultEntry[], duress: boolean): string {295  const envelope: VaultPayload = { entries, duressConfigured: duress };296  return padPlaintext(JSON.stringify(envelope));297}298299function decodePayload(plain: string): DecryptedPayload {300  const parsed = JSON.parse(plain) as301    | Array<VaultEntry | LegacyVaultEntry>302    | VaultPayload;303304  if (Array.isArray(parsed)) {305    // Pre-A-2 plaintext: bare array of entries, no duress flag.306    return {307      entries: parsed.map(normalizeEntry),308      duressConfigured: false,309    };310  }311312  return {313    entries: (parsed.entries ?? []).map(normalizeEntry),314    duressConfigured: parsed.duressConfigured === true,315  };316}317318async function tryDecryptSlot(319  vault: StoredVault,320  key: Uint8Array321): Promise<DecryptedPayload | null> {322  try {323    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);324    return decodePayload(plain);325  } catch {326    return null;327  }328}329330// ---------------------------------------------------------------------------331// Core operations332// ---------------------------------------------------------------------------333334function buildDomainIndexSync(all: VaultEntry[]): void {335  domainIndex = new Map();336337  for (const entry of all) {338    if (entry.deletedAt) continue;339    if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;340341    // An entry can match multiple sites via `additionalUrls`. Index each342    // hostname so `queryDomain` returns the entry regardless of which alias343    // the user is currently visiting. Dedupe per-entry so a single entry344    // never appears twice in one bucket.345    const domains = new Set<string>();346    const primary = extractDomain(entry.url ?? "");347    if (primary) domains.add(primary);348    for (const extra of entry.additionalUrls ?? []) {349      const d = extractDomain(extra);350      if (d) domains.add(d);351    }352    if (domains.size === 0) continue;353354    for (const domain of domains) {355      const bucket = domainIndex.get(domain) ?? [];356      bucket.push(entry);357      domainIndex.set(domain, bucket);358    }359  }360}361362export async function createVault(password: string): Promise<void> {363  await ensureMigrated();364365  const salt = generateSalt();366  derivedKey = await deriveKey(password, salt);367  entries = [];368  vaultMode = "real";369  duressConfigured = false;370  buildDomainIndexSync(entries);371372  const plaintext = encodePayload(entries, duressConfigured);373  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);374375  const slotA: StoredVault = {376    version: 1,377    salt: hex(salt),378    nonce,379    ciphertext,380    tag,381  };382  await writeSlot(SLOT_A, slotA);383384  // Matching-size noise blob for slot B, so a fresh install shows two385  // similarly-shaped records regardless of whether duress is ever set up.386  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));387388  touchAutoLock();389}390391export async function hasVault(): Promise<boolean> {392  await ensureMigrated();393  return (await loadSlot(SLOT_A)) !== null;394}395396/**397 * Returns whether the real vault has recorded that a duress passphrase is398 * configured. Reliable only after a real-mode unlock — before unlock or in399 * decoy mode, returns false because we cannot read slot A's plaintext.400 */401export async function isDuressEnabled(): Promise<boolean> {402  return vaultMode === "real" && duressConfigured;403}404405export async function hasDecoyVault(): Promise<boolean> {406  return isDuressEnabled();407}408409export async function setupDuress(duressPassword: string): Promise<void> {410  if (!derivedKey || vaultMode !== "real") {411    throw new Error("vault must be unlocked in real mode");412  }413  await ensureMigrated();414415  // Encrypt the decoy payload (empty entries) under the duress key. Pad to416  // at least the current slot A ciphertext size so the two slots look the417  // same at rest at setup time.418  const slotA = await loadSlot(SLOT_A);419  if (!slotA) throw new Error("missing slot A");420421  const decoySalt = generateSalt();422  const decoyKey = await deriveKey(duressPassword, decoySalt);423  try {424    // Pad the decoy plaintext to match slot A's ciphertext length (minus the425    // 16-byte tag) so encrypted sizes align exactly.426    const targetBytes = slotA.ciphertext.length / 2;427    let plaintext = encodePayload([], false);428    const encoded = new TextEncoder().encode(plaintext);429    if (encoded.length < targetBytes) {430      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);431    }432    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);433    await writeSlot(SLOT_B, {434      version: 1,435      salt: hex(decoySalt),436      nonce,437      ciphertext,438      tag,439    });440  } finally {441    zeroKey(decoyKey);442  }443444  // Re-encrypt slot A with the duressConfigured flag set to true. No external445  // flag is written — the bit lives inside the encrypted envelope.446  duressConfigured = true;447  await persistSlotA();448}449450export async function disableDuress(): Promise<void> {451  if (!derivedKey || vaultMode !== "real") {452    throw new Error("vault must be unlocked in real mode");453  }454  await ensureMigrated();455456  // Overwrite slot B with fresh noise that matches slot A's size. The slot457  // remains occupied so its mere existence does not signal that duress was458  // ever configured.459  const slotA = await loadSlot(SLOT_A);460  if (slotA) {461    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));462  }463464  duressConfigured = false;465  await persistSlotA();466}467468export async function verifyMainPassword(password: string): Promise<boolean> {469  await ensureMigrated();470  const slotA = await loadSlot(SLOT_A);471  if (!slotA) return false;472  const key = await deriveKey(password, unhex(slotA.salt));473  try {474    return (await tryDecryptSlot(slotA, key)) !== null;475  } finally {476    zeroKey(key);477  }478}479480export async function unlock(password: string): Promise<boolean> {481  await ensureMigrated();482483  // Load both slots in parallel. Both always exist post-migration; the484  // parallel load and the parallel derivation below keep wall-clock time485  // constant regardless of which slot's passphrase (real or decoy) the486  // caller supplied, so a timing side channel cannot distinguish the487  // duress path from the real-unlock path (DUR-002).488  const [slotA, slotB] = await Promise.all([489    loadSlot(SLOT_A),490    loadSlot(SLOT_B),491  ]);492493  // Derive both keys in parallel, unconditionally.494  const [keyA, keyB] = await Promise.all([495    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),496    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),497  ]);498499  // Attempt both decrypts in parallel.500  const [payloadA, payloadB] = await Promise.all([501    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),502    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),503  ]);504505  if (payloadA && keyA) {506    derivedKey = keyA;507    vaultMode = "real";508    entries = payloadA.entries;509    duressConfigured = payloadA.duressConfigured;510    buildDomainIndexSync(entries);511    await loadSettings();512    touchAutoLock();513    if (keyB) zeroKey(keyB);514    // If the payload came from the pre-envelope (legacy) shape, rewrite515    // it immediately so the next save uses the new format.516    if (hasLegacyShape(payloadA.entries)) {517      await persistSlotA();518    }519    return true;520  }521522  if (payloadB && keyB) {523    derivedKey = keyB;524    vaultMode = "decoy";525    entries = payloadB.entries;526    duressConfigured = false;527    buildDomainIndexSync(entries);528    await loadSettings();529    touchAutoLock();530    if (keyA) zeroKey(keyA);531    return true;532  }533534  if (keyA) zeroKey(keyA);535  if (keyB) zeroKey(keyB);536  return false;537}538539export function lock(): void {540  if (derivedKey) {541    zeroKey(derivedKey);542    derivedKey = null;543  }544545  entries = [];546  domainIndex = new Map();547  vaultMode = "real";548  duressConfigured = false;549  clearAutoLock();550}551552export function isUnlocked(): boolean {553  return derivedKey !== null;554}555556export function getEntries(): VaultEntry[] {557  if (!derivedKey) throw new Error("vault locked");558  return entries.filter((entry) => !entry.deletedAt);559}560561export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {562  if (!derivedKey) throw new Error("vault locked");563564  const now = Date.now();565  const added: VaultEntry[] = [];566  for (const draft of drafts) {567    try {568      const sanitized = sanitizeEntryDraft(draft);569      const nextEntry: VaultEntry = {570        ...sanitized,571        id: crypto.randomUUID(),572        createdAt: now,573        modifiedAt: now,574      };575      entries.push(nextEntry);576      added.push(nextEntry);577    } catch {578      // Skip invalid drafts silently — caller can see diff between input and output length579    }580  }581582  if (added.length > 0) {583    await persistEntries();584  }585586  return added;587}588589/**590 * Restore a set of entries in full fidelity — preserves id, createdAt,591 * modifiedAt, and every other field unlike `batchAddEntries` which592 * assigns fresh identity. Used by the CJK phrase restore path where the593 * backup is the authoritative source of truth. `mode` controls how594 * entries already in the vault are treated.595 */596export async function restoreEntries(597  incoming: VaultEntry[],598  mode: "replace" | "merge"599): Promise<{ added: number; updated: number; skipped: number }> {600  if (!derivedKey) throw new Error("vault locked");601602  // A replace with zero incoming entries is almost certainly a caller bug603  // (e.g. an import that silently dropped everything). Refuse rather than604  // wipe the vault.605  if (mode === "replace" && incoming.length === 0) {606    throw new Error("refuse to replace with empty entry set");607  }608609  let added = 0;610  let updated = 0;611  let skipped = 0;612613  if (mode === "replace") {614    entries = [];615    for (const entry of incoming) {616      const normalized = sanitizeRestoredEntry(entry);617      if (!normalized) {618        skipped += 1;619        continue;620      }621      entries.push(normalized);622      added += 1;623    }624  } else {625    const byId = new Map<string, VaultEntry>();626    for (const e of entries) byId.set(e.id, e);627628    for (const entry of incoming) {629      const normalized = sanitizeRestoredEntry(entry);630      if (!normalized) {631        skipped += 1;632        continue;633      }634      const existing = byId.get(normalized.id);635      if (!existing) {636        entries.push(normalized);637        added += 1;638      } else if (normalized.modifiedAt > existing.modifiedAt) {639        const idx = entries.findIndex((e) => e.id === existing.id);640        if (idx !== -1) {641          entries[idx] = normalized;642          updated += 1;643        }644      } else {645        skipped += 1;646      }647    }648  }649650  await persistEntries();651  return { added, updated, skipped };652}653654/**655 * Validate + normalise a VaultEntry coming from a backup. Accepts656 * anything structurally sound; keeps all metadata verbatim. Returns657 * null for values the live vault shouldn't accept (e.g., missing title).658 */659function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {660  if (!entry || typeof entry !== "object") return null;661  const title = typeof entry.title === "string" ? entry.title.trim() : "";662  if (!title) return null;663  const type = entry.type ?? "login";664  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();665  const now = Date.now();666  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;667  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;668669  if (type === "login" && typeof entry.password !== "string") {670    return null;671  }672  if (673    type === "passkey" &&674    (typeof entry.rpId !== "string" ||675      typeof entry.credentialId !== "string" ||676      !entry.privateKeyJwk)677  ) {678    return null;679  }680681  return {682    ...entry,683    id,684    title,685    type,686    createdAt,687    modifiedAt,688  };689}690691// Soft-deletes every non-deleted entry in one call. The literal string692// argument is a tripwire against accidental calls — any caller that would693// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this694// to a boolean.695export async function clearVaultEntries(696  confirmation: "REPLACE_ALL"697): Promise<void> {698  if (confirmation !== "REPLACE_ALL") {699    throw new Error("clearVaultEntries requires explicit confirmation");700  }701  if (!derivedKey) throw new Error("vault locked");702  for (const entry of entries) {703    if (!entry.deletedAt) {704      entry.deletedAt = Date.now();705      entry.modifiedAt = Date.now();706    }707  }708  await persistEntries();709}710711export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {712  if (!derivedKey) throw new Error("vault locked");713714  const sanitized = sanitizeEntryDraft(entry);715  const now = Date.now();716  const nextEntry: VaultEntry = {717    ...sanitized,718    id: crypto.randomUUID(),719    createdAt: now,720    modifiedAt: now,721    // Stamp passwordChangedAt at creation so age tracking is accurate from722    // day one. Non-login entries carry the same stamp harmlessly.723    passwordChangedAt: sanitized.password ? now : undefined,724  };725726  entries.push(nextEntry);727  await persistEntries();728729  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)730  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {731    checkBreach(nextEntry.password).then(async (count) => {732      if (count > 0) {733        const idx = entries.findIndex((e) => e.id === nextEntry.id);734        if (idx !== -1) {735          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };736          await persistEntries();737        }738      }739    });740  }741742  return nextEntry;743}744745export async function updateEntry(746  id: string,747  patch: Partial<VaultEntry>748): Promise<void> {749  if (!derivedKey) throw new Error("vault locked");750751  const idx = entries.findIndex((entry) => entry.id === id);752  if (idx === -1 || entries[idx].deletedAt) {753    throw new Error("entry not found");754  }755756  const current = entries[idx];757  const sanitizedPatch = sanitizeEntryPatch(patch);758  // Refresh passwordChangedAt only when the password value actually changes —759  // a metadata-only update (title, tags, etc.) must not reset the age clock760  // that drives the Pro health rotation recommendation.761  const passwordChanged =762    "password" in patch &&763    typeof sanitizedPatch.password === "string" &&764    sanitizedPatch.password !== current.password;765  entries[idx] = {766    ...current,767    ...sanitizedPatch,768    modifiedAt: Date.now(),769    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),770  };771772  // sanitizeEntryPatch maps an empty password to `undefined`. For login773  // entries that would silently produce a passwordless record — roll back774  // the merge and refuse.775  if (entries[idx].type === "login" && !entries[idx].password) {776    entries[idx] = current;777    throw new Error("password is required for login entries");778  }779780  // Card entries must retain their card number781  if (entries[idx].type === "card" && !entries[idx].cardNumber) {782    entries[idx] = current;783    throw new Error("card number is required for card entries");784  }785786  await persistEntries();787788  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)789  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {790    const password = entries[idx].password!;791    checkBreach(password).then(async (count) => {792      const i = entries.findIndex((e) => e.id === id);793      if (i !== -1) {794        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };795        await persistEntries();796      }797    });798  }799}800801export async function deleteEntry(id: string): Promise<void> {802  if (!derivedKey) throw new Error("vault locked");803804  const entry = entries.find((item) => item.id === id);805  if (!entry || entry.deletedAt) {806    throw new Error("entry not found");807  }808809  entry.deletedAt = Date.now();810  entry.modifiedAt = Date.now();811  await persistEntries();812}813814export function getDeletedEntries(): VaultEntry[] {815  if (!derivedKey) return [];816  return entries.filter((e) => !!e.deletedAt);817}818819export async function restoreEntry(id: string): Promise<void> {820  if (!derivedKey) throw new Error("vault locked");821  const entry = entries.find((e) => e.id === id && e.deletedAt);822  if (!entry) throw new Error("deleted entry not found");823  delete entry.deletedAt;824  entry.modifiedAt = Date.now();825  await persistEntries();826  buildDomainIndexSync(entries);827}828829export async function purgeEntry(id: string): Promise<void> {830  if (!derivedKey) throw new Error("vault locked");831  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);832  if (idx === -1) throw new Error("deleted entry not found");833  entries.splice(idx, 1);834  await persistEntries();835}836837/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */838export async function purgeExpiredTrash(retentionMs: number): Promise<number> {839  if (!derivedKey || retentionMs === 0) return 0;840  const cutoff = Date.now() - retentionMs;841  const before = entries.length;842  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));843  const purged = before - entries.length;844  if (purged > 0) await persistEntries();845  return purged;846}847848export function queryDomain(domain: string): VaultEntry[] {849  return domainIndex.get(domain) ?? [];850}851852export function getEntryById(id: string): VaultEntry | undefined {853  return entries.find((entry) => entry.id === id && !entry.deletedAt);854}855856async function persistSlotA(): Promise<StoredVault> {857  if (!derivedKey) throw new Error("vault locked");858859  const slotA = await loadSlot(SLOT_A);860  if (!slotA) throw new Error("missing slot A");861862  const plaintext = encodePayload(entries, duressConfigured);863  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);864865  const nextSlotA: StoredVault = {866    ...slotA,867    nonce,868    ciphertext,869    tag,870  };871  await writeSlot(SLOT_A, nextSlotA);872873  // When duress is not configured, keep slot B size-matched with slot A by874  // regenerating its noise. When duress IS configured, leave slot B alone —875  // we don't have the decoy key to re-encrypt it. This is a known trade-off.876  if (!duressConfigured) {877    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));878  }879880  return nextSlotA;881}882883async function persistSlotB(): Promise<void> {884  if (!derivedKey) throw new Error("vault locked");885886  const slotB = await loadSlot(SLOT_B);887  if (!slotB) throw new Error("missing slot B");888889  // Decoy plaintext always reports duressConfigured=false — the decoy user890  // sees a clean slate.891  const plaintext = encodePayload(entries, false);892  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);893894  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });895}896897async function persistEntries(): Promise<void> {898  if (!derivedKey) throw new Error("vault locked");899900  if (vaultMode === "decoy") {901    await persistSlotB();902  } else {903    const freshSlotA = await persistSlotA();904    // Save a snapshot of the real vault after every successful write.905    // Failures are silently swallowed — snapshots must never interrupt saves.906    const activeCount = entries.filter((e) => !e.deletedAt).length;907    void takeSnapshot(freshSlotA, activeCount);908  }909  buildDomainIndexSync(entries);910}911912function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {913  if (!Array.isArray(values)) return undefined;914  const seen = new Set<string>();915  const cleaned: string[] = [];916  for (const value of values) {917    const trimmed = typeof value === "string" ? value.trim() : "";918    if (!trimmed) continue;919    const key = trimmed.toLowerCase();920    if (seen.has(key)) continue;921    seen.add(key);922    cleaned.push(trimmed);923  }924  return cleaned.length > 0 ? cleaned : undefined;925}926927function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {928  const title = entry.title.trim();929  const type = entry.type ?? "login";930931  if (!title) throw new Error("title is required");932  if (type === "login" && !entry.password?.trim()) {933    throw new Error("password is required");934  }935  if (type === "passkey") {936    if (937      !entry.rpId?.trim() ||938      !entry.rpName?.trim() ||939      !entry.credentialId?.trim() ||940      !entry.publicKeyCose?.trim() ||941      !entry.privateKeyJwk ||942      !entry.userHandle?.trim() ||943      !entry.userName?.trim() ||944      !entry.userDisplayName?.trim() ||945      (entry.algorithm !== -7 && entry.algorithm !== -8)946    ) {947      throw new Error("invalid passkey entry");948    }949  }950  if (type === "card") {951    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {952      throw new Error("cardholder name and card number are required");953    }954  }955956  return {957    type,958    title,959    username: type === "login" ? entry.username?.trim() ?? "" : undefined,960    password: type === "login" ? entry.password : undefined,961    url: type === "login" ? entry.url?.trim() ?? "" : undefined,962    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,963    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,964    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,965    breach: type === "login" ? entry.breach : undefined,966    canary: type === "login" ? entry.canary : undefined,967    tags: entry.tags,968    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,969    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,970    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,971    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,972    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,973    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,974    userName: type === "passkey" ? entry.userName?.trim() : undefined,975    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,976    algorithm: type === "passkey" ? entry.algorithm : undefined,977    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,978    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,979    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,980    backupState: type === "passkey" ? entry.backupState === true : undefined,981    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,982    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,983    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,984    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,985    expYear: type === "card" ? entry.expYear?.trim() : undefined,986    cvc: type === "card" ? entry.cvc?.trim() : undefined,987    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,988    network: type === "card" ? entry.network : undefined,989    last4: type === "card" ? entry.last4?.trim() : undefined,990  };991}992993function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {994  const nextPatch: Partial<VaultEntry> = {};995996  if ("type" in patch) {997    nextPatch.type = patch.type;998  }9991000  if ("title" in patch) {1001    const title = patch.title?.trim() ?? "";1002    if (!title) throw new Error("title is required");1003    nextPatch.title = title;1004  }10051006  if ("url" in patch) {1007    nextPatch.url = patch.url?.trim() ?? "";1008  }10091010  if ("additionalUrls" in patch) {1011    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1012    const seen = new Set<string>();1013    const cleaned: string[] = [];1014    for (const value of incoming) {1015      const trimmed = typeof value === "string" ? value.trim() : "";1016      if (!trimmed) continue;1017      const key = trimmed.toLowerCase();1018      if (seen.has(key)) continue;1019      seen.add(key);1020      cleaned.push(trimmed);1021    }1022    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1023  }10241025  if ("username" in patch) {1026    nextPatch.username = patch.username?.trim() ?? "";1027  }10281029  if ("password" in patch) {1030    const password = patch.password ?? "";1031    // Allow clearing a password (e.g. on passkey-only entries)1032    nextPatch.password = password || undefined;1033  }10341035  if ("totp" in patch) {1036    nextPatch.totp = patch.totp?.trim() || undefined;1037  }10381039  if ("notes" in patch) {1040    nextPatch.notes = patch.notes?.trim() || undefined;1041  }10421043  if ("breach" in patch) {1044    nextPatch.breach = patch.breach;1045  }10461047  if ("canary" in patch) {1048    nextPatch.canary = patch.canary;1049  }10501051  if ("rpId" in patch) {1052    nextPatch.rpId = patch.rpId?.trim();1053  }10541055  if ("rpName" in patch) {1056    nextPatch.rpName = patch.rpName?.trim();1057  }10581059  if ("credentialId" in patch) {1060    nextPatch.credentialId = patch.credentialId?.trim();1061  }10621063  if ("publicKeyCose" in patch) {1064    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1065  }10661067  if ("privateKeyJwk" in patch) {1068    nextPatch.privateKeyJwk = patch.privateKeyJwk;1069  }10701071  if ("userHandle" in patch) {1072    nextPatch.userHandle = patch.userHandle?.trim();1073  }10741075  if ("userName" in patch) {1076    nextPatch.userName = patch.userName?.trim();1077  }10781079  if ("userDisplayName" in patch) {1080    nextPatch.userDisplayName = patch.userDisplayName?.trim();1081  }10821083  if ("algorithm" in patch) {1084    nextPatch.algorithm = patch.algorithm;1085  }10861087  if ("signCount" in patch) {1088    nextPatch.signCount = patch.signCount;1089  }10901091  if ("aaguid" in patch) {1092    nextPatch.aaguid = patch.aaguid?.trim();1093  }10941095  if ("backupEligible" in patch) {1096    nextPatch.backupEligible = patch.backupEligible;1097  }10981099  if ("backupState" in patch) {1100    nextPatch.backupState = patch.backupState;1101  }11021103  if ("lastUsedAt" in patch) {1104    nextPatch.lastUsedAt = patch.lastUsedAt;1105  }11061107  if ("tags" in patch) {1108    nextPatch.tags = patch.tags;1109  }11101111  if ("cardholderName" in patch) {1112    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1113  }1114  if ("cardNumber" in patch) {1115    const n = patch.cardNumber ?? "";1116    nextPatch.cardNumber = n || undefined;1117  }1118  if ("expMonth" in patch) {1119    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1120  }1121  if ("expYear" in patch) {1122    nextPatch.expYear = patch.expYear?.trim() || undefined;1123  }1124  if ("cvc" in patch) {1125    nextPatch.cvc = patch.cvc?.trim() || undefined;1126  }1127  if ("cardNickname" in patch) {1128    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1129  }1130  if ("network" in patch) {1131    nextPatch.network = patch.network;1132  }1133  if ("last4" in patch) {1134    nextPatch.last4 = patch.last4?.trim() || undefined;1135  }11361137  return nextPatch;1138}11391140function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1141  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1142  const modifiedAt =1143    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;11441145  return {1146    id: entry.id,1147    type: "type" in entry ? entry.type : "login",1148    title: entry.title,1149    username: entry.username,1150    password: entry.password,1151    url: entry.url,1152    totp: entry.totp,1153    notes: entry.notes,1154    breach: entry.breach,1155    canary: "canary" in entry ? entry.canary : undefined,1156    rpId: "rpId" in entry ? entry.rpId : undefined,1157    rpName: "rpName" in entry ? entry.rpName : undefined,1158    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1159    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1160    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1161    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1162    userName: "userName" in entry ? entry.userName : undefined,1163    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1164    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1165    signCount: "signCount" in entry ? entry.signCount : undefined,1166    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1167    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1168    backupState: "backupState" in entry ? entry.backupState : undefined,1169    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1170    createdAt,1171    modifiedAt,1172    deletedAt: entry.deletedAt,1173    tags: "tags" in entry ? entry.tags : undefined,1174    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1175    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1176    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1177    expYear: "expYear" in entry ? entry.expYear : undefined,1178    cvc: "cvc" in entry ? entry.cvc : undefined,1179    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1180    network: "network" in entry ? entry.network : undefined,1181    last4: "last4" in entry ? entry.last4 : undefined,1182  };1183}11841185function hasLegacyShape(parsed: VaultEntry[]): boolean {1186  return parsed.some(1187    (entry) => "created" in entry || "updated" in entry1188  );1189}11901191// ---------------------------------------------------------------------------1192// Deduplication1193// ---------------------------------------------------------------------------11941195function entryDupeKey(e: VaultEntry): string {1196  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1197  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1198  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1199}12001201/** Returns the number of duplicate entries (those that would be removed). */1202export function countDuplicates(): number {1203  const best = new Map<string, VaultEntry>();1204  let dupeCount = 0;1205  for (const e of entries.filter((e) => !e.deletedAt)) {1206    const key = entryDupeKey(e);1207    const existing = best.get(key);1208    if (!existing) {1209      best.set(key, e);1210    } else {1211      dupeCount++;1212      // Keep the more recently modified one in the map1213      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1214        best.set(key, e);1215      }1216    }1217  }1218  return dupeCount;1219}12201221/**1222 * Soft-deletes duplicate entries, keeping the most recently modified copy of1223 * each group. Returns the number of entries moved to trash.1224 */1225export async function deduplicateEntries(): Promise<number> {1226  if (!derivedKey) throw new Error("vault locked");1227  // Dedup reshapes slot B permanently under the decoy key, which is1228  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1229  if (vaultMode === "decoy") {1230    throw new Error("deduplicate disabled in decoy mode");1231  }12321233  const best = new Map<string, VaultEntry>();1234  for (const e of entries.filter((en) => !en.deletedAt)) {1235    const key = entryDupeKey(e);1236    const existing = best.get(key);1237    if (!existing) {1238      best.set(key, e);1239    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1240      best.set(key, e);1241    }1242  }12431244  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1245  const now = Date.now();1246  let removed = 0;1247  entries = entries.map((e) => {1248    if (!e.deletedAt && !keepIds.has(e.id)) {1249      removed++;1250      return { ...e, deletedAt: now };1251    }1252    return e;1253  });12541255  if (removed > 0) await persistEntries();1256  return removed;1257}12581259// ---------------------------------------------------------------------------1260// Vault snapshot system — last 6 encrypted states, used for self-recovery1261// ---------------------------------------------------------------------------12621263const SNAPSHOT_KEY = "peach_vault_snapshots";1264const MAX_SNAPSHOTS = 6;12651266interface VaultSnapshot {1267  timestamp: number;1268  entryCount: number;1269  slot: StoredVault;1270}12711272/** Public summary (no sensitive data) returned to the popup. */1273export interface SnapshotSummary {1274  index: number;1275  timestamp: number;1276  entryCount: number;1277}12781279async function takeSnapshot(1280  slot: StoredVault,1281  entryCount: number1282): Promise<void> {1283  try {1284    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1285    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1286    const next: VaultSnapshot[] = [1287      { timestamp: Date.now(), entryCount, slot },1288      ...existing,1289    ].slice(0, MAX_SNAPSHOTS);1290    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1291  } catch {1292    // Non-fatal — snapshot failure must never interrupt vault saves1293  }1294}12951296export async function listSnapshots(): Promise<SnapshotSummary[]> {1297  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1298  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1299  return snapshots.map((s, i) => ({1300    index: i,1301    timestamp: s.timestamp,1302    entryCount: s.entryCount,1303  }));1304}13051306/**1307 * Restore a snapshot by index. The password must decrypt that snapshot1308 * (i.e., the same master password used when the snapshot was taken).1309 * On success the vault is unlocked in-memory with the restored entries1310 * — no re-login required.1311 */1312export async function restoreFromSnapshot(1313  index: number,1314  password: string1315): Promise<boolean> {1316  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1317  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1318  const snapshot = snapshots[index];1319  if (!snapshot) return false;13201321  // Verify the password decrypts this snapshot1322  const key = await deriveKey(password, unhex(snapshot.slot.salt));1323  const payload = await tryDecryptSlot(snapshot.slot, key);1324  if (!payload) {1325    zeroKey(key);1326    return false;1327  }13281329  // Write the snapshot back to slot A. Regenerate noise for slot B.1330  await writeSlot(SLOT_A, snapshot.slot);1331  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));13321333  // Swap in-memory state to the restored vault — caller gets immediate access1334  if (derivedKey) zeroKey(derivedKey);1335  derivedKey = key;1336  entries = payload.entries;1337  duressConfigured = payload.duressConfigured;1338  vaultMode = "real";1339  buildDomainIndexSync(entries);1340  await loadSettings();1341  touchAutoLock();13421343  return true;1344}13451346/**1347 * Change the master password. Re-encrypts slot A with a fresh salt + new1348 * derived key. Slot B (duress or noise) is left untouched when duress is1349 * configured — it uses an independent key derived from the duress password —1350 * and regenerated as noise when duress is not configured.1351 */1352export async function changePassword(1353  currentPassword: string,1354  newPassword: string1355): Promise<boolean> {1356  if (!derivedKey || vaultMode !== "real") return false;13571358  // Verify current password before allowing any change1359  const valid = await verifyMainPassword(currentPassword);1360  if (!valid) return false;13611362  const slotA = await loadSlot(SLOT_A);1363  if (!slotA) return false;13641365  // Derive fresh key from new password + new random salt1366  const newSalt = generateSalt();1367  const newKey = await deriveKey(newPassword, newSalt);13681369  // Re-encrypt the in-memory entries with the new key1370  const plaintext = encodePayload(entries, duressConfigured);1371  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);13721373  const freshSlotA: StoredVault = {1374    ...slotA,1375    salt: hex(newSalt),1376    nonce,1377    ciphertext,1378    tag,1379  };1380  await writeSlot(SLOT_A, freshSlotA);13811382  // Swap the in-memory key synchronously before any further await. If we1383  // threw between the Slot A write and this swap, the next persistEntries()1384  // would overwrite Slot A with old-key ciphertext, locking the user out.1385  zeroKey(derivedKey);1386  derivedKey = newKey;13871388  // Snapshot now that in-memory key and disk are consistent. Pass the1389  // slot content we just wrote rather than re-reading from storage so the1390  // snapshot is immune to any microtask-coalesced read-after-write race.1391  const activeCount = entries.filter((e) => !e.deletedAt).length;1392  void takeSnapshot(freshSlotA, activeCount);13931394  // Regenerate noise for slot B when duress is not in use. Runs after the1395  // key swap so a failure here is a disposable noise regeneration, not a1396  // lockout.1397  if (!duressConfigured) {1398    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1399  }14001401  return true;1402}