vault.ts

38.8 KB · raw

1import { deriveKey, generateSalt } from "../crypto/argon2";2import { extractDomain } from "../shared/domain";3import type {4  AccentColorName,5  LegacyVaultEntry,6  ManualSummonRecord,7  StoredVault,8  VaultEntry,9} from "../shared/types";10import { decrypt, encrypt, zeroKey } from "./aes";11import { clearAutoLock, touchAutoLock } from "./alarms";1213type EntryDraft = Omit<14  VaultEntry,15  "id" | "createdAt" | "modifiedAt" | "deletedAt"16>;1718// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B19// either holds the decoy vault (when duress is configured) or random noise20// that looks identical at rest. No external indicator is stored — duress21// configuration is recorded only inside the encrypted payload of slot A.22const SLOT_A = "peach_vault_a";23const SLOT_B = "peach_vault_b";2425// Legacy keys, migrated away on first access.26const LEGACY_MAIN = "peach_vault";27const LEGACY_DECOY = "peach_decoy_vault";28const LEGACY_DURESS_FLAG = "peach_duress";2930// Plaintext is padded to the next multiple of this size before encryption.31// This coarsens ciphertext-length leakage into 1 KiB buckets.32const PAD_BLOCK = 1024;3334let derivedKey: Uint8Array | null = null;35let entries: VaultEntry[] = [];36let domainIndex: Map<string, VaultEntry[]> = new Map();37let vaultMode: "real" | "decoy" = "real";38// Cached post-unlock view of whether the real vault records that duress is39// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock40// this stays false because we never see slot A's plaintext.41let duressConfigured = false;4243let migrationRan = false;4445// ---------------------------------------------------------------------------46// Settings (persisted inside the encrypted vault store as a sidecar key)47// ---------------------------------------------------------------------------4849export interface VaultSettings {50  accentColor?: AccentColorName;51  theme?: "dark" | "light";52  autoLockMinutes?: number;53  clipboardClearSeconds?: number;54  lockOnClose?: boolean;55  breachMonitoring?: boolean;56  passkeyProviderEnabled?: boolean;57  trashRetentionDays?: number;58  manualSummonDomains?: Record<string, ManualSummonRecord>;59}6061let settings: VaultSettings = {};6263export function getSettings(): VaultSettings {64  return settings;65}6667export async function ensureSettingsLoaded(): Promise<VaultSettings> {68  await loadSettings();69  return settings;70}7172export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {73  await loadSettings();74  settings = { ...settings, ...patch };75  await persistSettings();76}7778async function persistSettings(): Promise<void> {79  try {80    await chrome.storage.local.set({ peach_settings: settings });81  } catch (err) {82    throw mapStorageError(err);83  }84}8586function mapStorageError(err: unknown): Error {87  const message = err instanceof Error ? err.message : String(err);88  if (/QUOTA_BYTES|quota/i.test(message)) {89    return new Error("STORAGE_QUOTA_EXCEEDED");90  }91  return err instanceof Error ? err : new Error(message);92}9394async function loadSettings(): Promise<void> {95  const result = await chrome.storage.local.get("peach_settings");96  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};97}9899// ---------------------------------------------------------------------------100// HIBP breach check (k-anonymity model)101// ---------------------------------------------------------------------------102103export async function checkBreach(password: string): Promise<number> {104  const encoder = new TextEncoder();105  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));106  const hashHex = Array.from(new Uint8Array(hashBuffer))107    .map((b) => b.toString(16).padStart(2, "0"))108    .join("")109    .toUpperCase();110111  const prefix = hashHex.slice(0, 5);112  const suffix = hashHex.slice(5);113114  try {115    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);116    if (!res.ok) return 0;117    const text = await res.text();118    const match = text.split("\r\n").find((l) => l.startsWith(suffix));119    return match ? parseInt(match.split(":")[1], 10) : 0;120  } catch {121    return 0; // Network unavailable — don't block122  }123}124125function hex(buf: Uint8Array): string {126  return Array.from(buf)127    .map((b) => b.toString(16).padStart(2, "0"))128    .join("");129}130131function unhex(s: string): Uint8Array {132  const out = new Uint8Array(s.length / 2);133  for (let i = 0; i < s.length; i += 2) {134    out[i / 2] = parseInt(s.slice(i, i + 2), 16);135  }136  return out;137}138139// ---------------------------------------------------------------------------140// Storage schema migration141// ---------------------------------------------------------------------------142143async function loadSlot(key: string): Promise<StoredVault | null> {144  const result = await chrome.storage.local.get(key);145  return (result as Record<string, StoredVault | undefined>)[key] ?? null;146}147148async function writeSlot(key: string, vault: StoredVault): Promise<void> {149  try {150    await chrome.storage.local.set({ [key]: vault });151  } catch (err) {152    throw mapStorageError(err);153  }154}155156/**157 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault158 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +159 * peach_vault_b). Runs at most once per session; safe to call repeatedly.160 */161async function ensureMigrated(): Promise<void> {162  if (migrationRan) return;163  migrationRan = true;164165  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];166  const raw = await chrome.storage.local.get(keys);167  const slotAExists = raw[SLOT_A] !== undefined;168  const slotBExists = raw[SLOT_B] !== undefined;169  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;170  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;171172  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.173  if (!slotAExists && !legacyMain) return;174175  // Already migrated.176  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;177178  if (legacyMain && !slotAExists) {179    await writeSlot(SLOT_A, legacyMain);180  }181182  // If slot B isn't set, populate it: use legacy decoy when present, else noise.183  if (!slotBExists) {184    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;185    if (legacyDecoy) {186      await writeSlot(SLOT_B, legacyDecoy);187    } else if (target) {188      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));189    }190  }191192  const toRemove: string[] = [];193  if (legacyMain) toRemove.push(LEGACY_MAIN);194  if (legacyDecoy) toRemove.push(LEGACY_DECOY);195  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);196  if (toRemove.length > 0) {197    await chrome.storage.local.remove(toRemove);198  }199}200201// ---------------------------------------------------------------------------202// Padding + noise helpers203// ---------------------------------------------------------------------------204205/**206 * Pads the plaintext string with trailing spaces so that the encoded byte207 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing208 * whitespace after a complete document, so the padding is transparent on209 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.210 */211function padPlaintext(json: string): string {212  const encoded = new TextEncoder().encode(json);213  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);214  const padLen = target - encoded.length;215  return json + " ".repeat(padLen);216}217218/**219 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The220 * ciphertext length matches the given byte count. Because GCM ciphertext is221 * pseudo-random, this blob is indistinguishable at rest from a real222 * encrypted vault without the key.223 */224function generateNoiseBlob(ciphertextBytes: number): StoredVault {225  const salt = crypto.getRandomValues(new Uint8Array(16));226  const nonce = crypto.getRandomValues(new Uint8Array(12));227  const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));228  const tag = crypto.getRandomValues(new Uint8Array(16));229  return {230    version: 1,231    salt: hex(salt),232    nonce: hex(nonce),233    ciphertext: hex(cipher),234    tag: hex(tag),235  };236}237238// ---------------------------------------------------------------------------239// Encrypted plaintext envelope240// ---------------------------------------------------------------------------241242type VaultPayload = {243  entries: VaultEntry[];244  duressConfigured?: boolean;245};246247type DecryptedPayload = {248  entries: VaultEntry[];249  duressConfigured: boolean;250};251252function encodePayload(entries: VaultEntry[], duress: boolean): string {253  const envelope: VaultPayload = { entries, duressConfigured: duress };254  return padPlaintext(JSON.stringify(envelope));255}256257function decodePayload(plain: string): DecryptedPayload {258  const parsed = JSON.parse(plain) as259    | Array<VaultEntry | LegacyVaultEntry>260    | VaultPayload;261262  if (Array.isArray(parsed)) {263    // Pre-A-2 plaintext: bare array of entries, no duress flag.264    return {265      entries: parsed.map(normalizeEntry),266      duressConfigured: false,267    };268  }269270  return {271    entries: (parsed.entries ?? []).map(normalizeEntry),272    duressConfigured: parsed.duressConfigured === true,273  };274}275276async function tryDecryptSlot(277  vault: StoredVault,278  key: Uint8Array279): Promise<DecryptedPayload | null> {280  try {281    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);282    return decodePayload(plain);283  } catch {284    return null;285  }286}287288// ---------------------------------------------------------------------------289// Core operations290// ---------------------------------------------------------------------------291292function buildDomainIndexSync(all: VaultEntry[]): void {293  domainIndex = new Map();294295  for (const entry of all) {296    if (entry.deletedAt) continue;297    if (entry.type === "note" || entry.type === "passkey") continue;298299    const domain = extractDomain(entry.url ?? "");300    if (!domain) continue;301302    const bucket = domainIndex.get(domain) ?? [];303    bucket.push(entry);304    domainIndex.set(domain, bucket);305  }306}307308export async function createVault(password: string): Promise<void> {309  await ensureMigrated();310311  const salt = generateSalt();312  derivedKey = await deriveKey(password, salt);313  entries = [];314  vaultMode = "real";315  duressConfigured = false;316  buildDomainIndexSync(entries);317318  const plaintext = encodePayload(entries, duressConfigured);319  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);320321  const slotA: StoredVault = {322    version: 1,323    salt: hex(salt),324    nonce,325    ciphertext,326    tag,327  };328  await writeSlot(SLOT_A, slotA);329330  // Matching-size noise blob for slot B, so a fresh install shows two331  // similarly-shaped records regardless of whether duress is ever set up.332  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));333334  touchAutoLock();335}336337export async function hasVault(): Promise<boolean> {338  await ensureMigrated();339  return (await loadSlot(SLOT_A)) !== null;340}341342/**343 * Returns whether the real vault has recorded that a duress passphrase is344 * configured. Reliable only after a real-mode unlock — before unlock or in345 * decoy mode, returns false because we cannot read slot A's plaintext.346 */347export async function isDuressEnabled(): Promise<boolean> {348  return vaultMode === "real" && duressConfigured;349}350351export async function hasDecoyVault(): Promise<boolean> {352  return isDuressEnabled();353}354355export async function setupDuress(duressPassword: string): Promise<void> {356  if (!derivedKey || vaultMode !== "real") {357    throw new Error("vault must be unlocked in real mode");358  }359  await ensureMigrated();360361  // Encrypt the decoy payload (empty entries) under the duress key. Pad to362  // at least the current slot A ciphertext size so the two slots look the363  // same at rest at setup time.364  const slotA = await loadSlot(SLOT_A);365  if (!slotA) throw new Error("missing slot A");366367  const decoySalt = generateSalt();368  const decoyKey = await deriveKey(duressPassword, decoySalt);369  try {370    // Pad the decoy plaintext to match slot A's ciphertext length (minus the371    // 16-byte tag) so encrypted sizes align exactly.372    const targetBytes = slotA.ciphertext.length / 2;373    let plaintext = encodePayload([], false);374    const encoded = new TextEncoder().encode(plaintext);375    if (encoded.length < targetBytes) {376      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);377    }378    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);379    await writeSlot(SLOT_B, {380      version: 1,381      salt: hex(decoySalt),382      nonce,383      ciphertext,384      tag,385    });386  } finally {387    zeroKey(decoyKey);388  }389390  // Re-encrypt slot A with the duressConfigured flag set to true. No external391  // flag is written — the bit lives inside the encrypted envelope.392  duressConfigured = true;393  await persistSlotA();394}395396export async function disableDuress(): Promise<void> {397  if (!derivedKey || vaultMode !== "real") {398    throw new Error("vault must be unlocked in real mode");399  }400  await ensureMigrated();401402  // Overwrite slot B with fresh noise that matches slot A's size. The slot403  // remains occupied so its mere existence does not signal that duress was404  // ever configured.405  const slotA = await loadSlot(SLOT_A);406  if (slotA) {407    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));408  }409410  duressConfigured = false;411  await persistSlotA();412}413414export async function verifyMainPassword(password: string): Promise<boolean> {415  await ensureMigrated();416  const slotA = await loadSlot(SLOT_A);417  if (!slotA) return false;418  const key = await deriveKey(password, unhex(slotA.salt));419  try {420    return (await tryDecryptSlot(slotA, key)) !== null;421  } finally {422    zeroKey(key);423  }424}425426export async function unlock(password: string): Promise<boolean> {427  await ensureMigrated();428429  // Load both slots in parallel. Both always exist post-migration; the430  // parallel load and the parallel derivation below keep wall-clock time431  // constant regardless of which slot's passphrase (real or decoy) the432  // caller supplied, so a timing side channel cannot distinguish the433  // duress path from the real-unlock path (DUR-002).434  const [slotA, slotB] = await Promise.all([435    loadSlot(SLOT_A),436    loadSlot(SLOT_B),437  ]);438439  // Derive both keys in parallel, unconditionally.440  const [keyA, keyB] = await Promise.all([441    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),442    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),443  ]);444445  // Attempt both decrypts in parallel.446  const [payloadA, payloadB] = await Promise.all([447    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),448    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),449  ]);450451  if (payloadA && keyA) {452    derivedKey = keyA;453    vaultMode = "real";454    entries = payloadA.entries;455    duressConfigured = payloadA.duressConfigured;456    buildDomainIndexSync(entries);457    await loadSettings();458    touchAutoLock();459    if (keyB) zeroKey(keyB);460    // If the payload came from the pre-envelope (legacy) shape, rewrite461    // it immediately so the next save uses the new format.462    if (hasLegacyShape(payloadA.entries)) {463      await persistSlotA();464    }465    return true;466  }467468  if (payloadB && keyB) {469    derivedKey = keyB;470    vaultMode = "decoy";471    entries = payloadB.entries;472    duressConfigured = false;473    buildDomainIndexSync(entries);474    await loadSettings();475    touchAutoLock();476    if (keyA) zeroKey(keyA);477    return true;478  }479480  if (keyA) zeroKey(keyA);481  if (keyB) zeroKey(keyB);482  return false;483}484485export function lock(): void {486  if (derivedKey) {487    zeroKey(derivedKey);488    derivedKey = null;489  }490491  entries = [];492  domainIndex = new Map();493  vaultMode = "real";494  duressConfigured = false;495  clearAutoLock();496}497498export function isUnlocked(): boolean {499  return derivedKey !== null;500}501502export function getEntries(): VaultEntry[] {503  if (!derivedKey) throw new Error("vault locked");504  return entries.filter((entry) => !entry.deletedAt);505}506507export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {508  if (!derivedKey) throw new Error("vault locked");509510  const now = Date.now();511  const added: VaultEntry[] = [];512  for (const draft of drafts) {513    try {514      const sanitized = sanitizeEntryDraft(draft);515      const nextEntry: VaultEntry = {516        ...sanitized,517        id: crypto.randomUUID(),518        createdAt: now,519        modifiedAt: now,520      };521      entries.push(nextEntry);522      added.push(nextEntry);523    } catch {524      // Skip invalid drafts silently — caller can see diff between input and output length525    }526  }527528  if (added.length > 0) {529    await persistEntries();530  }531532  return added;533}534535/**536 * Restore a set of entries in full fidelity — preserves id, createdAt,537 * modifiedAt, and every other field unlike `batchAddEntries` which538 * assigns fresh identity. Used by the CJK phrase restore path where the539 * backup is the authoritative source of truth. `mode` controls how540 * entries already in the vault are treated.541 */542export async function restoreEntries(543  incoming: VaultEntry[],544  mode: "replace" | "merge"545): Promise<{ added: number; updated: number; skipped: number }> {546  if (!derivedKey) throw new Error("vault locked");547548  // A replace with zero incoming entries is almost certainly a caller bug549  // (e.g. an import that silently dropped everything). Refuse rather than550  // wipe the vault.551  if (mode === "replace" && incoming.length === 0) {552    throw new Error("refuse to replace with empty entry set");553  }554555  let added = 0;556  let updated = 0;557  let skipped = 0;558559  if (mode === "replace") {560    entries = [];561    for (const entry of incoming) {562      const normalized = sanitizeRestoredEntry(entry);563      if (!normalized) {564        skipped += 1;565        continue;566      }567      entries.push(normalized);568      added += 1;569    }570  } else {571    const byId = new Map<string, VaultEntry>();572    for (const e of entries) byId.set(e.id, e);573574    for (const entry of incoming) {575      const normalized = sanitizeRestoredEntry(entry);576      if (!normalized) {577        skipped += 1;578        continue;579      }580      const existing = byId.get(normalized.id);581      if (!existing) {582        entries.push(normalized);583        added += 1;584      } else if (normalized.modifiedAt > existing.modifiedAt) {585        const idx = entries.findIndex((e) => e.id === existing.id);586        if (idx !== -1) {587          entries[idx] = normalized;588          updated += 1;589        }590      } else {591        skipped += 1;592      }593    }594  }595596  await persistEntries();597  return { added, updated, skipped };598}599600/**601 * Validate + normalise a VaultEntry coming from a backup. Accepts602 * anything structurally sound; keeps all metadata verbatim. Returns603 * null for values the live vault shouldn't accept (e.g., missing title).604 */605function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {606  if (!entry || typeof entry !== "object") return null;607  const title = typeof entry.title === "string" ? entry.title.trim() : "";608  if (!title) return null;609  const type = entry.type ?? "login";610  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();611  const now = Date.now();612  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;613  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;614615  if (type === "login" && typeof entry.password !== "string") {616    return null;617  }618  if (619    type === "passkey" &&620    (typeof entry.rpId !== "string" ||621      typeof entry.credentialId !== "string" ||622      !entry.privateKeyJwk)623  ) {624    return null;625  }626627  return {628    ...entry,629    id,630    title,631    type,632    createdAt,633    modifiedAt,634  };635}636637export async function clearVaultEntries(638  confirmation: "REPLACE_ALL"639): Promise<void> {640  if (confirmation !== "REPLACE_ALL") {641    throw new Error("clearVaultEntries requires explicit confirmation");642  }643  if (!derivedKey) throw new Error("vault locked");644  for (const entry of entries) {645    if (!entry.deletedAt) {646      entry.deletedAt = Date.now();647      entry.modifiedAt = Date.now();648    }649  }650  await persistEntries();651}652653export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {654  if (!derivedKey) throw new Error("vault locked");655656  const sanitized = sanitizeEntryDraft(entry);657  const now = Date.now();658  const nextEntry: VaultEntry = {659    ...sanitized,660    id: crypto.randomUUID(),661    createdAt: now,662    modifiedAt: now,663  };664665  entries.push(nextEntry);666  await persistEntries();667668  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)669  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {670    checkBreach(nextEntry.password).then(async (count) => {671      if (count > 0) {672        const idx = entries.findIndex((e) => e.id === nextEntry.id);673        if (idx !== -1) {674          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };675          await persistEntries();676        }677      }678    });679  }680681  return nextEntry;682}683684export async function updateEntry(685  id: string,686  patch: Partial<VaultEntry>687): Promise<void> {688  if (!derivedKey) throw new Error("vault locked");689690  const idx = entries.findIndex((entry) => entry.id === id);691  if (idx === -1 || entries[idx].deletedAt) {692    throw new Error("entry not found");693  }694695  const current = entries[idx];696  entries[idx] = {697    ...current,698    ...sanitizeEntryPatch(patch),699    modifiedAt: Date.now(),700  };701702  // sanitizeEntryPatch maps an empty password to `undefined`. For login703  // entries that would silently produce a passwordless record — roll back704  // the merge and refuse.705  if (entries[idx].type === "login" && !entries[idx].password) {706    entries[idx] = current;707    throw new Error("password is required for login entries");708  }709710  await persistEntries();711712  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)713  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {714    const password = entries[idx].password!;715    checkBreach(password).then(async (count) => {716      const i = entries.findIndex((e) => e.id === id);717      if (i !== -1) {718        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };719        await persistEntries();720      }721    });722  }723}724725export async function deleteEntry(id: string): Promise<void> {726  if (!derivedKey) throw new Error("vault locked");727728  const entry = entries.find((item) => item.id === id);729  if (!entry || entry.deletedAt) {730    throw new Error("entry not found");731  }732733  entry.deletedAt = Date.now();734  entry.modifiedAt = Date.now();735  await persistEntries();736}737738export function getDeletedEntries(): VaultEntry[] {739  if (!derivedKey) return [];740  return entries.filter((e) => !!e.deletedAt);741}742743export async function restoreEntry(id: string): Promise<void> {744  if (!derivedKey) throw new Error("vault locked");745  const entry = entries.find((e) => e.id === id && e.deletedAt);746  if (!entry) throw new Error("deleted entry not found");747  delete entry.deletedAt;748  entry.modifiedAt = Date.now();749  await persistEntries();750  buildDomainIndexSync(entries);751}752753export async function purgeEntry(id: string): Promise<void> {754  if (!derivedKey) throw new Error("vault locked");755  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);756  if (idx === -1) throw new Error("deleted entry not found");757  entries.splice(idx, 1);758  await persistEntries();759}760761/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */762export async function purgeExpiredTrash(retentionMs: number): Promise<number> {763  if (!derivedKey || retentionMs === 0) return 0;764  const cutoff = Date.now() - retentionMs;765  const before = entries.length;766  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));767  const purged = before - entries.length;768  if (purged > 0) await persistEntries();769  return purged;770}771772export function queryDomain(domain: string): VaultEntry[] {773  return domainIndex.get(domain) ?? [];774}775776export function getEntryById(id: string): VaultEntry | undefined {777  return entries.find((entry) => entry.id === id && !entry.deletedAt);778}779780async function persistSlotA(): Promise<StoredVault> {781  if (!derivedKey) throw new Error("vault locked");782783  const slotA = await loadSlot(SLOT_A);784  if (!slotA) throw new Error("missing slot A");785786  const plaintext = encodePayload(entries, duressConfigured);787  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);788789  const nextSlotA: StoredVault = {790    ...slotA,791    nonce,792    ciphertext,793    tag,794  };795  await writeSlot(SLOT_A, nextSlotA);796797  // When duress is not configured, keep slot B size-matched with slot A by798  // regenerating its noise. When duress IS configured, leave slot B alone —799  // we don't have the decoy key to re-encrypt it. This is a known trade-off.800  if (!duressConfigured) {801    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));802  }803804  return nextSlotA;805}806807async function persistSlotB(): Promise<void> {808  if (!derivedKey) throw new Error("vault locked");809810  const slotB = await loadSlot(SLOT_B);811  if (!slotB) throw new Error("missing slot B");812813  // Decoy plaintext always reports duressConfigured=false — the decoy user814  // sees a clean slate.815  const plaintext = encodePayload(entries, false);816  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);817818  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });819}820821async function persistEntries(): Promise<void> {822  if (!derivedKey) throw new Error("vault locked");823824  if (vaultMode === "decoy") {825    await persistSlotB();826  } else {827    const freshSlotA = await persistSlotA();828    // Save a snapshot of the real vault after every successful write.829    // Failures are silently swallowed — snapshots must never interrupt saves.830    const activeCount = entries.filter((e) => !e.deletedAt).length;831    void takeSnapshot(freshSlotA, activeCount);832  }833  buildDomainIndexSync(entries);834}835836function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {837  const title = entry.title.trim();838  const type = entry.type ?? "login";839840  if (!title) throw new Error("title is required");841  if (type === "login" && !entry.password?.trim()) {842    throw new Error("password is required");843  }844  if (type === "passkey") {845    if (846      !entry.rpId?.trim() ||847      !entry.rpName?.trim() ||848      !entry.credentialId?.trim() ||849      !entry.publicKeyCose?.trim() ||850      !entry.privateKeyJwk ||851      !entry.userHandle?.trim() ||852      !entry.userName?.trim() ||853      !entry.userDisplayName?.trim() ||854      (entry.algorithm !== -7 && entry.algorithm !== -8)855    ) {856      throw new Error("invalid passkey entry");857    }858  }859860  return {861    type,862    title,863    username: type === "login" ? entry.username?.trim() ?? "" : undefined,864    password: type === "login" ? entry.password : undefined,865    url: type === "login" ? entry.url?.trim() ?? "" : undefined,866    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,867    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,868    breach: type === "login" ? entry.breach : undefined,869    canary: type === "login" ? entry.canary : undefined,870    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,871    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,872    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,873    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,874    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,875    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,876    userName: type === "passkey" ? entry.userName?.trim() : undefined,877    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,878    algorithm: type === "passkey" ? entry.algorithm : undefined,879    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,880    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,881    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,882    backupState: type === "passkey" ? entry.backupState === true : undefined,883    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,884  };885}886887function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {888  const nextPatch: Partial<VaultEntry> = {};889890  if ("type" in patch) {891    nextPatch.type = patch.type;892  }893894  if ("title" in patch) {895    const title = patch.title?.trim() ?? "";896    if (!title) throw new Error("title is required");897    nextPatch.title = title;898  }899900  if ("url" in patch) {901    nextPatch.url = patch.url?.trim() ?? "";902  }903904  if ("username" in patch) {905    nextPatch.username = patch.username?.trim() ?? "";906  }907908  if ("password" in patch) {909    const password = patch.password ?? "";910    // Allow clearing a password (e.g. on passkey-only entries)911    nextPatch.password = password || undefined;912  }913914  if ("totp" in patch) {915    nextPatch.totp = patch.totp?.trim() || undefined;916  }917918  if ("notes" in patch) {919    nextPatch.notes = patch.notes?.trim() || undefined;920  }921922  if ("breach" in patch) {923    nextPatch.breach = patch.breach;924  }925926  if ("canary" in patch) {927    nextPatch.canary = patch.canary;928  }929930  if ("rpId" in patch) {931    nextPatch.rpId = patch.rpId?.trim();932  }933934  if ("rpName" in patch) {935    nextPatch.rpName = patch.rpName?.trim();936  }937938  if ("credentialId" in patch) {939    nextPatch.credentialId = patch.credentialId?.trim();940  }941942  if ("publicKeyCose" in patch) {943    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();944  }945946  if ("privateKeyJwk" in patch) {947    nextPatch.privateKeyJwk = patch.privateKeyJwk;948  }949950  if ("userHandle" in patch) {951    nextPatch.userHandle = patch.userHandle?.trim();952  }953954  if ("userName" in patch) {955    nextPatch.userName = patch.userName?.trim();956  }957958  if ("userDisplayName" in patch) {959    nextPatch.userDisplayName = patch.userDisplayName?.trim();960  }961962  if ("algorithm" in patch) {963    nextPatch.algorithm = patch.algorithm;964  }965966  if ("signCount" in patch) {967    nextPatch.signCount = patch.signCount;968  }969970  if ("aaguid" in patch) {971    nextPatch.aaguid = patch.aaguid?.trim();972  }973974  if ("backupEligible" in patch) {975    nextPatch.backupEligible = patch.backupEligible;976  }977978  if ("backupState" in patch) {979    nextPatch.backupState = patch.backupState;980  }981982  if ("lastUsedAt" in patch) {983    nextPatch.lastUsedAt = patch.lastUsedAt;984  }985986  return nextPatch;987}988989function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {990  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;991  const modifiedAt =992    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;993994  return {995    id: entry.id,996    type: "type" in entry ? entry.type : "login",997    title: entry.title,998    username: entry.username,999    password: entry.password,1000    url: entry.url,1001    totp: entry.totp,1002    notes: entry.notes,1003    breach: entry.breach,1004    canary: "canary" in entry ? entry.canary : undefined,1005    rpId: "rpId" in entry ? entry.rpId : undefined,1006    rpName: "rpName" in entry ? entry.rpName : undefined,1007    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1008    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1009    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1010    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1011    userName: "userName" in entry ? entry.userName : undefined,1012    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1013    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1014    signCount: "signCount" in entry ? entry.signCount : undefined,1015    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1016    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1017    backupState: "backupState" in entry ? entry.backupState : undefined,1018    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1019    createdAt,1020    modifiedAt,1021    deletedAt: entry.deletedAt,1022    tags: "tags" in entry ? entry.tags : undefined,1023  };1024}10251026function hasLegacyShape(parsed: VaultEntry[]): boolean {1027  return parsed.some(1028    (entry) => "created" in entry || "updated" in entry1029  );1030}10311032// ---------------------------------------------------------------------------1033// Deduplication1034// ---------------------------------------------------------------------------10351036function entryDupeKey(e: VaultEntry): string {1037  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1038  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1039  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1040}10411042/** Returns the number of duplicate entries (those that would be removed). */1043export function countDuplicates(): number {1044  const best = new Map<string, VaultEntry>();1045  let dupeCount = 0;1046  for (const e of entries.filter((e) => !e.deletedAt)) {1047    const key = entryDupeKey(e);1048    const existing = best.get(key);1049    if (!existing) {1050      best.set(key, e);1051    } else {1052      dupeCount++;1053      // Keep the more recently modified one in the map1054      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1055        best.set(key, e);1056      }1057    }1058  }1059  return dupeCount;1060}10611062/**1063 * Soft-deletes duplicate entries, keeping the most recently modified copy of1064 * each group. Returns the number of entries moved to trash.1065 */1066export async function deduplicateEntries(): Promise<number> {1067  if (!derivedKey) throw new Error("vault locked");1068  // Dedup reshapes slot B permanently under the decoy key, which is1069  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1070  if (vaultMode === "decoy") {1071    throw new Error("deduplicate disabled in decoy mode");1072  }10731074  const best = new Map<string, VaultEntry>();1075  for (const e of entries.filter((en) => !en.deletedAt)) {1076    const key = entryDupeKey(e);1077    const existing = best.get(key);1078    if (!existing) {1079      best.set(key, e);1080    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1081      best.set(key, e);1082    }1083  }10841085  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1086  const now = Date.now();1087  let removed = 0;1088  entries = entries.map((e) => {1089    if (!e.deletedAt && !keepIds.has(e.id)) {1090      removed++;1091      return { ...e, deletedAt: now };1092    }1093    return e;1094  });10951096  if (removed > 0) await persistEntries();1097  return removed;1098}10991100// ---------------------------------------------------------------------------1101// Vault snapshot system — last 6 encrypted states, used for self-recovery1102// ---------------------------------------------------------------------------11031104const SNAPSHOT_KEY = "peach_vault_snapshots";1105const MAX_SNAPSHOTS = 6;11061107interface VaultSnapshot {1108  timestamp: number;1109  entryCount: number;1110  slot: StoredVault;1111}11121113/** Public summary (no sensitive data) returned to the popup. */1114export interface SnapshotSummary {1115  index: number;1116  timestamp: number;1117  entryCount: number;1118}11191120async function takeSnapshot(1121  slot: StoredVault,1122  entryCount: number1123): Promise<void> {1124  try {1125    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1126    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1127    const next: VaultSnapshot[] = [1128      { timestamp: Date.now(), entryCount, slot },1129      ...existing,1130    ].slice(0, MAX_SNAPSHOTS);1131    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1132  } catch {1133    // Non-fatal — snapshot failure must never interrupt vault saves1134  }1135}11361137export async function listSnapshots(): Promise<SnapshotSummary[]> {1138  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1139  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1140  return snapshots.map((s, i) => ({1141    index: i,1142    timestamp: s.timestamp,1143    entryCount: s.entryCount,1144  }));1145}11461147/**1148 * Restore a snapshot by index. The password must decrypt that snapshot1149 * (i.e., the same master password used when the snapshot was taken).1150 * On success the vault is unlocked in-memory with the restored entries1151 * — no re-login required.1152 */1153export async function restoreFromSnapshot(1154  index: number,1155  password: string1156): Promise<boolean> {1157  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1158  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1159  const snapshot = snapshots[index];1160  if (!snapshot) return false;11611162  // Verify the password decrypts this snapshot1163  const key = await deriveKey(password, unhex(snapshot.slot.salt));1164  const payload = await tryDecryptSlot(snapshot.slot, key);1165  if (!payload) {1166    zeroKey(key);1167    return false;1168  }11691170  // Write the snapshot back to slot A. Regenerate noise for slot B.1171  await writeSlot(SLOT_A, snapshot.slot);1172  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));11731174  // Swap in-memory state to the restored vault — caller gets immediate access1175  if (derivedKey) zeroKey(derivedKey);1176  derivedKey = key;1177  entries = payload.entries;1178  duressConfigured = payload.duressConfigured;1179  vaultMode = "real";1180  buildDomainIndexSync(entries);1181  await loadSettings();1182  touchAutoLock();11831184  return true;1185}11861187/**1188 * Change the master password. Re-encrypts slot A with a fresh salt + new1189 * derived key. Slot B (duress or noise) is left untouched when duress is1190 * configured — it uses an independent key derived from the duress password —1191 * and regenerated as noise when duress is not configured.1192 */1193export async function changePassword(1194  currentPassword: string,1195  newPassword: string1196): Promise<boolean> {1197  if (!derivedKey || vaultMode !== "real") return false;11981199  // Verify current password before allowing any change1200  const valid = await verifyMainPassword(currentPassword);1201  if (!valid) return false;12021203  const slotA = await loadSlot(SLOT_A);1204  if (!slotA) return false;12051206  // Derive fresh key from new password + new random salt1207  const newSalt = generateSalt();1208  const newKey = await deriveKey(newPassword, newSalt);12091210  // Re-encrypt the in-memory entries with the new key1211  const plaintext = encodePayload(entries, duressConfigured);1212  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);12131214  const freshSlotA: StoredVault = {1215    ...slotA,1216    salt: hex(newSalt),1217    nonce,1218    ciphertext,1219    tag,1220  };1221  await writeSlot(SLOT_A, freshSlotA);12221223  // Swap the in-memory key synchronously before any further await. If we1224  // threw between the Slot A write and this swap, the next persistEntries()1225  // would overwrite Slot A with old-key ciphertext, locking the user out.1226  zeroKey(derivedKey);1227  derivedKey = newKey;12281229  // Snapshot now that in-memory key and disk are consistent. Pass the1230  // slot content we just wrote rather than re-reading from storage so the1231  // snapshot is immune to any microtask-coalesced read-after-write race.1232  const activeCount = entries.filter((e) => !e.deletedAt).length;1233  void takeSnapshot(freshSlotA, activeCount);12341235  // Regenerate noise for slot B when duress is not in use. Runs after the1236  // key swap so a failure here is a disposable noise regeneration, not a1237  // lockout.1238  if (!duressConfigured) {1239    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1240  }12411242  return true;1243}