vault.ts

38.7 KB · raw

1import { deriveKey, generateSalt } from "../crypto/argon2";2import { extractDomain } from "../shared/domain";3import type {4  LegacyVaultEntry,5  StoredVault,6  VaultEntry,7} from "../shared/types";8import { decrypt, encrypt, zeroKey } from "./aes";9import { clearAutoLock, touchAutoLock } from "./alarms";1011type EntryDraft = Omit<12  VaultEntry,13  "id" | "createdAt" | "modifiedAt" | "deletedAt"14>;1516// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B17// either holds the decoy vault (when duress is configured) or random noise18// that looks identical at rest. No external indicator is stored — duress19// configuration is recorded only inside the encrypted payload of slot A.20const SLOT_A = "peach_vault_a";21const SLOT_B = "peach_vault_b";2223// Legacy keys, migrated away on first access.24const LEGACY_MAIN = "peach_vault";25const LEGACY_DECOY = "peach_decoy_vault";26const LEGACY_DURESS_FLAG = "peach_duress";2728// Plaintext is padded to the next multiple of this size before encryption.29// This coarsens ciphertext-length leakage into 1 KiB buckets.30const PAD_BLOCK = 1024;3132let derivedKey: Uint8Array | null = null;33let entries: VaultEntry[] = [];34let domainIndex: Map<string, VaultEntry[]> = new Map();35let vaultMode: "real" | "decoy" = "real";36// Cached post-unlock view of whether the real vault records that duress is37// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock38// this stays false because we never see slot A's plaintext.39let duressConfigured = false;4041let migrationRan = false;4243// ---------------------------------------------------------------------------44// Settings (persisted inside the encrypted vault store as a sidecar key)45// ---------------------------------------------------------------------------4647export interface VaultSettings {48  accentColor?: string;49  theme?: "dark" | "light";50  autoLockMinutes?: number;51  clipboardClearSeconds?: number;52  lockOnClose?: boolean;53  breachMonitoring?: boolean;54  passkeyProviderEnabled?: boolean;55  trashRetentionDays?: number;56}5758let settings: VaultSettings = {};5960export function getSettings(): VaultSettings {61  return settings;62}6364export async function ensureSettingsLoaded(): Promise<VaultSettings> {65  await loadSettings();66  return settings;67}6869export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {70  await loadSettings();71  settings = { ...settings, ...patch };72  await persistSettings();73}7475async function persistSettings(): Promise<void> {76  try {77    await chrome.storage.local.set({ peach_settings: settings });78  } catch (err) {79    throw mapStorageError(err);80  }81}8283function mapStorageError(err: unknown): Error {84  const message = err instanceof Error ? err.message : String(err);85  if (/QUOTA_BYTES|quota/i.test(message)) {86    return new Error("STORAGE_QUOTA_EXCEEDED");87  }88  return err instanceof Error ? err : new Error(message);89}9091async function loadSettings(): Promise<void> {92  const result = await chrome.storage.local.get("peach_settings");93  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};94}9596// ---------------------------------------------------------------------------97// HIBP breach check (k-anonymity model)98// ---------------------------------------------------------------------------99100export async function checkBreach(password: string): Promise<number> {101  const encoder = new TextEncoder();102  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));103  const hashHex = Array.from(new Uint8Array(hashBuffer))104    .map((b) => b.toString(16).padStart(2, "0"))105    .join("")106    .toUpperCase();107108  const prefix = hashHex.slice(0, 5);109  const suffix = hashHex.slice(5);110111  try {112    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);113    if (!res.ok) return 0;114    const text = await res.text();115    const match = text.split("\r\n").find((l) => l.startsWith(suffix));116    return match ? parseInt(match.split(":")[1], 10) : 0;117  } catch {118    return 0; // Network unavailable — don't block119  }120}121122function hex(buf: Uint8Array): string {123  return Array.from(buf)124    .map((b) => b.toString(16).padStart(2, "0"))125    .join("");126}127128function unhex(s: string): Uint8Array {129  const out = new Uint8Array(s.length / 2);130  for (let i = 0; i < s.length; i += 2) {131    out[i / 2] = parseInt(s.slice(i, i + 2), 16);132  }133  return out;134}135136// ---------------------------------------------------------------------------137// Storage schema migration138// ---------------------------------------------------------------------------139140async function loadSlot(key: string): Promise<StoredVault | null> {141  const result = await chrome.storage.local.get(key);142  return (result as Record<string, StoredVault | undefined>)[key] ?? null;143}144145async function writeSlot(key: string, vault: StoredVault): Promise<void> {146  try {147    await chrome.storage.local.set({ [key]: vault });148  } catch (err) {149    throw mapStorageError(err);150  }151}152153/**154 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault155 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +156 * peach_vault_b). Runs at most once per session; safe to call repeatedly.157 */158async function ensureMigrated(): Promise<void> {159  if (migrationRan) return;160  migrationRan = true;161162  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];163  const raw = await chrome.storage.local.get(keys);164  const slotAExists = raw[SLOT_A] !== undefined;165  const slotBExists = raw[SLOT_B] !== undefined;166  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;167  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;168169  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.170  if (!slotAExists && !legacyMain) return;171172  // Already migrated.173  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;174175  if (legacyMain && !slotAExists) {176    await writeSlot(SLOT_A, legacyMain);177  }178179  // If slot B isn't set, populate it: use legacy decoy when present, else noise.180  if (!slotBExists) {181    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;182    if (legacyDecoy) {183      await writeSlot(SLOT_B, legacyDecoy);184    } else if (target) {185      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));186    }187  }188189  const toRemove: string[] = [];190  if (legacyMain) toRemove.push(LEGACY_MAIN);191  if (legacyDecoy) toRemove.push(LEGACY_DECOY);192  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);193  if (toRemove.length > 0) {194    await chrome.storage.local.remove(toRemove);195  }196}197198// ---------------------------------------------------------------------------199// Padding + noise helpers200// ---------------------------------------------------------------------------201202/**203 * Pads the plaintext string with trailing spaces so that the encoded byte204 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing205 * whitespace after a complete document, so the padding is transparent on206 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.207 */208function padPlaintext(json: string): string {209  const encoded = new TextEncoder().encode(json);210  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);211  const padLen = target - encoded.length;212  return json + " ".repeat(padLen);213}214215/**216 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The217 * ciphertext length matches the given byte count. Because GCM ciphertext is218 * pseudo-random, this blob is indistinguishable at rest from a real219 * encrypted vault without the key.220 */221function generateNoiseBlob(ciphertextBytes: number): StoredVault {222  const salt = crypto.getRandomValues(new Uint8Array(16));223  const nonce = crypto.getRandomValues(new Uint8Array(12));224  const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));225  const tag = crypto.getRandomValues(new Uint8Array(16));226  return {227    version: 1,228    salt: hex(salt),229    nonce: hex(nonce),230    ciphertext: hex(cipher),231    tag: hex(tag),232  };233}234235// ---------------------------------------------------------------------------236// Encrypted plaintext envelope237// ---------------------------------------------------------------------------238239type VaultPayload = {240  entries: VaultEntry[];241  duressConfigured?: boolean;242};243244type DecryptedPayload = {245  entries: VaultEntry[];246  duressConfigured: boolean;247};248249function encodePayload(entries: VaultEntry[], duress: boolean): string {250  const envelope: VaultPayload = { entries, duressConfigured: duress };251  return padPlaintext(JSON.stringify(envelope));252}253254function decodePayload(plain: string): DecryptedPayload {255  const parsed = JSON.parse(plain) as256    | Array<VaultEntry | LegacyVaultEntry>257    | VaultPayload;258259  if (Array.isArray(parsed)) {260    // Pre-A-2 plaintext: bare array of entries, no duress flag.261    return {262      entries: parsed.map(normalizeEntry),263      duressConfigured: false,264    };265  }266267  return {268    entries: (parsed.entries ?? []).map(normalizeEntry),269    duressConfigured: parsed.duressConfigured === true,270  };271}272273async function tryDecryptSlot(274  vault: StoredVault,275  key: Uint8Array276): Promise<DecryptedPayload | null> {277  try {278    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);279    return decodePayload(plain);280  } catch {281    return null;282  }283}284285// ---------------------------------------------------------------------------286// Core operations287// ---------------------------------------------------------------------------288289function buildDomainIndexSync(all: VaultEntry[]): void {290  domainIndex = new Map();291292  for (const entry of all) {293    if (entry.deletedAt) continue;294    if (entry.type === "note" || entry.type === "passkey") continue;295296    const domain = extractDomain(entry.url ?? "");297    if (!domain) continue;298299    const bucket = domainIndex.get(domain) ?? [];300    bucket.push(entry);301    domainIndex.set(domain, bucket);302  }303}304305export async function createVault(password: string): Promise<void> {306  await ensureMigrated();307308  const salt = generateSalt();309  derivedKey = await deriveKey(password, salt);310  entries = [];311  vaultMode = "real";312  duressConfigured = false;313  buildDomainIndexSync(entries);314315  const plaintext = encodePayload(entries, duressConfigured);316  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);317318  const slotA: StoredVault = {319    version: 1,320    salt: hex(salt),321    nonce,322    ciphertext,323    tag,324  };325  await writeSlot(SLOT_A, slotA);326327  // Matching-size noise blob for slot B, so a fresh install shows two328  // similarly-shaped records regardless of whether duress is ever set up.329  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));330331  touchAutoLock();332}333334export async function hasVault(): Promise<boolean> {335  await ensureMigrated();336  return (await loadSlot(SLOT_A)) !== null;337}338339/**340 * Returns whether the real vault has recorded that a duress passphrase is341 * configured. Reliable only after a real-mode unlock — before unlock or in342 * decoy mode, returns false because we cannot read slot A's plaintext.343 */344export async function isDuressEnabled(): Promise<boolean> {345  return vaultMode === "real" && duressConfigured;346}347348export async function hasDecoyVault(): Promise<boolean> {349  return isDuressEnabled();350}351352export async function setupDuress(duressPassword: string): Promise<void> {353  if (!derivedKey || vaultMode !== "real") {354    throw new Error("vault must be unlocked in real mode");355  }356  await ensureMigrated();357358  // Encrypt the decoy payload (empty entries) under the duress key. Pad to359  // at least the current slot A ciphertext size so the two slots look the360  // same at rest at setup time.361  const slotA = await loadSlot(SLOT_A);362  if (!slotA) throw new Error("missing slot A");363364  const decoySalt = generateSalt();365  const decoyKey = await deriveKey(duressPassword, decoySalt);366  try {367    // Pad the decoy plaintext to match slot A's ciphertext length (minus the368    // 16-byte tag) so encrypted sizes align exactly.369    const targetBytes = slotA.ciphertext.length / 2;370    let plaintext = encodePayload([], false);371    const encoded = new TextEncoder().encode(plaintext);372    if (encoded.length < targetBytes) {373      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);374    }375    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);376    await writeSlot(SLOT_B, {377      version: 1,378      salt: hex(decoySalt),379      nonce,380      ciphertext,381      tag,382    });383  } finally {384    zeroKey(decoyKey);385  }386387  // Re-encrypt slot A with the duressConfigured flag set to true. No external388  // flag is written — the bit lives inside the encrypted envelope.389  duressConfigured = true;390  await persistSlotA();391}392393export async function disableDuress(): Promise<void> {394  if (!derivedKey || vaultMode !== "real") {395    throw new Error("vault must be unlocked in real mode");396  }397  await ensureMigrated();398399  // Overwrite slot B with fresh noise that matches slot A's size. The slot400  // remains occupied so its mere existence does not signal that duress was401  // ever configured.402  const slotA = await loadSlot(SLOT_A);403  if (slotA) {404    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));405  }406407  duressConfigured = false;408  await persistSlotA();409}410411export async function verifyMainPassword(password: string): Promise<boolean> {412  await ensureMigrated();413  const slotA = await loadSlot(SLOT_A);414  if (!slotA) return false;415  const key = await deriveKey(password, unhex(slotA.salt));416  try {417    return (await tryDecryptSlot(slotA, key)) !== null;418  } finally {419    zeroKey(key);420  }421}422423export async function unlock(password: string): Promise<boolean> {424  await ensureMigrated();425426  // Load both slots in parallel. Both always exist post-migration; the427  // parallel load and the parallel derivation below keep wall-clock time428  // constant regardless of which slot's passphrase (real or decoy) the429  // caller supplied, so a timing side channel cannot distinguish the430  // duress path from the real-unlock path (DUR-002).431  const [slotA, slotB] = await Promise.all([432    loadSlot(SLOT_A),433    loadSlot(SLOT_B),434  ]);435436  // Derive both keys in parallel, unconditionally.437  const [keyA, keyB] = await Promise.all([438    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),439    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),440  ]);441442  // Attempt both decrypts in parallel.443  const [payloadA, payloadB] = await Promise.all([444    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),445    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),446  ]);447448  if (payloadA && keyA) {449    derivedKey = keyA;450    vaultMode = "real";451    entries = payloadA.entries;452    duressConfigured = payloadA.duressConfigured;453    buildDomainIndexSync(entries);454    await loadSettings();455    touchAutoLock();456    if (keyB) zeroKey(keyB);457    // If the payload came from the pre-envelope (legacy) shape, rewrite458    // it immediately so the next save uses the new format.459    if (hasLegacyShape(payloadA.entries)) {460      await persistSlotA();461    }462    return true;463  }464465  if (payloadB && keyB) {466    derivedKey = keyB;467    vaultMode = "decoy";468    entries = payloadB.entries;469    duressConfigured = false;470    buildDomainIndexSync(entries);471    await loadSettings();472    touchAutoLock();473    if (keyA) zeroKey(keyA);474    return true;475  }476477  if (keyA) zeroKey(keyA);478  if (keyB) zeroKey(keyB);479  return false;480}481482export function lock(): void {483  if (derivedKey) {484    zeroKey(derivedKey);485    derivedKey = null;486  }487488  entries = [];489  domainIndex = new Map();490  vaultMode = "real";491  duressConfigured = false;492  clearAutoLock();493}494495export function isUnlocked(): boolean {496  return derivedKey !== null;497}498499export function getEntries(): VaultEntry[] {500  if (!derivedKey) throw new Error("vault locked");501  return entries.filter((entry) => !entry.deletedAt);502}503504export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {505  if (!derivedKey) throw new Error("vault locked");506507  const now = Date.now();508  const added: VaultEntry[] = [];509  for (const draft of drafts) {510    try {511      const sanitized = sanitizeEntryDraft(draft);512      const nextEntry: VaultEntry = {513        ...sanitized,514        id: crypto.randomUUID(),515        createdAt: now,516        modifiedAt: now,517      };518      entries.push(nextEntry);519      added.push(nextEntry);520    } catch {521      // Skip invalid drafts silently — caller can see diff between input and output length522    }523  }524525  if (added.length > 0) {526    await persistEntries();527  }528529  return added;530}531532/**533 * Restore a set of entries in full fidelity — preserves id, createdAt,534 * modifiedAt, and every other field unlike `batchAddEntries` which535 * assigns fresh identity. Used by the CJK phrase restore path where the536 * backup is the authoritative source of truth. `mode` controls how537 * entries already in the vault are treated.538 */539export async function restoreEntries(540  incoming: VaultEntry[],541  mode: "replace" | "merge"542): Promise<{ added: number; updated: number; skipped: number }> {543  if (!derivedKey) throw new Error("vault locked");544545  // A replace with zero incoming entries is almost certainly a caller bug546  // (e.g. an import that silently dropped everything). Refuse rather than547  // wipe the vault.548  if (mode === "replace" && incoming.length === 0) {549    throw new Error("refuse to replace with empty entry set");550  }551552  let added = 0;553  let updated = 0;554  let skipped = 0;555556  if (mode === "replace") {557    entries = [];558    for (const entry of incoming) {559      const normalized = sanitizeRestoredEntry(entry);560      if (!normalized) {561        skipped += 1;562        continue;563      }564      entries.push(normalized);565      added += 1;566    }567  } else {568    const byId = new Map<string, VaultEntry>();569    for (const e of entries) byId.set(e.id, e);570571    for (const entry of incoming) {572      const normalized = sanitizeRestoredEntry(entry);573      if (!normalized) {574        skipped += 1;575        continue;576      }577      const existing = byId.get(normalized.id);578      if (!existing) {579        entries.push(normalized);580        added += 1;581      } else if (normalized.modifiedAt > existing.modifiedAt) {582        const idx = entries.findIndex((e) => e.id === existing.id);583        if (idx !== -1) {584          entries[idx] = normalized;585          updated += 1;586        }587      } else {588        skipped += 1;589      }590    }591  }592593  await persistEntries();594  return { added, updated, skipped };595}596597/**598 * Validate + normalise a VaultEntry coming from a backup. Accepts599 * anything structurally sound; keeps all metadata verbatim. Returns600 * null for values the live vault shouldn't accept (e.g., missing title).601 */602function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {603  if (!entry || typeof entry !== "object") return null;604  const title = typeof entry.title === "string" ? entry.title.trim() : "";605  if (!title) return null;606  const type = entry.type ?? "login";607  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();608  const now = Date.now();609  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;610  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;611612  if (type === "login" && typeof entry.password !== "string") {613    return null;614  }615  if (616    type === "passkey" &&617    (typeof entry.rpId !== "string" ||618      typeof entry.credentialId !== "string" ||619      !entry.privateKeyJwk)620  ) {621    return null;622  }623624  return {625    ...entry,626    id,627    title,628    type,629    createdAt,630    modifiedAt,631  };632}633634export async function clearVaultEntries(635  confirmation: "REPLACE_ALL"636): Promise<void> {637  if (confirmation !== "REPLACE_ALL") {638    throw new Error("clearVaultEntries requires explicit confirmation");639  }640  if (!derivedKey) throw new Error("vault locked");641  for (const entry of entries) {642    if (!entry.deletedAt) {643      entry.deletedAt = Date.now();644      entry.modifiedAt = Date.now();645    }646  }647  await persistEntries();648}649650export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {651  if (!derivedKey) throw new Error("vault locked");652653  const sanitized = sanitizeEntryDraft(entry);654  const now = Date.now();655  const nextEntry: VaultEntry = {656    ...sanitized,657    id: crypto.randomUUID(),658    createdAt: now,659    modifiedAt: now,660  };661662  entries.push(nextEntry);663  await persistEntries();664665  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)666  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {667    checkBreach(nextEntry.password).then(async (count) => {668      if (count > 0) {669        const idx = entries.findIndex((e) => e.id === nextEntry.id);670        if (idx !== -1) {671          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };672          await persistEntries();673        }674      }675    });676  }677678  return nextEntry;679}680681export async function updateEntry(682  id: string,683  patch: Partial<VaultEntry>684): Promise<void> {685  if (!derivedKey) throw new Error("vault locked");686687  const idx = entries.findIndex((entry) => entry.id === id);688  if (idx === -1 || entries[idx].deletedAt) {689    throw new Error("entry not found");690  }691692  const current = entries[idx];693  entries[idx] = {694    ...current,695    ...sanitizeEntryPatch(patch),696    modifiedAt: Date.now(),697  };698699  // sanitizeEntryPatch maps an empty password to `undefined`. For login700  // entries that would silently produce a passwordless record — roll back701  // the merge and refuse.702  if (entries[idx].type === "login" && !entries[idx].password) {703    entries[idx] = current;704    throw new Error("password is required for login entries");705  }706707  await persistEntries();708709  // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)710  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {711    const password = entries[idx].password!;712    checkBreach(password).then(async (count) => {713      const i = entries.findIndex((e) => e.id === id);714      if (i !== -1) {715        entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };716        await persistEntries();717      }718    });719  }720}721722export async function deleteEntry(id: string): Promise<void> {723  if (!derivedKey) throw new Error("vault locked");724725  const entry = entries.find((item) => item.id === id);726  if (!entry || entry.deletedAt) {727    throw new Error("entry not found");728  }729730  entry.deletedAt = Date.now();731  entry.modifiedAt = Date.now();732  await persistEntries();733}734735export function getDeletedEntries(): VaultEntry[] {736  if (!derivedKey) return [];737  return entries.filter((e) => !!e.deletedAt);738}739740export async function restoreEntry(id: string): Promise<void> {741  if (!derivedKey) throw new Error("vault locked");742  const entry = entries.find((e) => e.id === id && e.deletedAt);743  if (!entry) throw new Error("deleted entry not found");744  delete entry.deletedAt;745  entry.modifiedAt = Date.now();746  await persistEntries();747  buildDomainIndexSync(entries);748}749750export async function purgeEntry(id: string): Promise<void> {751  if (!derivedKey) throw new Error("vault locked");752  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);753  if (idx === -1) throw new Error("deleted entry not found");754  entries.splice(idx, 1);755  await persistEntries();756}757758/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */759export async function purgeExpiredTrash(retentionMs: number): Promise<number> {760  if (!derivedKey || retentionMs === 0) return 0;761  const cutoff = Date.now() - retentionMs;762  const before = entries.length;763  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));764  const purged = before - entries.length;765  if (purged > 0) await persistEntries();766  return purged;767}768769export function queryDomain(domain: string): VaultEntry[] {770  return domainIndex.get(domain) ?? [];771}772773export function getEntryById(id: string): VaultEntry | undefined {774  return entries.find((entry) => entry.id === id && !entry.deletedAt);775}776777async function persistSlotA(): Promise<StoredVault> {778  if (!derivedKey) throw new Error("vault locked");779780  const slotA = await loadSlot(SLOT_A);781  if (!slotA) throw new Error("missing slot A");782783  const plaintext = encodePayload(entries, duressConfigured);784  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);785786  const nextSlotA: StoredVault = {787    ...slotA,788    nonce,789    ciphertext,790    tag,791  };792  await writeSlot(SLOT_A, nextSlotA);793794  // When duress is not configured, keep slot B size-matched with slot A by795  // regenerating its noise. When duress IS configured, leave slot B alone —796  // we don't have the decoy key to re-encrypt it. This is a known trade-off.797  if (!duressConfigured) {798    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));799  }800801  return nextSlotA;802}803804async function persistSlotB(): Promise<void> {805  if (!derivedKey) throw new Error("vault locked");806807  const slotB = await loadSlot(SLOT_B);808  if (!slotB) throw new Error("missing slot B");809810  // Decoy plaintext always reports duressConfigured=false — the decoy user811  // sees a clean slate.812  const plaintext = encodePayload(entries, false);813  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);814815  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });816}817818async function persistEntries(): Promise<void> {819  if (!derivedKey) throw new Error("vault locked");820821  if (vaultMode === "decoy") {822    await persistSlotB();823  } else {824    const freshSlotA = await persistSlotA();825    // Save a snapshot of the real vault after every successful write.826    // Failures are silently swallowed — snapshots must never interrupt saves.827    const activeCount = entries.filter((e) => !e.deletedAt).length;828    void takeSnapshot(freshSlotA, activeCount);829  }830  buildDomainIndexSync(entries);831}832833function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {834  const title = entry.title.trim();835  const type = entry.type ?? "login";836837  if (!title) throw new Error("title is required");838  if (type === "login" && !entry.password?.trim()) {839    throw new Error("password is required");840  }841  if (type === "passkey") {842    if (843      !entry.rpId?.trim() ||844      !entry.rpName?.trim() ||845      !entry.credentialId?.trim() ||846      !entry.publicKeyCose?.trim() ||847      !entry.privateKeyJwk ||848      !entry.userHandle?.trim() ||849      !entry.userName?.trim() ||850      !entry.userDisplayName?.trim() ||851      (entry.algorithm !== -7 && entry.algorithm !== -8)852    ) {853      throw new Error("invalid passkey entry");854    }855  }856857  return {858    type,859    title,860    username: type === "login" ? entry.username?.trim() ?? "" : undefined,861    password: type === "login" ? entry.password : undefined,862    url: type === "login" ? entry.url?.trim() ?? "" : undefined,863    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,864    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,865    breach: type === "login" ? entry.breach : undefined,866    canary: type === "login" ? entry.canary : undefined,867    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,868    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,869    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,870    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,871    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,872    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,873    userName: type === "passkey" ? entry.userName?.trim() : undefined,874    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,875    algorithm: type === "passkey" ? entry.algorithm : undefined,876    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,877    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,878    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,879    backupState: type === "passkey" ? entry.backupState === true : undefined,880    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,881  };882}883884function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {885  const nextPatch: Partial<VaultEntry> = {};886887  if ("type" in patch) {888    nextPatch.type = patch.type;889  }890891  if ("title" in patch) {892    const title = patch.title?.trim() ?? "";893    if (!title) throw new Error("title is required");894    nextPatch.title = title;895  }896897  if ("url" in patch) {898    nextPatch.url = patch.url?.trim() ?? "";899  }900901  if ("username" in patch) {902    nextPatch.username = patch.username?.trim() ?? "";903  }904905  if ("password" in patch) {906    const password = patch.password ?? "";907    // Allow clearing a password (e.g. on passkey-only entries)908    nextPatch.password = password || undefined;909  }910911  if ("totp" in patch) {912    nextPatch.totp = patch.totp?.trim() || undefined;913  }914915  if ("notes" in patch) {916    nextPatch.notes = patch.notes?.trim() || undefined;917  }918919  if ("breach" in patch) {920    nextPatch.breach = patch.breach;921  }922923  if ("canary" in patch) {924    nextPatch.canary = patch.canary;925  }926927  if ("rpId" in patch) {928    nextPatch.rpId = patch.rpId?.trim();929  }930931  if ("rpName" in patch) {932    nextPatch.rpName = patch.rpName?.trim();933  }934935  if ("credentialId" in patch) {936    nextPatch.credentialId = patch.credentialId?.trim();937  }938939  if ("publicKeyCose" in patch) {940    nextPatch.publicKeyCose = patch.publicKeyCose?.trim();941  }942943  if ("privateKeyJwk" in patch) {944    nextPatch.privateKeyJwk = patch.privateKeyJwk;945  }946947  if ("userHandle" in patch) {948    nextPatch.userHandle = patch.userHandle?.trim();949  }950951  if ("userName" in patch) {952    nextPatch.userName = patch.userName?.trim();953  }954955  if ("userDisplayName" in patch) {956    nextPatch.userDisplayName = patch.userDisplayName?.trim();957  }958959  if ("algorithm" in patch) {960    nextPatch.algorithm = patch.algorithm;961  }962963  if ("signCount" in patch) {964    nextPatch.signCount = patch.signCount;965  }966967  if ("aaguid" in patch) {968    nextPatch.aaguid = patch.aaguid?.trim();969  }970971  if ("backupEligible" in patch) {972    nextPatch.backupEligible = patch.backupEligible;973  }974975  if ("backupState" in patch) {976    nextPatch.backupState = patch.backupState;977  }978979  if ("lastUsedAt" in patch) {980    nextPatch.lastUsedAt = patch.lastUsedAt;981  }982983  return nextPatch;984}985986function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {987  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;988  const modifiedAt =989    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;990991  return {992    id: entry.id,993    type: "type" in entry ? entry.type : "login",994    title: entry.title,995    username: entry.username,996    password: entry.password,997    url: entry.url,998    totp: entry.totp,999    notes: entry.notes,1000    breach: entry.breach,1001    canary: "canary" in entry ? entry.canary : undefined,1002    rpId: "rpId" in entry ? entry.rpId : undefined,1003    rpName: "rpName" in entry ? entry.rpName : undefined,1004    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1005    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1006    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1007    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1008    userName: "userName" in entry ? entry.userName : undefined,1009    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1010    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1011    signCount: "signCount" in entry ? entry.signCount : undefined,1012    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1013    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1014    backupState: "backupState" in entry ? entry.backupState : undefined,1015    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1016    createdAt,1017    modifiedAt,1018    deletedAt: entry.deletedAt,1019    tags: "tags" in entry ? entry.tags : undefined,1020  };1021}10221023function hasLegacyShape(parsed: VaultEntry[]): boolean {1024  return parsed.some(1025    (entry) => "created" in entry || "updated" in entry1026  );1027}10281029// ---------------------------------------------------------------------------1030// Deduplication1031// ---------------------------------------------------------------------------10321033function entryDupeKey(e: VaultEntry): string {1034  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1035  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1036  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1037}10381039/** Returns the number of duplicate entries (those that would be removed). */1040export function countDuplicates(): number {1041  const best = new Map<string, VaultEntry>();1042  let dupeCount = 0;1043  for (const e of entries.filter((e) => !e.deletedAt)) {1044    const key = entryDupeKey(e);1045    const existing = best.get(key);1046    if (!existing) {1047      best.set(key, e);1048    } else {1049      dupeCount++;1050      // Keep the more recently modified one in the map1051      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1052        best.set(key, e);1053      }1054    }1055  }1056  return dupeCount;1057}10581059/**1060 * Soft-deletes duplicate entries, keeping the most recently modified copy of1061 * each group. Returns the number of entries moved to trash.1062 */1063export async function deduplicateEntries(): Promise<number> {1064  if (!derivedKey) throw new Error("vault locked");1065  // Dedup reshapes slot B permanently under the decoy key, which is1066  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1067  if (vaultMode === "decoy") {1068    throw new Error("deduplicate disabled in decoy mode");1069  }10701071  const best = new Map<string, VaultEntry>();1072  for (const e of entries.filter((en) => !en.deletedAt)) {1073    const key = entryDupeKey(e);1074    const existing = best.get(key);1075    if (!existing) {1076      best.set(key, e);1077    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1078      best.set(key, e);1079    }1080  }10811082  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1083  const now = Date.now();1084  let removed = 0;1085  entries = entries.map((e) => {1086    if (!e.deletedAt && !keepIds.has(e.id)) {1087      removed++;1088      return { ...e, deletedAt: now };1089    }1090    return e;1091  });10921093  if (removed > 0) await persistEntries();1094  return removed;1095}10961097// ---------------------------------------------------------------------------1098// Vault snapshot system — last 6 encrypted states, used for self-recovery1099// ---------------------------------------------------------------------------11001101const SNAPSHOT_KEY = "peach_vault_snapshots";1102const MAX_SNAPSHOTS = 6;11031104interface VaultSnapshot {1105  timestamp: number;1106  entryCount: number;1107  slot: StoredVault;1108}11091110/** Public summary (no sensitive data) returned to the popup. */1111export interface SnapshotSummary {1112  index: number;1113  timestamp: number;1114  entryCount: number;1115}11161117async function takeSnapshot(1118  slot: StoredVault,1119  entryCount: number1120): Promise<void> {1121  try {1122    const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1123    const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1124    const next: VaultSnapshot[] = [1125      { timestamp: Date.now(), entryCount, slot },1126      ...existing,1127    ].slice(0, MAX_SNAPSHOTS);1128    await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1129  } catch {1130    // Non-fatal — snapshot failure must never interrupt vault saves1131  }1132}11331134export async function listSnapshots(): Promise<SnapshotSummary[]> {1135  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1136  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1137  return snapshots.map((s, i) => ({1138    index: i,1139    timestamp: s.timestamp,1140    entryCount: s.entryCount,1141  }));1142}11431144/**1145 * Restore a snapshot by index. The password must decrypt that snapshot1146 * (i.e., the same master password used when the snapshot was taken).1147 * On success the vault is unlocked in-memory with the restored entries1148 * — no re-login required.1149 */1150export async function restoreFromSnapshot(1151  index: number,1152  password: string1153): Promise<boolean> {1154  const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1155  const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1156  const snapshot = snapshots[index];1157  if (!snapshot) return false;11581159  // Verify the password decrypts this snapshot1160  const key = await deriveKey(password, unhex(snapshot.slot.salt));1161  const payload = await tryDecryptSlot(snapshot.slot, key);1162  if (!payload) {1163    zeroKey(key);1164    return false;1165  }11661167  // Write the snapshot back to slot A. Regenerate noise for slot B.1168  await writeSlot(SLOT_A, snapshot.slot);1169  await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));11701171  // Swap in-memory state to the restored vault — caller gets immediate access1172  if (derivedKey) zeroKey(derivedKey);1173  derivedKey = key;1174  entries = payload.entries;1175  duressConfigured = payload.duressConfigured;1176  vaultMode = "real";1177  buildDomainIndexSync(entries);1178  await loadSettings();1179  touchAutoLock();11801181  return true;1182}11831184/**1185 * Change the master password. Re-encrypts slot A with a fresh salt + new1186 * derived key. Slot B (duress or noise) is left untouched when duress is1187 * configured — it uses an independent key derived from the duress password —1188 * and regenerated as noise when duress is not configured.1189 */1190export async function changePassword(1191  currentPassword: string,1192  newPassword: string1193): Promise<boolean> {1194  if (!derivedKey || vaultMode !== "real") return false;11951196  // Verify current password before allowing any change1197  const valid = await verifyMainPassword(currentPassword);1198  if (!valid) return false;11991200  const slotA = await loadSlot(SLOT_A);1201  if (!slotA) return false;12021203  // Derive fresh key from new password + new random salt1204  const newSalt = generateSalt();1205  const newKey = await deriveKey(newPassword, newSalt);12061207  // Re-encrypt the in-memory entries with the new key1208  const plaintext = encodePayload(entries, duressConfigured);1209  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);12101211  const freshSlotA: StoredVault = {1212    ...slotA,1213    salt: hex(newSalt),1214    nonce,1215    ciphertext,1216    tag,1217  };1218  await writeSlot(SLOT_A, freshSlotA);12191220  // Swap the in-memory key synchronously before any further await. If we1221  // threw between the Slot A write and this swap, the next persistEntries()1222  // would overwrite Slot A with old-key ciphertext, locking the user out.1223  zeroKey(derivedKey);1224  derivedKey = newKey;12251226  // Snapshot now that in-memory key and disk are consistent. Pass the1227  // slot content we just wrote rather than re-reading from storage so the1228  // snapshot is immune to any microtask-coalesced read-after-write race.1229  const activeCount = entries.filter((e) => !e.deletedAt).length;1230  void takeSnapshot(freshSlotA, activeCount);12311232  // Regenerate noise for slot B when duress is not in use. Runs after the1233  // key swap so a failure here is a disposable noise regeneration, not a1234  // lockout.1235  if (!duressConfigured) {1236    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1237  }12381239  return true;1240}