vault.ts

42.1 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  declinedCardFingerprints?: string[];60}6162let settings: VaultSettings = {};6364export function getSettings(): VaultSettings {65  return settings;66}6768export async function ensureSettingsLoaded(): Promise<VaultSettings> {69  await loadSettings();70  return settings;71}7273export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {74  await loadSettings();75  settings = { ...settings, ...patch };76  await persistSettings();77}7879async function persistSettings(): Promise<void> {80  try {81    await chrome.storage.local.set({ peach_settings: settings });82  } catch (err) {83    throw mapStorageError(err);84  }85}8687function mapStorageError(err: unknown): Error {88  const message = err instanceof Error ? err.message : String(err);89  if (/QUOTA_BYTES|quota/i.test(message)) {90    return new Error("STORAGE_QUOTA_EXCEEDED");91  }92  return err instanceof Error ? err : new Error(message);93}9495async function loadSettings(): Promise<void> {96  const result = await chrome.storage.local.get("peach_settings");97  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};98}99100// ---------------------------------------------------------------------------101// HIBP breach check (k-anonymity model)102// ---------------------------------------------------------------------------103104export async function checkBreach(password: string): Promise<number> {105  const encoder = new TextEncoder();106  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));107  const hashHex = Array.from(new Uint8Array(hashBuffer))108    .map((b) => b.toString(16).padStart(2, "0"))109    .join("")110    .toUpperCase();111112  const prefix = hashHex.slice(0, 5);113  const suffix = hashHex.slice(5);114115  try {116    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);117    if (!res.ok) return 0;118    const text = await res.text();119    const match = text.split("\r\n").find((l) => l.startsWith(suffix));120    return match ? parseInt(match.split(":")[1], 10) : 0;121  } catch {122    return 0; // Network unavailable — don't block123  }124}125126export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {127  const fingerprint = await cardFingerprint(last4, expMonth, expYear);128  await loadSettings();129  settings = {130    ...settings,131    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],132  };133  await persistSettings();134}135136export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {137  await loadSettings();138  const fingerprint = await cardFingerprint(last4, expMonth, expYear);139  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);140}141142async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {143  const encoder = new TextEncoder();144  const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));145  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");146}147148function hex(buf: Uint8Array): string {149  return Array.from(buf)150    .map((b) => b.toString(16).padStart(2, "0"))151    .join("");152}153154function unhex(s: string): Uint8Array {155  const out = new Uint8Array(s.length / 2);156  for (let i = 0; i < s.length; i += 2) {157    out[i / 2] = parseInt(s.slice(i, i + 2), 16);158  }159  return out;160}161162// ---------------------------------------------------------------------------163// Storage schema migration164// ---------------------------------------------------------------------------165166async function loadSlot(key: string): Promise<StoredVault | null> {167  const result = await chrome.storage.local.get(key);168  return (result as Record<string, StoredVault | undefined>)[key] ?? null;169}170171async function writeSlot(key: string, vault: StoredVault): Promise<void> {172  try {173    await chrome.storage.local.set({ [key]: vault });174  } catch (err) {175    throw mapStorageError(err);176  }177}178179/**180 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault181 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +182 * peach_vault_b). Runs at most once per session; safe to call repeatedly.183 */184async function ensureMigrated(): Promise<void> {185  if (migrationRan) return;186  migrationRan = true;187188  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];189  const raw = await chrome.storage.local.get(keys);190  const slotAExists = raw[SLOT_A] !== undefined;191  const slotBExists = raw[SLOT_B] !== undefined;192  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;193  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;194195  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.196  if (!slotAExists && !legacyMain) return;197198  // Already migrated.199  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;200201  if (legacyMain && !slotAExists) {202    await writeSlot(SLOT_A, legacyMain);203  }204205  // If slot B isn't set, populate it: use legacy decoy when present, else noise.206  if (!slotBExists) {207    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;208    if (legacyDecoy) {209      await writeSlot(SLOT_B, legacyDecoy);210    } else if (target) {211      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));212    }213  }214215  const toRemove: string[] = [];216  if (legacyMain) toRemove.push(LEGACY_MAIN);217  if (legacyDecoy) toRemove.push(LEGACY_DECOY);218  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);219  if (toRemove.length > 0) {220    await chrome.storage.local.remove(toRemove);221  }222}223224// ---------------------------------------------------------------------------225// Padding + noise helpers226// ---------------------------------------------------------------------------227228/**229 * Pads the plaintext string with trailing spaces so that the encoded byte230 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing231 * whitespace after a complete document, so the padding is transparent on232 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.233 */234function padPlaintext(json: string): string {235  const encoded = new TextEncoder().encode(json);236  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);237  const padLen = target - encoded.length;238  return json + " ".repeat(padLen);239}240241/**242 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The243 * ciphertext length matches the given byte count. Because GCM ciphertext is244 * pseudo-random, this blob is indistinguishable at rest from a real245 * encrypted vault without the key.246 */247function generateNoiseBlob(ciphertextBytes: number): StoredVault {248  const salt = crypto.getRandomValues(new Uint8Array(16));249  const nonce = crypto.getRandomValues(new Uint8Array(12));250  const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));251  const tag = crypto.getRandomValues(new Uint8Array(16));252  return {253    version: 1,254    salt: hex(salt),255    nonce: hex(nonce),256    ciphertext: hex(cipher),257    tag: hex(tag),258  };259}260261// ---------------------------------------------------------------------------262// Encrypted plaintext envelope263// ---------------------------------------------------------------------------264265type VaultPayload = {266  entries: VaultEntry[];267  duressConfigured?: boolean;268};269270type DecryptedPayload = {271  entries: VaultEntry[];272  duressConfigured: boolean;273};274275function encodePayload(entries: VaultEntry[], duress: boolean): string {276  const envelope: VaultPayload = { entries, duressConfigured: duress };277  return padPlaintext(JSON.stringify(envelope));278}279280function decodePayload(plain: string): DecryptedPayload {281  const parsed = JSON.parse(plain) as282    | Array<VaultEntry | LegacyVaultEntry>283    | VaultPayload;284285  if (Array.isArray(parsed)) {286    // Pre-A-2 plaintext: bare array of entries, no duress flag.287    return {288      entries: parsed.map(normalizeEntry),289      duressConfigured: false,290    };291  }292293  return {294    entries: (parsed.entries ?? []).map(normalizeEntry),295    duressConfigured: parsed.duressConfigured === true,296  };297}298299async function tryDecryptSlot(300  vault: StoredVault,301  key: Uint8Array302): Promise<DecryptedPayload | null> {303  try {304    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);305    return decodePayload(plain);306  } catch {307    return null;308  }309}310311// ---------------------------------------------------------------------------312// Core operations313// ---------------------------------------------------------------------------314315function buildDomainIndexSync(all: VaultEntry[]): void {316  domainIndex = new Map();317318  for (const entry of all) {319    if (entry.deletedAt) continue;320    if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;321322    const domain = extractDomain(entry.url ?? "");323    if (!domain) continue;324325    const bucket = domainIndex.get(domain) ?? [];326    bucket.push(entry);327    domainIndex.set(domain, bucket);328  }329}330331export async function createVault(password: string): Promise<void> {332  await ensureMigrated();333334  const salt = generateSalt();335  derivedKey = await deriveKey(password, salt);336  entries = [];337  vaultMode = "real";338  duressConfigured = false;339  buildDomainIndexSync(entries);340341  const plaintext = encodePayload(entries, duressConfigured);342  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);343344  const slotA: StoredVault = {345    version: 1,346    salt: hex(salt),347    nonce,348    ciphertext,349    tag,350  };351  await writeSlot(SLOT_A, slotA);352353  // Matching-size noise blob for slot B, so a fresh install shows two354  // similarly-shaped records regardless of whether duress is ever set up.355  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));356357  touchAutoLock();358}359360export async function hasVault(): Promise<boolean> {361  await ensureMigrated();362  return (await loadSlot(SLOT_A)) !== null;363}364365/**366 * Returns whether the real vault has recorded that a duress passphrase is367 * configured. Reliable only after a real-mode unlock — before unlock or in368 * decoy mode, returns false because we cannot read slot A's plaintext.369 */370export async function isDuressEnabled(): Promise<boolean> {371  return vaultMode === "real" && duressConfigured;372}373374export async function hasDecoyVault(): Promise<boolean> {375  return isDuressEnabled();376}377378export async function setupDuress(duressPassword: string): Promise<void> {379  if (!derivedKey || vaultMode !== "real") {380    throw new Error("vault must be unlocked in real mode");381  }382  await ensureMigrated();383384  // Encrypt the decoy payload (empty entries) under the duress key. Pad to385  // at least the current slot A ciphertext size so the two slots look the386  // same at rest at setup time.387  const slotA = await loadSlot(SLOT_A);388  if (!slotA) throw new Error("missing slot A");389390  const decoySalt = generateSalt();391  const decoyKey = await deriveKey(duressPassword, decoySalt);392  try {393    // Pad the decoy plaintext to match slot A's ciphertext length (minus the394    // 16-byte tag) so encrypted sizes align exactly.395    const targetBytes = slotA.ciphertext.length / 2;396    let plaintext = encodePayload([], false);397    const encoded = new TextEncoder().encode(plaintext);398    if (encoded.length < targetBytes) {399      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);400    }401    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);402    await writeSlot(SLOT_B, {403      version: 1,404      salt: hex(decoySalt),405      nonce,406      ciphertext,407      tag,408    });409  } finally {410    zeroKey(decoyKey);411  }412413  // Re-encrypt slot A with the duressConfigured flag set to true. No external414  // flag is written — the bit lives inside the encrypted envelope.415  duressConfigured = true;416  await persistSlotA();417}418419export async function disableDuress(): Promise<void> {420  if (!derivedKey || vaultMode !== "real") {421    throw new Error("vault must be unlocked in real mode");422  }423  await ensureMigrated();424425  // Overwrite slot B with fresh noise that matches slot A's size. The slot426  // remains occupied so its mere existence does not signal that duress was427  // ever configured.428  const slotA = await loadSlot(SLOT_A);429  if (slotA) {430    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));431  }432433  duressConfigured = false;434  await persistSlotA();435}436437export async function verifyMainPassword(password: string): Promise<boolean> {438  await ensureMigrated();439  const slotA = await loadSlot(SLOT_A);440  if (!slotA) return false;441  const key = await deriveKey(password, unhex(slotA.salt));442  try {443    return (await tryDecryptSlot(slotA, key)) !== null;444  } finally {445    zeroKey(key);446  }447}448449export async function unlock(password: string): Promise<boolean> {450  await ensureMigrated();451452  // Load both slots in parallel. Both always exist post-migration; the453  // parallel load and the parallel derivation below keep wall-clock time454  // constant regardless of which slot's passphrase (real or decoy) the455  // caller supplied, so a timing side channel cannot distinguish the456  // duress path from the real-unlock path (DUR-002).457  const [slotA, slotB] = await Promise.all([458    loadSlot(SLOT_A),459    loadSlot(SLOT_B),460  ]);461462  // Derive both keys in parallel, unconditionally.463  const [keyA, keyB] = await Promise.all([464    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),465    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),466  ]);467468  // Attempt both decrypts in parallel.469  const [payloadA, payloadB] = await Promise.all([470    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),471    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),472  ]);473474  if (payloadA && keyA) {475    derivedKey = keyA;476    vaultMode = "real";477    entries = payloadA.entries;478    duressConfigured = payloadA.duressConfigured;479    buildDomainIndexSync(entries);480    await loadSettings();481    touchAutoLock();482    if (keyB) zeroKey(keyB);483    // If the payload came from the pre-envelope (legacy) shape, rewrite484    // it immediately so the next save uses the new format.485    if (hasLegacyShape(payloadA.entries)) {486      await persistSlotA();487    }488    return true;489  }490491  if (payloadB && keyB) {492    derivedKey = keyB;493    vaultMode = "decoy";494    entries = payloadB.entries;495    duressConfigured = false;496    buildDomainIndexSync(entries);497    await loadSettings();498    touchAutoLock();499    if (keyA) zeroKey(keyA);500    return true;501  }502503  if (keyA) zeroKey(keyA);504  if (keyB) zeroKey(keyB);505  return false;506}507508export function lock(): void {509  if (derivedKey) {510    zeroKey(derivedKey);511    derivedKey = null;512  }513514  entries = [];515  domainIndex = new Map();516  vaultMode = "real";517  duressConfigured = false;518  clearAutoLock();519}520521export function isUnlocked(): boolean {522  return derivedKey !== null;523}524525export function getEntries(): VaultEntry[] {526  if (!derivedKey) throw new Error("vault locked");527  return entries.filter((entry) => !entry.deletedAt);528}529530export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {531  if (!derivedKey) throw new Error("vault locked");532533  const now = Date.now();534  const added: VaultEntry[] = [];535  for (const draft of drafts) {536    try {537      const sanitized = sanitizeEntryDraft(draft);538      const nextEntry: VaultEntry = {539        ...sanitized,540        id: crypto.randomUUID(),541        createdAt: now,542        modifiedAt: now,543      };544      entries.push(nextEntry);545      added.push(nextEntry);546    } catch {547      // Skip invalid drafts silently — caller can see diff between input and output length548    }549  }550551  if (added.length > 0) {552    await persistEntries();553  }554555  return added;556}557558/**559 * Restore a set of entries in full fidelity — preserves id, createdAt,560 * modifiedAt, and every other field unlike `batchAddEntries` which561 * assigns fresh identity. Used by the CJK phrase restore path where the562 * backup is the authoritative source of truth. `mode` controls how563 * entries already in the vault are treated.564 */565export async function restoreEntries(566  incoming: VaultEntry[],567  mode: "replace" | "merge"568): Promise<{ added: number; updated: number; skipped: number }> {569  if (!derivedKey) throw new Error("vault locked");570571  // A replace with zero incoming entries is almost certainly a caller bug572  // (e.g. an import that silently dropped everything). Refuse rather than573  // wipe the vault.574  if (mode === "replace" && incoming.length === 0) {575    throw new Error("refuse to replace with empty entry set");576  }577578  let added = 0;579  let updated = 0;580  let skipped = 0;581582  if (mode === "replace") {583    entries = [];584    for (const entry of incoming) {585      const normalized = sanitizeRestoredEntry(entry);586      if (!normalized) {587        skipped += 1;588        continue;589      }590      entries.push(normalized);591      added += 1;592    }593  } else {594    const byId = new Map<string, VaultEntry>();595    for (const e of entries) byId.set(e.id, e);596597    for (const entry of incoming) {598      const normalized = sanitizeRestoredEntry(entry);599      if (!normalized) {600        skipped += 1;601        continue;602      }603      const existing = byId.get(normalized.id);604      if (!existing) {605        entries.push(normalized);606        added += 1;607      } else if (normalized.modifiedAt > existing.modifiedAt) {608        const idx = entries.findIndex((e) => e.id === existing.id);609        if (idx !== -1) {610          entries[idx] = normalized;611          updated += 1;612        }613      } else {614        skipped += 1;615      }616    }617  }618619  await persistEntries();620  return { added, updated, skipped };621}622623/**624 * Validate + normalise a VaultEntry coming from a backup. Accepts625 * anything structurally sound; keeps all metadata verbatim. Returns626 * null for values the live vault shouldn't accept (e.g., missing title).627 */628function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {629  if (!entry || typeof entry !== "object") return null;630  const title = typeof entry.title === "string" ? entry.title.trim() : "";631  if (!title) return null;632  const type = entry.type ?? "login";633  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();634  const now = Date.now();635  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;636  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;637638  if (type === "login" && typeof entry.password !== "string") {639    return null;640  }641  if (642    type === "passkey" &&643    (typeof entry.rpId !== "string" ||644      typeof entry.credentialId !== "string" ||645      !entry.privateKeyJwk)646  ) {647    return null;648  }649650  return {651    ...entry,652    id,653    title,654    type,655    createdAt,656    modifiedAt,657  };658}659660export async function clearVaultEntries(661  confirmation: "REPLACE_ALL"662): Promise<void> {663  if (confirmation !== "REPLACE_ALL") {664    throw new Error("clearVaultEntries requires explicit confirmation");665  }666  if (!derivedKey) throw new Error("vault locked");667  for (const entry of entries) {668    if (!entry.deletedAt) {669      entry.deletedAt = Date.now();670      entry.modifiedAt = Date.now();671    }672  }673  await persistEntries();674}675676export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {677  if (!derivedKey) throw new Error("vault locked");678679  const sanitized = sanitizeEntryDraft(entry);680  const now = Date.now();681  const nextEntry: VaultEntry = {682    ...sanitized,683    id: crypto.randomUUID(),684    createdAt: now,685    modifiedAt: now,686  };687688  entries.push(nextEntry);689  await persistEntries();690691  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)692  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {693    checkBreach(nextEntry.password).then(async (count) => {694      if (count > 0) {695        const idx = entries.findIndex((e) => e.id === nextEntry.id);696        if (idx !== -1) {697          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };698          await persistEntries();699        }700      }701    });702  }703704  return nextEntry;705}706707export async function updateEntry(708  id: string,709  patch: Partial<VaultEntry>710): Promise<void> {711  if (!derivedKey) throw new Error("vault locked");712713  const idx = entries.findIndex((entry) => entry.id === id);714  if (idx === -1 || entries[idx].deletedAt) {715    throw new Error("entry not found");716  }717718  const current = entries[idx];719  entries[idx] = {720    ...current,721    ...sanitizeEntryPatch(patch),722    modifiedAt: Date.now(),723  };724725  // sanitizeEntryPatch maps an empty password to `undefined`. For login726  // entries that would silently produce a passwordless record — roll back727  // the merge and refuse.728  if (entries[idx].type === "login" && !entries[idx].password) {729    entries[idx] = current;730    throw new Error("password is required for login entries");731  }732733  // Card entries must retain their card number734  if (entries[idx].type === "card" && !entries[idx].cardNumber) {735    entries[idx] = current;736    throw new Error("card number is required for card entries");737  }738739  await persistEntries();740741  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)742  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {743    const password = entries[idx].password!;744    checkBreach(password).then(async (count) => {745      const i = entries.findIndex((e) => e.id === id);746      if (i !== -1) {747        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };748        await persistEntries();749      }750    });751  }752}753754export async function deleteEntry(id: string): Promise<void> {755  if (!derivedKey) throw new Error("vault locked");756757  const entry = entries.find((item) => item.id === id);758  if (!entry || entry.deletedAt) {759    throw new Error("entry not found");760  }761762  entry.deletedAt = Date.now();763  entry.modifiedAt = Date.now();764  await persistEntries();765}766767export function getDeletedEntries(): VaultEntry[] {768  if (!derivedKey) return [];769  return entries.filter((e) => !!e.deletedAt);770}771772export async function restoreEntry(id: string): Promise<void> {773  if (!derivedKey) throw new Error("vault locked");774  const entry = entries.find((e) => e.id === id && e.deletedAt);775  if (!entry) throw new Error("deleted entry not found");776  delete entry.deletedAt;777  entry.modifiedAt = Date.now();778  await persistEntries();779  buildDomainIndexSync(entries);780}781782export async function purgeEntry(id: string): Promise<void> {783  if (!derivedKey) throw new Error("vault locked");784  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);785  if (idx === -1) throw new Error("deleted entry not found");786  entries.splice(idx, 1);787  await persistEntries();788}789790/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */791export async function purgeExpiredTrash(retentionMs: number): Promise<number> {792  if (!derivedKey || retentionMs === 0) return 0;793  const cutoff = Date.now() - retentionMs;794  const before = entries.length;795  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));796  const purged = before - entries.length;797  if (purged > 0) await persistEntries();798  return purged;799}800801export function queryDomain(domain: string): VaultEntry[] {802  return domainIndex.get(domain) ?? [];803}804805export function getEntryById(id: string): VaultEntry | undefined {806  return entries.find((entry) => entry.id === id && !entry.deletedAt);807}808809async function persistSlotA(): Promise<StoredVault> {810  if (!derivedKey) throw new Error("vault locked");811812  const slotA = await loadSlot(SLOT_A);813  if (!slotA) throw new Error("missing slot A");814815  const plaintext = encodePayload(entries, duressConfigured);816  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);817818  const nextSlotA: StoredVault = {819    ...slotA,820    nonce,821    ciphertext,822    tag,823  };824  await writeSlot(SLOT_A, nextSlotA);825826  // When duress is not configured, keep slot B size-matched with slot A by827  // regenerating its noise. When duress IS configured, leave slot B alone —828  // we don't have the decoy key to re-encrypt it. This is a known trade-off.829  if (!duressConfigured) {830    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));831  }832833  return nextSlotA;834}835836async function persistSlotB(): Promise<void> {837  if (!derivedKey) throw new Error("vault locked");838839  const slotB = await loadSlot(SLOT_B);840  if (!slotB) throw new Error("missing slot B");841842  // Decoy plaintext always reports duressConfigured=false — the decoy user843  // sees a clean slate.844  const plaintext = encodePayload(entries, false);845  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);846847  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });848}849850async function persistEntries(): Promise<void> {851  if (!derivedKey) throw new Error("vault locked");852853  if (vaultMode === "decoy") {854    await persistSlotB();855  } else {856    const freshSlotA = await persistSlotA();857    // Save a snapshot of the real vault after every successful write.858    // Failures are silently swallowed — snapshots must never interrupt saves.859    const activeCount = entries.filter((e) => !e.deletedAt).length;860    void takeSnapshot(freshSlotA, activeCount);861  }862  buildDomainIndexSync(entries);863}864865function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {866  const title = entry.title.trim();867  const type = entry.type ?? "login";868869  if (!title) throw new Error("title is required");870  if (type === "login" && !entry.password?.trim()) {871    throw new Error("password is required");872  }873  if (type === "passkey") {874    if (875      !entry.rpId?.trim() ||876      !entry.rpName?.trim() ||877      !entry.credentialId?.trim() ||878      !entry.publicKeyCose?.trim() ||879      !entry.privateKeyJwk ||880      !entry.userHandle?.trim() ||881      !entry.userName?.trim() ||882      !entry.userDisplayName?.trim() ||883      (entry.algorithm !== -7 && entry.algorithm !== -8)884    ) {885      throw new Error("invalid passkey entry");886    }887  }888  if (type === "card") {889    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {890      throw new Error("cardholder name and card number are required");891    }892  }893894  return {895    type,896    title,897    username: type === "login" ? entry.username?.trim() ?? "" : undefined,898    password: type === "login" ? entry.password : undefined,899    url: type === "login" ? entry.url?.trim() ?? "" : undefined,900    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,901    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,902    breach: type === "login" ? entry.breach : undefined,903    canary: type === "login" ? entry.canary : undefined,904    tags: entry.tags,905    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,906    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,907    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,908    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,909    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,910    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,911    userName: type === "passkey" ? entry.userName?.trim() : undefined,912    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,913    algorithm: type === "passkey" ? entry.algorithm : undefined,914    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,915    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,916    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,917    backupState: type === "passkey" ? entry.backupState === true : undefined,918    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,919    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,920    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,921    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,922    expYear: type === "card" ? entry.expYear?.trim() : undefined,923    cvc: type === "card" ? entry.cvc?.trim() : undefined,924    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,925    network: type === "card" ? entry.network : undefined,926    last4: type === "card" ? entry.last4?.trim() : undefined,927  };928}929930function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {931  const nextPatch: Partial<VaultEntry> = {};932933  if ("type" in patch) {934    nextPatch.type = patch.type;935  }936937  if ("title" in patch) {938    const title = patch.title?.trim() ?? "";939    if (!title) throw new Error("title is required");940    nextPatch.title = title;941  }942943  if ("url" in patch) {944    nextPatch.url = patch.url?.trim() ?? "";945  }946947  if ("username" in patch) {948    nextPatch.username = patch.username?.trim() ?? "";949  }950951  if ("password" in patch) {952    const password = patch.password ?? "";953    // Allow clearing a password (e.g. on passkey-only entries)954    nextPatch.password = password || undefined;955  }956957  if ("totp" in patch) {958    nextPatch.totp = patch.totp?.trim() || undefined;959  }960961  if ("notes" in patch) {962    nextPatch.notes = patch.notes?.trim() || undefined;963  }964965  if ("breach" in patch) {966    nextPatch.breach = patch.breach;967  }968969  if ("canary" in patch) {970    nextPatch.canary = patch.canary;971  }972973  if ("rpId" in patch) {974    nextPatch.rpId = patch.rpId?.trim();975  }976977  if ("rpName" in patch) {978    nextPatch.rpName = patch.rpName?.trim();979  }980981  if ("credentialId" in patch) {982    nextPatch.credentialId = patch.credentialId?.trim();983  }984985  if ("publicKeyCose" in patch) {986    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();987  }988989  if ("privateKeyJwk" in patch) {990    nextPatch.privateKeyJwk = patch.privateKeyJwk;991  }992993  if ("userHandle" in patch) {994    nextPatch.userHandle = patch.userHandle?.trim();995  }996997  if ("userName" in patch) {998    nextPatch.userName = patch.userName?.trim();999  }10001001  if ("userDisplayName" in patch) {1002    nextPatch.userDisplayName = patch.userDisplayName?.trim();1003  }10041005  if ("algorithm" in patch) {1006    nextPatch.algorithm = patch.algorithm;1007  }10081009  if ("signCount" in patch) {1010    nextPatch.signCount = patch.signCount;1011  }10121013  if ("aaguid" in patch) {1014    nextPatch.aaguid = patch.aaguid?.trim();1015  }10161017  if ("backupEligible" in patch) {1018    nextPatch.backupEligible = patch.backupEligible;1019  }10201021  if ("backupState" in patch) {1022    nextPatch.backupState = patch.backupState;1023  }10241025  if ("lastUsedAt" in patch) {1026    nextPatch.lastUsedAt = patch.lastUsedAt;1027  }10281029  if ("tags" in patch) {1030    nextPatch.tags = patch.tags;1031  }10321033  if ("cardholderName" in patch) {1034    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1035  }1036  if ("cardNumber" in patch) {1037    const n = patch.cardNumber ?? "";1038    nextPatch.cardNumber = n || undefined;1039  }1040  if ("expMonth" in patch) {1041    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1042  }1043  if ("expYear" in patch) {1044    nextPatch.expYear = patch.expYear?.trim() || undefined;1045  }1046  if ("cvc" in patch) {1047    nextPatch.cvc = patch.cvc?.trim() || undefined;1048  }1049  if ("cardNickname" in patch) {1050    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1051  }1052  if ("network" in patch) {1053    nextPatch.network = patch.network;1054  }1055  if ("last4" in patch) {1056    nextPatch.last4 = patch.last4?.trim() || undefined;1057  }10581059  return nextPatch;1060}10611062function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1063  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1064  const modifiedAt =1065    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;10661067  return {1068    id: entry.id,1069    type: "type" in entry ? entry.type : "login",1070    title: entry.title,1071    username: entry.username,1072    password: entry.password,1073    url: entry.url,1074    totp: entry.totp,1075    notes: entry.notes,1076    breach: entry.breach,1077    canary: "canary" in entry ? entry.canary : undefined,1078    rpId: "rpId" in entry ? entry.rpId : undefined,1079    rpName: "rpName" in entry ? entry.rpName : undefined,1080    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1081    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1082    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1083    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1084    userName: "userName" in entry ? entry.userName : undefined,1085    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1086    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1087    signCount: "signCount" in entry ? entry.signCount : undefined,1088    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1089    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1090    backupState: "backupState" in entry ? entry.backupState : undefined,1091    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1092    createdAt,1093    modifiedAt,1094    deletedAt: entry.deletedAt,1095    tags: "tags" in entry ? entry.tags : undefined,1096    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1097    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1098    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1099    expYear: "expYear" in entry ? entry.expYear : undefined,1100    cvc: "cvc" in entry ? entry.cvc : undefined,1101    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1102    network: "network" in entry ? entry.network : undefined,1103    last4: "last4" in entry ? entry.last4 : undefined,1104  };1105}11061107function hasLegacyShape(parsed: VaultEntry[]): boolean {1108  return parsed.some(1109    (entry) => "created" in entry || "updated" in entry1110  );1111}11121113// ---------------------------------------------------------------------------1114// Deduplication1115// ---------------------------------------------------------------------------11161117function entryDupeKey(e: VaultEntry): string {1118  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1119  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1120  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1121}11221123/** Returns the number of duplicate entries (those that would be removed). */1124export function countDuplicates(): number {1125  const best = new Map<string, VaultEntry>();1126  let dupeCount = 0;1127  for (const e of entries.filter((e) => !e.deletedAt)) {1128    const key = entryDupeKey(e);1129    const existing = best.get(key);1130    if (!existing) {1131      best.set(key, e);1132    } else {1133      dupeCount++;1134      // Keep the more recently modified one in the map1135      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1136        best.set(key, e);1137      }1138    }1139  }1140  return dupeCount;1141}11421143/**1144 * Soft-deletes duplicate entries, keeping the most recently modified copy of1145 * each group. Returns the number of entries moved to trash.1146 */1147export async function deduplicateEntries(): Promise<number> {1148  if (!derivedKey) throw new Error("vault locked");1149  // Dedup reshapes slot B permanently under the decoy key, which is1150  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1151  if (vaultMode === "decoy") {1152    throw new Error("deduplicate disabled in decoy mode");1153  }11541155  const best = new Map<string, VaultEntry>();1156  for (const e of entries.filter((en) => !en.deletedAt)) {1157    const key = entryDupeKey(e);1158    const existing = best.get(key);1159    if (!existing) {1160      best.set(key, e);1161    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1162      best.set(key, e);1163    }1164  }11651166  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1167  const now = Date.now();1168  let removed = 0;1169  entries = entries.map((e) => {1170    if (!e.deletedAt && !keepIds.has(e.id)) {1171      removed++;1172      return { ...e, deletedAt: now };1173    }1174    return e;1175  });11761177  if (removed > 0) await persistEntries();1178  return removed;1179}11801181// ---------------------------------------------------------------------------1182// Vault snapshot system — last 6 encrypted states, used for self-recovery1183// ---------------------------------------------------------------------------11841185const SNAPSHOT_KEY = "peach_vault_snapshots";1186const MAX_SNAPSHOTS = 6;11871188interface VaultSnapshot {1189  timestamp: number;1190  entryCount: number;1191  slot: StoredVault;1192}11931194/** Public summary (no sensitive data) returned to the popup. */1195export interface SnapshotSummary {1196  index: number;1197  timestamp: number;1198  entryCount: number;1199}12001201async function takeSnapshot(1202  slot: StoredVault,1203  entryCount: number1204): Promise<void> {1205  try {1206    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1207    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1208    const next: VaultSnapshot[] = [1209      { timestamp: Date.now(), entryCount, slot },1210      ...existing,1211    ].slice(0, MAX_SNAPSHOTS);1212    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1213  } catch {1214    // Non-fatal — snapshot failure must never interrupt vault saves1215  }1216}12171218export async function listSnapshots(): Promise<SnapshotSummary[]> {1219  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1220  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1221  return snapshots.map((s, i) => ({1222    index: i,1223    timestamp: s.timestamp,1224    entryCount: s.entryCount,1225  }));1226}12271228/**1229 * Restore a snapshot by index. The password must decrypt that snapshot1230 * (i.e., the same master password used when the snapshot was taken).1231 * On success the vault is unlocked in-memory with the restored entries1232 * — no re-login required.1233 */1234export async function restoreFromSnapshot(1235  index: number,1236  password: string1237): Promise<boolean> {1238  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1239  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1240  const snapshot = snapshots[index];1241  if (!snapshot) return false;12421243  // Verify the password decrypts this snapshot1244  const key = await deriveKey(password, unhex(snapshot.slot.salt));1245  const payload = await tryDecryptSlot(snapshot.slot, key);1246  if (!payload) {1247    zeroKey(key);1248    return false;1249  }12501251  // Write the snapshot back to slot A. Regenerate noise for slot B.1252  await writeSlot(SLOT_A, snapshot.slot);1253  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));12541255  // Swap in-memory state to the restored vault — caller gets immediate access1256  if (derivedKey) zeroKey(derivedKey);1257  derivedKey = key;1258  entries = payload.entries;1259  duressConfigured = payload.duressConfigured;1260  vaultMode = "real";1261  buildDomainIndexSync(entries);1262  await loadSettings();1263  touchAutoLock();12641265  return true;1266}12671268/**1269 * Change the master password. Re-encrypts slot A with a fresh salt + new1270 * derived key. Slot B (duress or noise) is left untouched when duress is1271 * configured — it uses an independent key derived from the duress password —1272 * and regenerated as noise when duress is not configured.1273 */1274export async function changePassword(1275  currentPassword: string,1276  newPassword: string1277): Promise<boolean> {1278  if (!derivedKey || vaultMode !== "real") return false;12791280  // Verify current password before allowing any change1281  const valid = await verifyMainPassword(currentPassword);1282  if (!valid) return false;12831284  const slotA = await loadSlot(SLOT_A);1285  if (!slotA) return false;12861287  // Derive fresh key from new password + new random salt1288  const newSalt = generateSalt();1289  const newKey = await deriveKey(newPassword, newSalt);12901291  // Re-encrypt the in-memory entries with the new key1292  const plaintext = encodePayload(entries, duressConfigured);1293  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);12941295  const freshSlotA: StoredVault = {1296    ...slotA,1297    salt: hex(newSalt),1298    nonce,1299    ciphertext,1300    tag,1301  };1302  await writeSlot(SLOT_A, freshSlotA);13031304  // Swap the in-memory key synchronously before any further await. If we1305  // threw between the Slot A write and this swap, the next persistEntries()1306  // would overwrite Slot A with old-key ciphertext, locking the user out.1307  zeroKey(derivedKey);1308  derivedKey = newKey;13091310  // Snapshot now that in-memory key and disk are consistent. Pass the1311  // slot content we just wrote rather than re-reading from storage so the1312  // snapshot is immune to any microtask-coalesced read-after-write race.1313  const activeCount = entries.filter((e) => !e.deletedAt).length;1314  void takeSnapshot(freshSlotA, activeCount);13151316  // Regenerate noise for slot B when duress is not in use. Runs after the1317  // key swap so a failure here is a disposable noise regeneration, not a1318  // lockout.1319  if (!duressConfigured) {1320    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1321  }13221323  return true;1324}