vault.ts

38.7 KB · raw

1import { deriveKey, generateSalt } from "../crypto/argon2";2import { extractDomain } from "../shared/domain";3import type {4  AccentColorName,5  LegacyVaultEntry,6  StoredVault,7  VaultEntry,8} from "../shared/types";9import { decrypt, encrypt, zeroKey } from "./aes";10import { clearAutoLock, touchAutoLock } from "./alarms";1112type EntryDraft = Omit<13  VaultEntry,14  "id" | "createdAt" | "modifiedAt" | "deletedAt"15>;1617// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B18// either holds the decoy vault (when duress is configured) or random noise19// that looks identical at rest. No external indicator is stored — duress20// configuration is recorded only inside the encrypted payload of slot A.21const SLOT_A = "peach_vault_a";22const SLOT_B = "peach_vault_b";2324// Legacy keys, migrated away on first access.25const LEGACY_MAIN = "peach_vault";26const LEGACY_DECOY = "peach_decoy_vault";27const LEGACY_DURESS_FLAG = "peach_duress";2829// Plaintext is padded to the next multiple of this size before encryption.30// This coarsens ciphertext-length leakage into 1 KiB buckets.31const PAD_BLOCK = 1024;3233let derivedKey: Uint8Array | null = null;34let entries: VaultEntry[] = [];35let domainIndex: Map<string, VaultEntry[]> = new Map();36let vaultMode: "real" | "decoy" = "real";37// Cached post-unlock view of whether the real vault records that duress is38// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock39// this stays false because we never see slot A's plaintext.40let duressConfigured = false;4142let migrationRan = false;4344// ---------------------------------------------------------------------------45// Settings (persisted inside the encrypted vault store as a sidecar key)46// ---------------------------------------------------------------------------4748export interface VaultSettings {49  accentColor?: AccentColorName;50  theme?: "dark" | "light";51  autoLockMinutes?: number;52  clipboardClearSeconds?: number;53  lockOnClose?: boolean;54  breachMonitoring?: boolean;55  passkeyProviderEnabled?: boolean;56  trashRetentionDays?: number;57}5859let settings: VaultSettings = {};6061export function getSettings(): VaultSettings {62  return settings;63}6465export async function ensureSettingsLoaded(): Promise<VaultSettings> {66  await loadSettings();67  return settings;68}6970export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {71  await loadSettings();72  settings = { ...settings, ...patch };73  await persistSettings();74}7576async function persistSettings(): Promise<void> {77  try {78    await chrome.storage.local.set({ peach_settings: settings });79  } catch (err) {80    throw mapStorageError(err);81  }82}8384function mapStorageError(err: unknown): Error {85  const message = err instanceof Error ? err.message : String(err);86  if (/QUOTA_BYTES|quota/i.test(message)) {87    return new Error("STORAGE_QUOTA_EXCEEDED");88  }89  return err instanceof Error ? err : new Error(message);90}9192async function loadSettings(): Promise<void> {93  const result = await chrome.storage.local.get("peach_settings");94  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};95}9697// ---------------------------------------------------------------------------98// HIBP breach check (k-anonymity model)99// ---------------------------------------------------------------------------100101export async function checkBreach(password: string): Promise<number> {102  const encoder = new TextEncoder();103  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));104  const hashHex = Array.from(new Uint8Array(hashBuffer))105    .map((b) => b.toString(16).padStart(2, "0"))106    .join("")107    .toUpperCase();108109  const prefix = hashHex.slice(0, 5);110  const suffix = hashHex.slice(5);111112  try {113    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);114    if (!res.ok) return 0;115    const text = await res.text();116    const match = text.split("\r\n").find((l) => l.startsWith(suffix));117    return match ? parseInt(match.split(":")[1], 10) : 0;118  } catch {119    return 0; // Network unavailable — don't block120  }121}122123function hex(buf: Uint8Array): string {124  return Array.from(buf)125    .map((b) => b.toString(16).padStart(2, "0"))126    .join("");127}128129function unhex(s: string): Uint8Array {130  const out = new Uint8Array(s.length / 2);131  for (let i = 0; i < s.length; i += 2) {132    out[i / 2] = parseInt(s.slice(i, i + 2), 16);133  }134  return out;135}136137// ---------------------------------------------------------------------------138// Storage schema migration139// ---------------------------------------------------------------------------140141async function loadSlot(key: string): Promise<StoredVault | null> {142  const result = await chrome.storage.local.get(key);143  return (result as Record<string, StoredVault | undefined>)[key] ?? null;144}145146async function writeSlot(key: string, vault: StoredVault): Promise<void> {147  try {148    await chrome.storage.local.set({ [key]: vault });149  } catch (err) {150    throw mapStorageError(err);151  }152}153154/**155 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault156 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +157 * peach_vault_b). Runs at most once per session; safe to call repeatedly.158 */159async function ensureMigrated(): Promise<void> {160  if (migrationRan) return;161  migrationRan = true;162163  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];164  const raw = await chrome.storage.local.get(keys);165  const slotAExists = raw[SLOT_A] !== undefined;166  const slotBExists = raw[SLOT_B] !== undefined;167  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;168  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;169170  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.171  if (!slotAExists && !legacyMain) return;172173  // Already migrated.174  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;175176  if (legacyMain && !slotAExists) {177    await writeSlot(SLOT_A, legacyMain);178  }179180  // If slot B isn't set, populate it: use legacy decoy when present, else noise.181  if (!slotBExists) {182    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;183    if (legacyDecoy) {184      await writeSlot(SLOT_B, legacyDecoy);185    } else if (target) {186      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));187    }188  }189190  const toRemove: string[] = [];191  if (legacyMain) toRemove.push(LEGACY_MAIN);192  if (legacyDecoy) toRemove.push(LEGACY_DECOY);193  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);194  if (toRemove.length > 0) {195    await chrome.storage.local.remove(toRemove);196  }197}198199// ---------------------------------------------------------------------------200// Padding + noise helpers201// ---------------------------------------------------------------------------202203/**204 * Pads the plaintext string with trailing spaces so that the encoded byte205 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing206 * whitespace after a complete document, so the padding is transparent on207 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.208 */209function padPlaintext(json: string): string {210  const encoded = new TextEncoder().encode(json);211  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);212  const padLen = target - encoded.length;213  return json + " ".repeat(padLen);214}215216/**217 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The218 * ciphertext length matches the given byte count. Because GCM ciphertext is219 * pseudo-random, this blob is indistinguishable at rest from a real220 * encrypted vault without the key.221 */222function generateNoiseBlob(ciphertextBytes: number): StoredVault {223  const salt = crypto.getRandomValues(new Uint8Array(16));224  const nonce = crypto.getRandomValues(new Uint8Array(12));225  const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));226  const tag = crypto.getRandomValues(new Uint8Array(16));227  return {228    version: 1,229    salt: hex(salt),230    nonce: hex(nonce),231    ciphertext: hex(cipher),232    tag: hex(tag),233  };234}235236// ---------------------------------------------------------------------------237// Encrypted plaintext envelope238// ---------------------------------------------------------------------------239240type VaultPayload = {241  entries: VaultEntry[];242  duressConfigured?: boolean;243};244245type DecryptedPayload = {246  entries: VaultEntry[];247  duressConfigured: boolean;248};249250function encodePayload(entries: VaultEntry[], duress: boolean): string {251  const envelope: VaultPayload = { entries, duressConfigured: duress };252  return padPlaintext(JSON.stringify(envelope));253}254255function decodePayload(plain: string): DecryptedPayload {256  const parsed = JSON.parse(plain) as257    | Array<VaultEntry | LegacyVaultEntry>258    | VaultPayload;259260  if (Array.isArray(parsed)) {261    // Pre-A-2 plaintext: bare array of entries, no duress flag.262    return {263      entries: parsed.map(normalizeEntry),264      duressConfigured: false,265    };266  }267268  return {269    entries: (parsed.entries ?? []).map(normalizeEntry),270    duressConfigured: parsed.duressConfigured === true,271  };272}273274async function tryDecryptSlot(275  vault: StoredVault,276  key: Uint8Array277): Promise<DecryptedPayload | null> {278  try {279    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);280    return decodePayload(plain);281  } catch {282    return null;283  }284}285286// ---------------------------------------------------------------------------287// Core operations288// ---------------------------------------------------------------------------289290function buildDomainIndexSync(all: VaultEntry[]): void {291  domainIndex = new Map();292293  for (const entry of all) {294    if (entry.deletedAt) continue;295    if (entry.type === "note" || entry.type === "passkey") continue;296297    const domain = extractDomain(entry.url ?? "");298    if (!domain) continue;299300    const bucket = domainIndex.get(domain) ?? [];301    bucket.push(entry);302    domainIndex.set(domain, bucket);303  }304}305306export async function createVault(password: string): Promise<void> {307  await ensureMigrated();308309  const salt = generateSalt();310  derivedKey = await deriveKey(password, salt);311  entries = [];312  vaultMode = "real";313  duressConfigured = false;314  buildDomainIndexSync(entries);315316  const plaintext = encodePayload(entries, duressConfigured);317  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);318319  const slotA: StoredVault = {320    version: 1,321    salt: hex(salt),322    nonce,323    ciphertext,324    tag,325  };326  await writeSlot(SLOT_A, slotA);327328  // Matching-size noise blob for slot B, so a fresh install shows two329  // similarly-shaped records regardless of whether duress is ever set up.330  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));331332  touchAutoLock();333}334335export async function hasVault(): Promise<boolean> {336  await ensureMigrated();337  return (await loadSlot(SLOT_A)) !== null;338}339340/**341 * Returns whether the real vault has recorded that a duress passphrase is342 * configured. Reliable only after a real-mode unlock — before unlock or in343 * decoy mode, returns false because we cannot read slot A's plaintext.344 */345export async function isDuressEnabled(): Promise<boolean> {346  return vaultMode === "real" && duressConfigured;347}348349export async function hasDecoyVault(): Promise<boolean> {350  return isDuressEnabled();351}352353export async function setupDuress(duressPassword: string): Promise<void> {354  if (!derivedKey || vaultMode !== "real") {355    throw new Error("vault must be unlocked in real mode");356  }357  await ensureMigrated();358359  // Encrypt the decoy payload (empty entries) under the duress key. Pad to360  // at least the current slot A ciphertext size so the two slots look the361  // same at rest at setup time.362  const slotA = await loadSlot(SLOT_A);363  if (!slotA) throw new Error("missing slot A");364365  const decoySalt = generateSalt();366  const decoyKey = await deriveKey(duressPassword, decoySalt);367  try {368    // Pad the decoy plaintext to match slot A's ciphertext length (minus the369    // 16-byte tag) so encrypted sizes align exactly.370    const targetBytes = slotA.ciphertext.length / 2;371    let plaintext = encodePayload([], false);372    const encoded = new TextEncoder().encode(plaintext);373    if (encoded.length < targetBytes) {374      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);375    }376    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);377    await writeSlot(SLOT_B, {378      version: 1,379      salt: hex(decoySalt),380      nonce,381      ciphertext,382      tag,383    });384  } finally {385    zeroKey(decoyKey);386  }387388  // Re-encrypt slot A with the duressConfigured flag set to true. No external389  // flag is written — the bit lives inside the encrypted envelope.390  duressConfigured = true;391  await persistSlotA();392}393394export async function disableDuress(): Promise<void> {395  if (!derivedKey || vaultMode !== "real") {396    throw new Error("vault must be unlocked in real mode");397  }398  await ensureMigrated();399400  // Overwrite slot B with fresh noise that matches slot A's size. The slot401  // remains occupied so its mere existence does not signal that duress was402  // ever configured.403  const slotA = await loadSlot(SLOT_A);404  if (slotA) {405    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));406  }407408  duressConfigured = false;409  await persistSlotA();410}411412export async function verifyMainPassword(password: string): Promise<boolean> {413  await ensureMigrated();414  const slotA = await loadSlot(SLOT_A);415  if (!slotA) return false;416  const key = await deriveKey(password, unhex(slotA.salt));417  try {418    return (await tryDecryptSlot(slotA, key)) !== null;419  } finally {420    zeroKey(key);421  }422}423424export async function unlock(password: string): Promise<boolean> {425  await ensureMigrated();426427  // Load both slots in parallel. Both always exist post-migration; the428  // parallel load and the parallel derivation below keep wall-clock time429  // constant regardless of which slot's passphrase (real or decoy) the430  // caller supplied, so a timing side channel cannot distinguish the431  // duress path from the real-unlock path (DUR-002).432  const [slotA, slotB] = await Promise.all([433    loadSlot(SLOT_A),434    loadSlot(SLOT_B),435  ]);436437  // Derive both keys in parallel, unconditionally.438  const [keyA, keyB] = await Promise.all([439    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),440    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),441  ]);442443  // Attempt both decrypts in parallel.444  const [payloadA, payloadB] = await Promise.all([445    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),446    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),447  ]);448449  if (payloadA && keyA) {450    derivedKey = keyA;451    vaultMode = "real";452    entries = payloadA.entries;453    duressConfigured = payloadA.duressConfigured;454    buildDomainIndexSync(entries);455    await loadSettings();456    touchAutoLock();457    if (keyB) zeroKey(keyB);458    // If the payload came from the pre-envelope (legacy) shape, rewrite459    // it immediately so the next save uses the new format.460    if (hasLegacyShape(payloadA.entries)) {461      await persistSlotA();462    }463    return true;464  }465466  if (payloadB && keyB) {467    derivedKey = keyB;468    vaultMode = "decoy";469    entries = payloadB.entries;470    duressConfigured = false;471    buildDomainIndexSync(entries);472    await loadSettings();473    touchAutoLock();474    if (keyA) zeroKey(keyA);475    return true;476  }477478  if (keyA) zeroKey(keyA);479  if (keyB) zeroKey(keyB);480  return false;481}482483export function lock(): void {484  if (derivedKey) {485    zeroKey(derivedKey);486    derivedKey = null;487  }488489  entries = [];490  domainIndex = new Map();491  vaultMode = "real";492  duressConfigured = false;493  clearAutoLock();494}495496export function isUnlocked(): boolean {497  return derivedKey !== null;498}499500export function getEntries(): VaultEntry[] {501  if (!derivedKey) throw new Error("vault locked");502  return entries.filter((entry) => !entry.deletedAt);503}504505export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {506  if (!derivedKey) throw new Error("vault locked");507508  const now = Date.now();509  const added: VaultEntry[] = [];510  for (const draft of drafts) {511    try {512      const sanitized = sanitizeEntryDraft(draft);513      const nextEntry: VaultEntry = {514        ...sanitized,515        id: crypto.randomUUID(),516        createdAt: now,517        modifiedAt: now,518      };519      entries.push(nextEntry);520      added.push(nextEntry);521    } catch {522      // Skip invalid drafts silently — caller can see diff between input and output length523    }524  }525526  if (added.length > 0) {527    await persistEntries();528  }529530  return added;531}532533/**534 * Restore a set of entries in full fidelity — preserves id, createdAt,535 * modifiedAt, and every other field unlike `batchAddEntries` which536 * assigns fresh identity. Used by the CJK phrase restore path where the537 * backup is the authoritative source of truth. `mode` controls how538 * entries already in the vault are treated.539 */540export async function restoreEntries(541  incoming: VaultEntry[],542  mode: "replace" | "merge"543): Promise<{ added: number; updated: number; skipped: number }> {544  if (!derivedKey) throw new Error("vault locked");545546  // A replace with zero incoming entries is almost certainly a caller bug547  // (e.g. an import that silently dropped everything). Refuse rather than548  // wipe the vault.549  if (mode === "replace" && incoming.length === 0) {550    throw new Error("refuse to replace with empty entry set");551  }552553  let added = 0;554  let updated = 0;555  let skipped = 0;556557  if (mode === "replace") {558    entries = [];559    for (const entry of incoming) {560      const normalized = sanitizeRestoredEntry(entry);561      if (!normalized) {562        skipped += 1;563        continue;564      }565      entries.push(normalized);566      added += 1;567    }568  } else {569    const byId = new Map<string, VaultEntry>();570    for (const e of entries) byId.set(e.id, e);571572    for (const entry of incoming) {573      const normalized = sanitizeRestoredEntry(entry);574      if (!normalized) {575        skipped += 1;576        continue;577      }578      const existing = byId.get(normalized.id);579      if (!existing) {580        entries.push(normalized);581        added += 1;582      } else if (normalized.modifiedAt > existing.modifiedAt) {583        const idx = entries.findIndex((e) => e.id === existing.id);584        if (idx !== -1) {585          entries[idx] = normalized;586          updated += 1;587        }588      } else {589        skipped += 1;590      }591    }592  }593594  await persistEntries();595  return { added, updated, skipped };596}597598/**599 * Validate + normalise a VaultEntry coming from a backup. Accepts600 * anything structurally sound; keeps all metadata verbatim. Returns601 * null for values the live vault shouldn't accept (e.g., missing title).602 */603function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {604  if (!entry || typeof entry !== "object") return null;605  const title = typeof entry.title === "string" ? entry.title.trim() : "";606  if (!title) return null;607  const type = entry.type ?? "login";608  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();609  const now = Date.now();610  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;611  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;612613  if (type === "login" && typeof entry.password !== "string") {614    return null;615  }616  if (617    type === "passkey" &&618    (typeof entry.rpId !== "string" ||619      typeof entry.credentialId !== "string" ||620      !entry.privateKeyJwk)621  ) {622    return null;623  }624625  return {626    ...entry,627    id,628    title,629    type,630    createdAt,631    modifiedAt,632  };633}634635export async function clearVaultEntries(636  confirmation: "REPLACE_ALL"637): Promise<void> {638  if (confirmation !== "REPLACE_ALL") {639    throw new Error("clearVaultEntries requires explicit confirmation");640  }641  if (!derivedKey) throw new Error("vault locked");642  for (const entry of entries) {643    if (!entry.deletedAt) {644      entry.deletedAt = Date.now();645      entry.modifiedAt = Date.now();646    }647  }648  await persistEntries();649}650651export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {652  if (!derivedKey) throw new Error("vault locked");653654  const sanitized = sanitizeEntryDraft(entry);655  const now = Date.now();656  const nextEntry: VaultEntry = {657    ...sanitized,658    id: crypto.randomUUID(),659    createdAt: now,660    modifiedAt: now,661  };662663  entries.push(nextEntry);664  await persistEntries();665666  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)667  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {668    checkBreach(nextEntry.password).then(async (count) => {669      if (count > 0) {670        const idx = entries.findIndex((e) => e.id === nextEntry.id);671        if (idx !== -1) {672          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };673          await persistEntries();674        }675      }676    });677  }678679  return nextEntry;680}681682export async function updateEntry(683  id: string,684  patch: Partial<VaultEntry>685): Promise<void> {686  if (!derivedKey) throw new Error("vault locked");687688  const idx = entries.findIndex((entry) => entry.id === id);689  if (idx === -1 || entries[idx].deletedAt) {690    throw new Error("entry not found");691  }692693  const current = entries[idx];694  entries[idx] = {695    ...current,696    ...sanitizeEntryPatch(patch),697    modifiedAt: Date.now(),698  };699700  // sanitizeEntryPatch maps an empty password to `undefined`. For login701  // entries that would silently produce a passwordless record — roll back702  // the merge and refuse.703  if (entries[idx].type === "login" && !entries[idx].password) {704    entries[idx] = current;705    throw new Error("password is required for login entries");706  }707708  await persistEntries();709710  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)711  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {712    const password = entries[idx].password!;713    checkBreach(password).then(async (count) => {714      const i = entries.findIndex((e) => e.id === id);715      if (i !== -1) {716        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };717        await persistEntries();718      }719    });720  }721}722723export async function deleteEntry(id: string): Promise<void> {724  if (!derivedKey) throw new Error("vault locked");725726  const entry = entries.find((item) => item.id === id);727  if (!entry || entry.deletedAt) {728    throw new Error("entry not found");729  }730731  entry.deletedAt = Date.now();732  entry.modifiedAt = Date.now();733  await persistEntries();734}735736export function getDeletedEntries(): VaultEntry[] {737  if (!derivedKey) return [];738  return entries.filter((e) => !!e.deletedAt);739}740741export async function restoreEntry(id: string): Promise<void> {742  if (!derivedKey) throw new Error("vault locked");743  const entry = entries.find((e) => e.id === id && e.deletedAt);744  if (!entry) throw new Error("deleted entry not found");745  delete entry.deletedAt;746  entry.modifiedAt = Date.now();747  await persistEntries();748  buildDomainIndexSync(entries);749}750751export async function purgeEntry(id: string): Promise<void> {752  if (!derivedKey) throw new Error("vault locked");753  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);754  if (idx === -1) throw new Error("deleted entry not found");755  entries.splice(idx, 1);756  await persistEntries();757}758759/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */760export async function purgeExpiredTrash(retentionMs: number): Promise<number> {761  if (!derivedKey || retentionMs === 0) return 0;762  const cutoff = Date.now() - retentionMs;763  const before = entries.length;764  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));765  const purged = before - entries.length;766  if (purged > 0) await persistEntries();767  return purged;768}769770export function queryDomain(domain: string): VaultEntry[] {771  return domainIndex.get(domain) ?? [];772}773774export function getEntryById(id: string): VaultEntry | undefined {775  return entries.find((entry) => entry.id === id && !entry.deletedAt);776}777778async function persistSlotA(): Promise<StoredVault> {779  if (!derivedKey) throw new Error("vault locked");780781  const slotA = await loadSlot(SLOT_A);782  if (!slotA) throw new Error("missing slot A");783784  const plaintext = encodePayload(entries, duressConfigured);785  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);786787  const nextSlotA: StoredVault = {788    ...slotA,789    nonce,790    ciphertext,791    tag,792  };793  await writeSlot(SLOT_A, nextSlotA);794795  // When duress is not configured, keep slot B size-matched with slot A by796  // regenerating its noise. When duress IS configured, leave slot B alone —797  // we don't have the decoy key to re-encrypt it. This is a known trade-off.798  if (!duressConfigured) {799    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));800  }801802  return nextSlotA;803}804805async function persistSlotB(): Promise<void> {806  if (!derivedKey) throw new Error("vault locked");807808  const slotB = await loadSlot(SLOT_B);809  if (!slotB) throw new Error("missing slot B");810811  // Decoy plaintext always reports duressConfigured=false — the decoy user812  // sees a clean slate.813  const plaintext = encodePayload(entries, false);814  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);815816  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });817}818819async function persistEntries(): Promise<void> {820  if (!derivedKey) throw new Error("vault locked");821822  if (vaultMode === "decoy") {823    await persistSlotB();824  } else {825    const freshSlotA = await persistSlotA();826    // Save a snapshot of the real vault after every successful write.827    // Failures are silently swallowed — snapshots must never interrupt saves.828    const activeCount = entries.filter((e) => !e.deletedAt).length;829    void takeSnapshot(freshSlotA, activeCount);830  }831  buildDomainIndexSync(entries);832}833834function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {835  const title = entry.title.trim();836  const type = entry.type ?? "login";837838  if (!title) throw new Error("title is required");839  if (type === "login" && !entry.password?.trim()) {840    throw new Error("password is required");841  }842  if (type === "passkey") {843    if (844      !entry.rpId?.trim() ||845      !entry.rpName?.trim() ||846      !entry.credentialId?.trim() ||847      !entry.publicKeyCose?.trim() ||848      !entry.privateKeyJwk ||849      !entry.userHandle?.trim() ||850      !entry.userName?.trim() ||851      !entry.userDisplayName?.trim() ||852      (entry.algorithm !== -7 && entry.algorithm !== -8)853    ) {854      throw new Error("invalid passkey entry");855    }856  }857858  return {859    type,860    title,861    username: type === "login" ? entry.username?.trim() ?? "" : undefined,862    password: type === "login" ? entry.password : undefined,863    url: type === "login" ? entry.url?.trim() ?? "" : undefined,864    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,865    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,866    breach: type === "login" ? entry.breach : undefined,867    canary: type === "login" ? entry.canary : undefined,868    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,869    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,870    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,871    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,872    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,873    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,874    userName: type === "passkey" ? entry.userName?.trim() : undefined,875    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,876    algorithm: type === "passkey" ? entry.algorithm : undefined,877    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,878    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,879    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,880    backupState: type === "passkey" ? entry.backupState === true : undefined,881    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,882  };883}884885function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {886  const nextPatch: Partial<VaultEntry> = {};887888  if ("type" in patch) {889    nextPatch.type = patch.type;890  }891892  if ("title" in patch) {893    const title = patch.title?.trim() ?? "";894    if (!title) throw new Error("title is required");895    nextPatch.title = title;896  }897898  if ("url" in patch) {899    nextPatch.url = patch.url?.trim() ?? "";900  }901902  if ("username" in patch) {903    nextPatch.username = patch.username?.trim() ?? "";904  }905906  if ("password" in patch) {907    const password = patch.password ?? "";908    // Allow clearing a password (e.g. on passkey-only entries)909    nextPatch.password = password || undefined;910  }911912  if ("totp" in patch) {913    nextPatch.totp = patch.totp?.trim() || undefined;914  }915916  if ("notes" in patch) {917    nextPatch.notes = patch.notes?.trim() || undefined;918  }919920  if ("breach" in patch) {921    nextPatch.breach = patch.breach;922  }923924  if ("canary" in patch) {925    nextPatch.canary = patch.canary;926  }927928  if ("rpId" in patch) {929    nextPatch.rpId = patch.rpId?.trim();930  }931932  if ("rpName" in patch) {933    nextPatch.rpName = patch.rpName?.trim();934  }935936  if ("credentialId" in patch) {937    nextPatch.credentialId = patch.credentialId?.trim();938  }939940  if ("publicKeyCose" in patch) {941    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();942  }943944  if ("privateKeyJwk" in patch) {945    nextPatch.privateKeyJwk = patch.privateKeyJwk;946  }947948  if ("userHandle" in patch) {949    nextPatch.userHandle = patch.userHandle?.trim();950  }951952  if ("userName" in patch) {953    nextPatch.userName = patch.userName?.trim();954  }955956  if ("userDisplayName" in patch) {957    nextPatch.userDisplayName = patch.userDisplayName?.trim();958  }959960  if ("algorithm" in patch) {961    nextPatch.algorithm = patch.algorithm;962  }963964  if ("signCount" in patch) {965    nextPatch.signCount = patch.signCount;966  }967968  if ("aaguid" in patch) {969    nextPatch.aaguid = patch.aaguid?.trim();970  }971972  if ("backupEligible" in patch) {973    nextPatch.backupEligible = patch.backupEligible;974  }975976  if ("backupState" in patch) {977    nextPatch.backupState = patch.backupState;978  }979980  if ("lastUsedAt" in patch) {981    nextPatch.lastUsedAt = patch.lastUsedAt;982  }983984  return nextPatch;985}986987function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {988  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;989  const modifiedAt =990    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;991992  return {993    id: entry.id,994    type: "type" in entry ? entry.type : "login",995    title: entry.title,996    username: entry.username,997    password: entry.password,998    url: entry.url,999    totp: entry.totp,1000    notes: entry.notes,1001    breach: entry.breach,1002    canary: "canary" in entry ? entry.canary : undefined,1003    rpId: "rpId" in entry ? entry.rpId : undefined,1004    rpName: "rpName" in entry ? entry.rpName : undefined,1005    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1006    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1007    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1008    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1009    userName: "userName" in entry ? entry.userName : undefined,1010    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1011    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1012    signCount: "signCount" in entry ? entry.signCount : undefined,1013    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1014    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1015    backupState: "backupState" in entry ? entry.backupState : undefined,1016    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1017    createdAt,1018    modifiedAt,1019    deletedAt: entry.deletedAt,1020    tags: "tags" in entry ? entry.tags : undefined,1021  };1022}10231024function hasLegacyShape(parsed: VaultEntry[]): boolean {1025  return parsed.some(1026    (entry) => "created" in entry || "updated" in entry1027  );1028}10291030// ---------------------------------------------------------------------------1031// Deduplication1032// ---------------------------------------------------------------------------10331034function entryDupeKey(e: VaultEntry): string {1035  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1036  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1037  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1038}10391040/** Returns the number of duplicate entries (those that would be removed). */1041export function countDuplicates(): number {1042  const best = new Map<string, VaultEntry>();1043  let dupeCount = 0;1044  for (const e of entries.filter((e) => !e.deletedAt)) {1045    const key = entryDupeKey(e);1046    const existing = best.get(key);1047    if (!existing) {1048      best.set(key, e);1049    } else {1050      dupeCount++;1051      // Keep the more recently modified one in the map1052      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1053        best.set(key, e);1054      }1055    }1056  }1057  return dupeCount;1058}10591060/**1061 * Soft-deletes duplicate entries, keeping the most recently modified copy of1062 * each group. Returns the number of entries moved to trash.1063 */1064export async function deduplicateEntries(): Promise<number> {1065  if (!derivedKey) throw new Error("vault locked");1066  // Dedup reshapes slot B permanently under the decoy key, which is1067  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1068  if (vaultMode === "decoy") {1069    throw new Error("deduplicate disabled in decoy mode");1070  }10711072  const best = new Map<string, VaultEntry>();1073  for (const e of entries.filter((en) => !en.deletedAt)) {1074    const key = entryDupeKey(e);1075    const existing = best.get(key);1076    if (!existing) {1077      best.set(key, e);1078    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1079      best.set(key, e);1080    }1081  }10821083  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1084  const now = Date.now();1085  let removed = 0;1086  entries = entries.map((e) => {1087    if (!e.deletedAt && !keepIds.has(e.id)) {1088      removed++;1089      return { ...e, deletedAt: now };1090    }1091    return e;1092  });10931094  if (removed > 0) await persistEntries();1095  return removed;1096}10971098// ---------------------------------------------------------------------------1099// Vault snapshot system — last 6 encrypted states, used for self-recovery1100// ---------------------------------------------------------------------------11011102const SNAPSHOT_KEY = "peach_vault_snapshots";1103const MAX_SNAPSHOTS = 6;11041105interface VaultSnapshot {1106  timestamp: number;1107  entryCount: number;1108  slot: StoredVault;1109}11101111/** Public summary (no sensitive data) returned to the popup. */1112export interface SnapshotSummary {1113  index: number;1114  timestamp: number;1115  entryCount: number;1116}11171118async function takeSnapshot(1119  slot: StoredVault,1120  entryCount: number1121): Promise<void> {1122  try {1123    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1124    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1125    const next: VaultSnapshot[] = [1126      { timestamp: Date.now(), entryCount, slot },1127      ...existing,1128    ].slice(0, MAX_SNAPSHOTS);1129    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1130  } catch {1131    // Non-fatal — snapshot failure must never interrupt vault saves1132  }1133}11341135export async function listSnapshots(): Promise<SnapshotSummary[]> {1136  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1137  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1138  return snapshots.map((s, i) => ({1139    index: i,1140    timestamp: s.timestamp,1141    entryCount: s.entryCount,1142  }));1143}11441145/**1146 * Restore a snapshot by index. The password must decrypt that snapshot1147 * (i.e., the same master password used when the snapshot was taken).1148 * On success the vault is unlocked in-memory with the restored entries1149 * — no re-login required.1150 */1151export async function restoreFromSnapshot(1152  index: number,1153  password: string1154): Promise<boolean> {1155  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1156  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1157  const snapshot = snapshots[index];1158  if (!snapshot) return false;11591160  // Verify the password decrypts this snapshot1161  const key = await deriveKey(password, unhex(snapshot.slot.salt));1162  const payload = await tryDecryptSlot(snapshot.slot, key);1163  if (!payload) {1164    zeroKey(key);1165    return false;1166  }11671168  // Write the snapshot back to slot A. Regenerate noise for slot B.1169  await writeSlot(SLOT_A, snapshot.slot);1170  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));11711172  // Swap in-memory state to the restored vault — caller gets immediate access1173  if (derivedKey) zeroKey(derivedKey);1174  derivedKey = key;1175  entries = payload.entries;1176  duressConfigured = payload.duressConfigured;1177  vaultMode = "real";1178  buildDomainIndexSync(entries);1179  await loadSettings();1180  touchAutoLock();11811182  return true;1183}11841185/**1186 * Change the master password. Re-encrypts slot A with a fresh salt + new1187 * derived key. Slot B (duress or noise) is left untouched when duress is1188 * configured — it uses an independent key derived from the duress password —1189 * and regenerated as noise when duress is not configured.1190 */1191export async function changePassword(1192  currentPassword: string,1193  newPassword: string1194): Promise<boolean> {1195  if (!derivedKey || vaultMode !== "real") return false;11961197  // Verify current password before allowing any change1198  const valid = await verifyMainPassword(currentPassword);1199  if (!valid) return false;12001201  const slotA = await loadSlot(SLOT_A);1202  if (!slotA) return false;12031204  // Derive fresh key from new password + new random salt1205  const newSalt = generateSalt();1206  const newKey = await deriveKey(newPassword, newSalt);12071208  // Re-encrypt the in-memory entries with the new key1209  const plaintext = encodePayload(entries, duressConfigured);1210  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);12111212  const freshSlotA: StoredVault = {1213    ...slotA,1214    salt: hex(newSalt),1215    nonce,1216    ciphertext,1217    tag,1218  };1219  await writeSlot(SLOT_A, freshSlotA);12201221  // Swap the in-memory key synchronously before any further await. If we1222  // threw between the Slot A write and this swap, the next persistEntries()1223  // would overwrite Slot A with old-key ciphertext, locking the user out.1224  zeroKey(derivedKey);1225  derivedKey = newKey;12261227  // Snapshot now that in-memory key and disk are consistent. Pass the1228  // slot content we just wrote rather than re-reading from storage so the1229  // snapshot is immune to any microtask-coalesced read-after-write race.1230  const activeCount = entries.filter((e) => !e.deletedAt).length;1231  void takeSnapshot(freshSlotA, activeCount);12321233  // Regenerate noise for slot B when duress is not in use. Runs after the1234  // key swap so a failure here is a disposable noise regeneration, not a1235  // lockout.1236  if (!duressConfigured) {1237    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1238  }12391240  return true;1241}