vault.ts

86.8 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import {15  rootKeyToWords,16  wordsToRootKeyMaterial,17  deriveRecoveryKey,18} from "../shared/recovery-words";19import type {20  AccentColorName,21  LegacyVaultEntry,22  ManualSummonRecord,23  SavedIdentity,24  StoredVault,25  VaultEntry,26  VaultHistory,27  VaultSnapshot,28} from "../shared/types";29import { decrypt, encrypt, zeroKey } from "./aes";30import { clearAutoLock, touchAutoLock } from "./alarms";3132type EntryDraft = Omit<33  VaultEntry,34  "id" | "createdAt" | "modifiedAt" | "deletedAt"35>;3637// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B38// either holds the decoy vault (when duress is configured) or random noise39// that looks identical at rest. No external indicator is stored — duress40// configuration is recorded only inside the encrypted payload of slot A.41const SLOT_A = "peach_vault_a";42const SLOT_B = "peach_vault_b";4344// Legacy keys, migrated away on first access.45const LEGACY_MAIN = "peach_vault";46const LEGACY_DECOY = "peach_decoy_vault";47const LEGACY_DURESS_FLAG = "peach_duress";4849// Plaintext is padded to the next multiple of this size before encryption.50// This coarsens ciphertext-length leakage into 1 KiB buckets.51const PAD_BLOCK = 1024;5253let derivedKey: Uint8Array | null = null;54// 32-byte random root key, separate from the derivedKey used for vault encryption.55// The root key is wrapped (encrypted) under the recovery key derived from the56// 12-word phrase. When the user changes their password, only the wrapping changes;57// the root key and recovery words remain stable.58let rootKey: Uint8Array | null = null;59let entries: VaultEntry[] = [];60let domainIndex: Map<string, VaultEntry[]> = new Map();61let vaultMode: "real" | "decoy" = "real";62// Cached post-unlock view of whether the real vault records that duress is63// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock64// this stays false because we never see slot A's plaintext.65let duressConfigured = false;6667let migrationRan = false;6869// Set to a corruption reason during the last unlock() attempt when both slots70// decrypted but failed schema validation. Consumed and cleared by71// consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted72// distinctly from wrong-password (M7/COR-007).73//74// NOTE: module-level state — if vault.ts is ever moved to a worker context,75// this must be replaced with a structured return value from unlock() or a76// postMessage channel. The signal is invisible across module-instance77// boundaries.78let lastUnlockFailureReason: string | null = null;7980export function consumeLastUnlockFailureReason(): string | null {81  const r = lastUnlockFailureReason;82  lastUnlockFailureReason = null;83  return r;84}8586// ---------------------------------------------------------------------------87// Settings (persisted inside the encrypted vault store as a sidecar key)88// ---------------------------------------------------------------------------8990export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";91export type TextSize = "small" | "medium" | "large";9293export interface VaultSettings {94  accentColor?: AccentColorName;95  theme?: "dark" | "light";96  autoLockMinutes?: number;97  clipboardClearSeconds?: number;98  lockOnClose?: boolean;99  breachMonitoring?: boolean;100  passkeyProviderEnabled?: boolean;101  trashRetentionDays?: number;102  manualSummonDomains?: Record<string, ManualSummonRecord>;103  declinedCardFingerprints?: string[];104  /**105   * Display font for headlines, brand wordmark, and narrative copy. Default106   * is "cormorant-italic" — the bold italic serif that ships as Peach's107   * voice. Non-italic options exist for users who prefer upright type.108   */109  displayFont?: DisplayFont;110  /**111   * Text size scale for body copy and entry titles. Stops at "large" by112   * design — XL clips dense screens (Vault, Health) inside the fixed113   * 330×480 popup surface.114   */115  textSize?: TextSize;116  /**117   * When true, all entrance/exit transitions and ambient animations are118   * collapsed to instant. Honoured alongside the user's OS-level119   * `prefers-reduced-motion`.120   */121  reduceMotion?: boolean;122  /**123   * Android-only: when true, the master key is escrowed in AndroidKeyStore124   * and the lock screen offers biometric unlock. Toggled from Settings →125   * Security → Biometrics. Has no effect on desktop builds.126   */127  biometricsEnabled?: boolean;128  /**129   * User overrides for keyboard shortcuts. Keys are ShortcutAction names130   * (see src/shared/shortcuts.ts). Missing actions fall through to131   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the132   * canonical lookup. Storing only the deltas keeps the vault payload133   * small and lets default updates land for users who never rebind.134   */135  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;136}137138let settings: VaultSettings = {};139140export function getSettings(): VaultSettings {141  return settings;142}143144export async function ensureSettingsLoaded(): Promise<VaultSettings> {145  await loadSettings();146  return settings;147}148149export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {150  await loadSettings();151  settings = { ...settings, ...patch };152  await persistSettings();153}154155async function persistSettings(): Promise<void> {156  try {157    await chrome.storage.local.set({ peach_settings: settings });158  } catch (err) {159    throw mapStorageError(err);160  }161}162163function mapStorageError(err: unknown): Error {164  const message = err instanceof Error ? err.message : String(err);165  if (/QUOTA_BYTES|quota/i.test(message)) {166    return new Error("STORAGE_QUOTA_EXCEEDED");167  }168  return err instanceof Error ? err : new Error(message);169}170171async function loadSettings(): Promise<void> {172  const result = await chrome.storage.local.get("peach_settings");173  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};174}175176// ---------------------------------------------------------------------------177// HIBP breach check (k-anonymity model)178// ---------------------------------------------------------------------------179180export async function checkBreach(password: string): Promise<number> {181  // HIBP k-anonymity API is keyed on SHA-1 prefixes — this is dictated by182  // the public API, not a security choice. Do not migrate to SHA-256 here.183  const encoder = new TextEncoder();184  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));185  const hashHex = Array.from(new Uint8Array(hashBuffer))186    .map((b) => b.toString(16).padStart(2, "0"))187    .join("")188    .toUpperCase();189190  const prefix = hashHex.slice(0, 5);191  const suffix = hashHex.slice(5);192193  try {194    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);195    if (!res.ok) return 0;196    const text = await res.text();197    const match = text.split("\r\n").find((l) => l.startsWith(suffix));198    return match ? parseInt(match.split(":")[1], 10) : 0;199  } catch {200    return 0; // Network unavailable — don't block201  }202}203204export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {205  const fingerprint = await cardFingerprint(last4, expMonth, expYear);206  await loadSettings();207  settings = {208    ...settings,209    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],210  };211  await persistSettings();212}213214export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {215  await loadSettings();216  const fingerprint = await cardFingerprint(last4, expMonth, expYear);217  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);218}219220async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {221  const encoder = new TextEncoder();222  const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));223  return Array.from(new Uint8Array(buf).slice(0, 8))224    .map((b) => b.toString(16).padStart(2, "0"))225    .join("");226}227228function hex(buf: Uint8Array): string {229  return Array.from(buf)230    .map((b) => b.toString(16).padStart(2, "0"))231    .join("");232}233234function unhex(s: string): Uint8Array {235  const out = new Uint8Array(s.length / 2);236  for (let i = 0; i < s.length; i += 2) {237    out[i / 2] = parseInt(s.slice(i, i + 2), 16);238  }239  return out;240}241242// ---------------------------------------------------------------------------243// Root key generation and recovery word wrapping244// ---------------------------------------------------------------------------245246/**247 * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key248 * derived from the recovery word material via HKDF-SHA256. The wrapped blob249 * fields are stored in slot A alongside the password-wrapped ciphertext. The250 * root key itself is kept in memory for the session so getRecoveryWords() can251 * encode it without re-deriving.252 */253async function generateAndWrapRootKey(): Promise<{254  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey255  recoverySalt: string;256  wrappedRootKey: string;257  wrappedNonce: string;258  wrappedTag: string;259}> {260  const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));261262  // Derive the recovery key from the BIP39 words encoding of the root key.263  const words = await rootKeyToWords(rootKeyBytes);264  const wordMaterial = await wordsToRootKeyMaterial(words);265  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));266  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);267268  // Wrap the root key with the recovery key using AES-256-GCM directly.269  // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.270  const nonce = crypto.getRandomValues(new Uint8Array(12));271  const encoded = new TextEncoder().encode(hex(rootKeyBytes));272  const raw = await crypto.subtle.encrypt(273    {274      name: "AES-GCM",275      iv: nonce,276      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,277      tagLength: 128,278    },279    recoveryKey,280    encoded281  );282  // WebCrypto returns ciphertext || auth tag concatenated; split them.283  const rawU8 = new Uint8Array(raw);284  const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));285  const tag = hex(rawU8.slice(rawU8.length - 16));286287  return {288    rawRootKey: hex(rootKeyBytes),289    recoverySalt: hex(recoverySalt),290    wrappedRootKey: ciphertext,291    wrappedNonce: hex(nonce),292    wrappedTag: tag,293  };294}295296/**297 * Unwrap the root key using recovery words. Called during recovery flow when298 * the user provides their 12-word phrase and a new password.299 * Exported for use by the RECOVER_WITH_WORDS message handler.300 */301export async function unwrapRootKeyForRecovery(302  wrappedRootKey: string,303  wrappedNonce: string,304  wrappedTag: string,305  recoverySaltHex: string,306  words: string[]307): Promise<void> {308  const wordMaterial = await wordsToRootKeyMaterial(words);309  const recoverySalt = unhex(recoverySaltHex);310  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);311312  // Decrypt and cache the root key. The root key bytes were hex-encoded before313  // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt314  // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.315  // Each component is stored separately in the vault slot; decode them individually316  // so the nonce is always exactly the 12 bytes from wrappedNonce.317  const nonceRaw = unhex(wrappedNonce);318  const ciphertextRaw = unhex(wrappedRootKey);319  const tagRaw = unhex(wrappedTag);320  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).321  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.322  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;323  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);324  combined.set(ciphertextRaw, 0);325  combined.set(tagRaw, ciphertextRaw.length);326  const raw = await crypto.subtle.decrypt(327    { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },328    recoveryKey,329    combined330  );331  const decryptedHex = new TextDecoder().decode(raw);332  rootKey = unhex(decryptedHex);333}334335/**336 * Re-wrap the cached root key under a new derived key (from the user's new337 * password). Called after password change and after recovery re-key so the338 * same recovery words continue to work with the updated password — and so339 * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.340 *341 * recovery_* fields are intentionally not touched: those wrap rootKey under342 * the recovery words, which do not change when the password changes.343 */344export async function rewrapRootKeyForPassword(345  newDerivedKey: Uint8Array,346  _newSalt: Uint8Array347): Promise<void> {348  if (!rootKey) throw new Error("root key not set");349  const slotA = await loadSlot(SLOT_A);350  if (!slotA) throw new Error("missing slot A");351  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);352  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });353}354355/**356 * Change the vault encryption to a new derived key (already derived from357 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.358 * Caller is responsible for key swap and snapshot.359 */360async function reencryptVaultForNewKey(361  newKey: Uint8Array,362  slotA: StoredVault363): Promise<void> {364  const plaintext = encodePayload(entries, duressConfigured);365  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");366  await writeSlot(SLOT_A, {367    ...slotA,368    nonce,369    ciphertext,370    tag,371  });372}373374/**375 * Full password change with an already-derived key (used after recovery).376 * Unlike changePassword(), this skips re-verification of current password377 * since recovery flow has already validated the user's identity via words.378 */379export async function changePasswordWithNewKey(380  newSalt: Uint8Array,381  newKey: Uint8Array382): Promise<void> {383  const slotA = await loadSlot(SLOT_A);384  if (!slotA) throw new Error("missing slot A");385  // reencryptVaultForNewKey must receive the updated salt so that subsequent386  // UNLOCK calls with the new password can re-derive the correct key.387  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });388  if (derivedKey) zeroKey(derivedKey);389  derivedKey = newKey;390  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works391  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow392  // should have populated it — fall back silently rather than blowing up the393  // recovery path; migrateToRecovery will rehydrate on the next unlock.394  if (rootKey) {395    const slotAfter = await loadSlot(SLOT_A);396    if (slotAfter) {397      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);398      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });399    }400  }401  if (!duressConfigured) {402    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));403  }404  await stampLocalVaultMutation();405}406407// ---------------------------------------------------------------------------408// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive409// a new key after password reset without re-importing the crypto module.410// ---------------------------------------------------------------------------411412export { deriveKey, generateSalt };413414export async function loadSlotA(): Promise<StoredVault | null> {415  return loadSlot(SLOT_A);416}417418/**419 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields420 * (a second wrapping of the root key under the password-derived key). This421 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without422 * the user re-entering their recovery phrase: the same root key bytes —423 * and therefore the same 12 words — survive every unlock.424 *425 * Returns null when the pw_root fields are absent (pre-v2.12 vault that426 * hasn't been migrated yet) or when decryption fails (wrong derived key,427 * tampered ciphertext). On null the caller should fall through to428 * migrateToRecovery() to populate the fields.429 */430async function deriveRootKeyFromDerivedKey(431  derivedKeyBytes: Uint8Array,432  slotA: StoredVault,433): Promise<Uint8Array | null> {434  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {435    return null;436  }437  try {438    const rootHex = await decrypt(439      derivedKeyBytes,440      slotA.pw_root_nonce,441      slotA.pw_root_ciphertext,442      slotA.pw_root_tag,443      "pw_root_v1",444    );445    return unhex(rootHex);446  } catch {447    return null;448  }449}450451// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the452// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.453async function wrapRootKeyForDerivedKey(454  derivedKeyBytes: Uint8Array,455  rootKeyBytes: Uint8Array,456): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {457  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");458  return {459    pw_root_ciphertext: ciphertext,460    pw_root_nonce: nonce,461    pw_root_tag: tag,462  };463}464465/**466 * For vaults created before v2.12 (no recovery_salt/recovery_wrapped_root_key),467 * generate the root key, wrap it under the recovery words, and persist the468 * recovery escrow into slot A. The caller must show the recovery words to the469 * user before calling this — once called, those words are the vault's recovery470 * path. This runs once, on the first unlock of an upgraded vault.471 */472export async function migrateToRecovery(): Promise<void> {473  if (!derivedKey || vaultMode !== "real") {474    throw new Error("vault must be unlocked in real mode");475  }476  const slotA = await loadSlot(SLOT_A);477  if (!slotA) throw new Error("missing slot A");478  const hasRecoveryEscrow =479    !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;480  const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;481  if (hasRecoveryEscrow && hasPwRoot) return; // already done482483  // If recovery escrow already exists but pw_root doesn't, we cannot recover484  // the original rootKey without the user's words. The least-surprising path485  // is to rotate to a fresh root key — the user's old recovery words become486  // invalid but we surface peach_recovery_pending so the popup re-presents487  // the words. The unlock-path callers all set that flag.488  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =489    await generateAndWrapRootKey();490  rootKey = unhex(rawRootKey);491492  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);493  const nextSlotA: StoredVault = {494    ...slotA,495    recovery_salt: recoverySalt,496    recovery_wrapped_root_key: wrappedRootKey,497    recovery_nonce: wrappedNonce,498    recovery_tag: wrappedTag,499    ...pwRoot,500  };501  await writeSlot(SLOT_A, nextSlotA);502  await stampLocalVaultMutation();503}504505/**506 * Returns the 12 recovery words for the current session's root key.507 * Must be called while the vault is unlocked. Throws if recovery has not508 * been set up (pre-v2.12 vault that hasn't migrated yet).509 */510export async function getRecoveryWords(): Promise<string[]> {511  if (!rootKey) {512    throw new Error("recovery not set up — vault may need migration");513  }514  return rootKeyToWords(rootKey);515}516517async function loadSlot(key: string): Promise<StoredVault | null> {518  const result = await chrome.storage.local.get(key);519  return (result as Record<string, StoredVault | undefined>)[key] ?? null;520}521522async function writeSlot(key: string, vault: StoredVault): Promise<void> {523  try {524    await chrome.storage.local.set({ [key]: vault });525  } catch (err) {526    throw mapStorageError(err);527  }528}529530async function stampLocalVaultMutation(): Promise<void> {531  await chrome.storage.local.set({532    peach_last_modified_at: String(Date.now()),533  });534}535536/**537 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault538 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +539 * peach_vault_b). Runs at most once per session; safe to call repeatedly.540 */541async function ensureMigrated(): Promise<void> {542  if (migrationRan) return;543  migrationRan = true;544545  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];546  const raw = await chrome.storage.local.get(keys);547  const slotAExists = raw[SLOT_A] !== undefined;548  const slotBExists = raw[SLOT_B] !== undefined;549  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;550  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;551552  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.553  if (!slotAExists && !legacyMain) return;554555  // Already migrated.556  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;557558  let didMigrate = false;559  if (legacyMain && !slotAExists) {560    await writeSlot(SLOT_A, legacyMain);561    didMigrate = true;562  }563564  // If slot B isn't set, populate it: use legacy decoy when present, else noise.565  if (!slotBExists) {566    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;567    if (legacyDecoy) {568      await writeSlot(SLOT_B, legacyDecoy);569      didMigrate = true;570    } else if (target) {571      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));572      didMigrate = true;573    }574  }575576  const toRemove: string[] = [];577  if (legacyMain) toRemove.push(LEGACY_MAIN);578  if (legacyDecoy) toRemove.push(LEGACY_DECOY);579  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);580  if (toRemove.length > 0) {581    await chrome.storage.local.remove(toRemove);582    didMigrate = true;583  }584  if (didMigrate) {585    await stampLocalVaultMutation();586  }587}588589// ---------------------------------------------------------------------------590// Padding + noise helpers591// ---------------------------------------------------------------------------592593/**594 * Pads the plaintext string with trailing spaces so that the encoded byte595 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing596 * whitespace after a complete document, so the padding is transparent on597 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.598 */599function padPlaintext(json: string): string {600  const encoded = new TextEncoder().encode(json);601  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);602  const padLen = target - encoded.length;603  return json + " ".repeat(padLen);604}605606/**607 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The608 * ciphertext length matches the given byte count. Because GCM ciphertext is609 * pseudo-random, this blob is indistinguishable at rest from a real610 * encrypted vault without the key.611 *612 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto613 * spec hard limit). Chunk the fill so vaults larger than that — common once614 * the user has dozens of entries or any passkey — don't throw.615 */616const CSPRNG_CHUNK = 65_536;617618function fillRandom(buf: Uint8Array): Uint8Array {619  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {620    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);621    const chunk = new Uint8Array(end - offset);622    crypto.getRandomValues(chunk);623    buf.set(chunk, offset);624  }625  return buf;626}627628function generateNoiseBlob(ciphertextBytes: number): StoredVault {629  const salt = crypto.getRandomValues(new Uint8Array(16));630  const nonce = crypto.getRandomValues(new Uint8Array(12));631  const cipher = fillRandom(new Uint8Array(ciphertextBytes));632  const tag = crypto.getRandomValues(new Uint8Array(16));633  return {634    version: 1,635    salt: hex(salt),636    nonce: hex(nonce),637    ciphertext: hex(cipher),638    tag: hex(tag),639  };640}641642// ---------------------------------------------------------------------------643// Encrypted plaintext envelope644// ---------------------------------------------------------------------------645646type VaultPayload = {647  entries: VaultEntry[];648  duressConfigured?: boolean;649};650651type DecryptedPayload = {652  entries: VaultEntry[];653  duressConfigured: boolean;654  /**655   * True when at least one entry in the on-disk payload had a legacy shape656   * (pre-v2.11.0 passkey type/flat fields, or pre-A-2 created/updated keys)657   * and got rewritten by normalizeEntry. The unlock path uses this to658   * persist the new shape immediately — without it, the migration would659   * re-run on every unlock and old shape never disappears from disk.660   */661  migratedFromLegacy: boolean;662};663664function encodePayload(entries: VaultEntry[], duress: boolean): string {665  const envelope: VaultPayload = { entries, duressConfigured: duress };666  return padPlaintext(JSON.stringify(envelope));667}668669function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {670  if ("created" in raw || "updated" in raw) return true;671  const flat = raw as Partial<VaultEntry>;672  if (flat.type === "passkey") return true;673  // Flat rpId without a passkey sub-object means pre-v2.11.0 schema.674  if (flat.rpId !== undefined && flat.passkey === undefined) return true;675  return false;676}677678function decodePayload(plain: string): DecryptedPayload {679  const parsed = JSON.parse(plain) as680    | Array<VaultEntry | LegacyVaultEntry>681    | VaultPayload;682683  const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];684  const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);685686  const normalized = rawEntries.map(normalizeEntry);687  const { merged } = mergePasskeyOnlyIntoLogins(normalized);688689  if (Array.isArray(parsed)) {690    return {691      entries: normalized,692      duressConfigured: false,693      migratedFromLegacy: true, // pre-A-2 array shape always needs save694    };695  }696697  return {698    entries: normalized,699    duressConfigured: parsed.duressConfigured === true,700    migratedFromLegacy: flatMigrationNeeded || merged,701  };702}703704/**705 * One-time consolidation pass run alongside the legacy passkey migration.706 *707 * Pre-v2.11.0 the Chrome passkey path always created a fresh `type:"passkey"`708 * entry per registration — so users who tested registration multiple times709 * accumulated several passkey-bearing entries for the same RP, and the710 * migration to the unified shape carried that duplication forward. This711 * pass collapses the duplicates:712 *713 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.714 *   2. Within each group, the most recently-active credential wins (prefer715 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).716 *   3. If a NON-passkey login entry exists for that RP (matched by url or717 *      additionalUrls hostname), attach the winning passkey to it and718 *      soft-delete every passkey-bearing entry in the group.719 *   4. Otherwise, soft-delete every loser in the group, keeping the winner720 *      as a passkey-only entry.721 *722 * Soft delete (deletedAt) rather than splice — the trash flow keeps the723 * record around for retention and the user can restore if the heuristic724 * picked wrong. The `merged` flag goes back to decodePayload so the725 * unlock path persists the consolidated shape immediately.726 */727function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {728  let merged = false;729  const now = Date.now();730731  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);732  const byRpId = new Map<string, VaultEntry[]>();733  for (const e of candidates) {734    const rpId = e.passkey!.rpId;735    const list = byRpId.get(rpId);736    if (list) list.push(e);737    else byRpId.set(rpId, [e]);738  }739740  for (const [rpId, group] of byRpId) {741    if (group.length === 0) continue;742    group.sort((a, b) => {743      const aT =744        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;745      const bT =746        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;747      return bT - aT;748    });749    const winner = group[0]!;750751    // Look for a NON-passkey login entry matching this RP. Use the URL752    // hostname rather than a substring search so a "login.foo.com" entry753    // doesn't accidentally absorb a passkey for "foo.com".754    const loginTarget = entries.find((e) => {755      if (e.deletedAt || e.passkey || e.type !== "login") return false;756      if (e.url && extractDomain(e.url) === rpId) return true;757      for (const extra of e.additionalUrls ?? []) {758        if (extractDomain(extra) === rpId) return true;759      }760      return false;761    });762763    if (loginTarget) {764      loginTarget.passkey = winner.passkey;765      loginTarget.modifiedAt = now;766      for (const dup of group) {767        if (!dup.deletedAt) {768          dup.deletedAt = now;769          dup.modifiedAt = now;770        }771      }772      merged = true;773    } else if (group.length > 1) {774      for (let i = 1; i < group.length; i += 1) {775        const dup = group[i]!;776        if (!dup.deletedAt) {777          dup.deletedAt = now;778          dup.modifiedAt = now;779        }780      }781      merged = true;782    }783  }784785  return { merged };786}787788// Validates that a `StoredVault` record on disk has the field shape we expect789// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only790// authenticates the static VERSION_BYTE AAD, not the `version` field on the791// outer record — a corrupted `version: 42` slot still decrypts cleanly. This792// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a793// history-restore path instead of falsely reporting "wrong password".794function hasValidVaultSchema(v: unknown): v is StoredVault {795  if (typeof v !== "object" || v === null) return false;796  const r = v as Record<string, unknown>;797  return (798    r.version === 1 &&799    typeof r.salt === "string" &&800    typeof r.nonce === "string" &&801    typeof r.ciphertext === "string" &&802    typeof r.tag === "string"803  );804}805806export type TryDecryptResult =807  | { status: "ok"; payload: DecryptedPayload }808  | { status: "wrong_key" }809  | { status: "corrupted"; reason: string };810811// Exported solely for `unlockFromCodex` below, which needs to verify a812// supplied passphrase against a slotA blob *without* committing anything to813// storage. Keep this internal otherwise — callers crossing the messages.ts814// boundary should always go through `unlock` / `verifyMainPassword` so the815// constant-time invariants in Projects.md §3.4 are not bypassed.816export async function tryDecryptSlot(817  vault: StoredVault,818  key: Uint8Array,819  aadTag?: string,820): Promise<DecryptedPayload | null> {821  const res = await tryDecryptSlotResult(vault, key, aadTag);822  return res.status === "ok" ? res.payload : null;823}824825export async function tryDecryptSlotResult(826  vault: StoredVault,827  key: Uint8Array,828  aadTag?: string,829): Promise<TryDecryptResult> {830  if (!hasValidVaultSchema(vault)) {831    return { status: "corrupted", reason: "invalid_slot_schema" };832  }833  let plain: string;834  try {835    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);836  } catch {837    return { status: "wrong_key" };838  }839  try {840    const parsed = JSON.parse(plain) as unknown;841    if (typeof parsed !== "object" || parsed === null) {842      return { status: "corrupted", reason: "invalid_payload_shape" };843    }844    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {845      return { status: "corrupted", reason: "invalid_payload_shape" };846    }847    return { status: "ok", payload: decodePayload(plain) };848  } catch {849    return { status: "corrupted", reason: "invalid_payload_json" };850  }851}852853// ---------------------------------------------------------------------------854// Core operations855// ---------------------------------------------------------------------------856857function buildDomainIndexSync(all: VaultEntry[]): void {858  domainIndex = new Map();859860  for (const entry of all) {861    if (entry.deletedAt) continue;862    if (entry.type === "note" || entry.type === "card") continue;863864    // An entry can match multiple sites via `additionalUrls`. Index each865    // hostname so `queryDomain` returns the entry regardless of which alias866    // the user is currently visiting. Dedupe per-entry so a single entry867    // never appears twice in one bucket.868    const domains = new Set<string>();869    const primary = extractDomain(entry.url ?? "");870    if (primary) domains.add(primary);871    for (const extra of entry.additionalUrls ?? []) {872      const d = extractDomain(extra);873      if (d) domains.add(d);874    }875    if (domains.size === 0) continue;876877    for (const domain of domains) {878      const bucket = domainIndex.get(domain) ?? [];879      bucket.push(entry);880      domainIndex.set(domain, bucket);881    }882  }883}884885export async function createVault(password: string): Promise<void> {886  await ensureMigrated();887888  const salt = generateSalt();889  derivedKey = await deriveKey(password, salt);890  entries = [];891  vaultMode = "real";892  duressConfigured = false;893  buildDomainIndexSync(entries);894895  // Generate and wrap the root key for recovery word escrow.896  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =897    await generateAndWrapRootKey();898  rootKey = unhex(rawRootKey);899900  const plaintext = encodePayload(entries, duressConfigured);901  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");902903  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate904  // it without recovery words (H2/COR-006).905  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);906907  const slotA: StoredVault = {908    version: 1,909    salt: hex(salt),910    nonce,911    ciphertext,912    tag,913    recovery_salt: recoverySalt,914    recovery_wrapped_root_key: wrappedRootKey,915    recovery_nonce: wrappedNonce,916    recovery_tag: wrappedTag,917    ...pwRoot,918  };919  await writeSlot(SLOT_A, slotA);920921  // Matching-size noise blob for slot B, so a fresh install shows two922  // similarly-shaped records regardless of whether duress is ever set up.923  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));924  await stampLocalVaultMutation();925926  touchAutoLock();927}928929export async function hasVault(): Promise<boolean> {930  await ensureMigrated();931  return (await loadSlot(SLOT_A)) !== null;932}933934/**935 * Returns whether the real vault has recorded that a duress passphrase is936 * configured. Reliable only after a real-mode unlock — before unlock or in937 * decoy mode, returns false because we cannot read slot A's plaintext.938 */939export async function isDuressEnabled(): Promise<boolean> {940  return vaultMode === "real" && duressConfigured;941}942943export async function setupDuress(duressPassword: string): Promise<void> {944  if (!derivedKey || vaultMode !== "real") {945    throw new Error("vault must be unlocked in real mode");946  }947  await ensureMigrated();948949  // Encrypt the decoy payload (empty entries) under the duress key. Pad to950  // at least the current slot A ciphertext size so the two slots look the951  // same at rest at setup time.952  const slotA = await loadSlot(SLOT_A);953  if (!slotA) throw new Error("missing slot A");954955  const decoySalt = generateSalt();956  const decoyKey = await deriveKey(duressPassword, decoySalt);957  try {958    // Pad the decoy plaintext to match slot A's ciphertext length (minus the959    // 16-byte tag) so encrypted sizes align exactly.960    const targetBytes = slotA.ciphertext.length / 2;961    let plaintext = encodePayload([], false);962    const encoded = new TextEncoder().encode(plaintext);963    if (encoded.length < targetBytes) {964      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);965    }966    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");967    await writeSlot(SLOT_B, {968      version: 1,969      salt: hex(decoySalt),970      nonce,971      ciphertext,972      tag,973    });974  } finally {975    zeroKey(decoyKey);976  }977978  // Re-encrypt slot A with the duressConfigured flag set to true. No external979  // flag is written — the bit lives inside the encrypted envelope.980  duressConfigured = true;981  await persistSlotA();982}983984export async function disableDuress(): Promise<void> {985  if (!derivedKey || vaultMode !== "real") {986    throw new Error("vault must be unlocked in real mode");987  }988  await ensureMigrated();989990  // Overwrite slot B with fresh noise that matches slot A's size. The slot991  // remains occupied so its mere existence does not signal that duress was992  // ever configured.993  const slotA = await loadSlot(SLOT_A);994  if (slotA) {995    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));996  }997998  duressConfigured = false;999  await persistSlotA();1000}10011002// L14/COR-015: this function intentionally checks slot A (real vault) only.1003// A duress-mode user attempting changePassword is rejected earlier by the1004// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1005// distinction here is by design — duress users cannot rotate the real1006// password by guessing it, and the asymmetry in error timing is acceptable1007// because duress mode is opt-in and the user knows their own decoy key.1008export async function verifyMainPassword(password: string): Promise<boolean> {1009  await ensureMigrated();1010  const slotA = await loadSlot(SLOT_A);1011  if (!slotA) return false;1012  const key = await deriveKey(password, unhex(slotA.salt));1013  try {1014    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1015  } finally {1016    zeroKey(key);1017  }1018}10191020export async function unlock(password: string): Promise<boolean> {1021  await ensureMigrated();10221023  // Load both slots in parallel. Both always exist post-migration; the1024  // parallel load and the parallel derivation below keep wall-clock time1025  // constant regardless of which slot's passphrase (real or decoy) the1026  // caller supplied, so a timing side channel cannot distinguish the1027  // duress path from the real-unlock path (DUR-002).1028  const [slotA, slotB] = await Promise.all([1029    loadSlot(SLOT_A),1030    loadSlot(SLOT_B),1031  ]);10321033  // Derive both keys in parallel, unconditionally.1034  const [keyA, keyB] = await Promise.all([1035    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1036    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1037  ]);10381039  // Attempt both decrypts in parallel. The Result variant lets us distinguish1040  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1041  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1042  const [resultA, resultB] = await Promise.all([1043    keyA && slotA1044      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1045      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1046    keyB && slotB1047      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1048      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1049  ]);1050  const payloadA = resultA.status === "ok" ? resultA.payload : null;1051  const payloadB = resultB.status === "ok" ? resultB.payload : null;10521053  if (payloadA && keyA) {1054    derivedKey = keyA;1055    vaultMode = "real";1056    entries = payloadA.entries;1057    duressConfigured = payloadA.duressConfigured;1058    buildDomainIndexSync(entries);1059    await loadSettings();1060    touchAutoLock();1061    if (keyB) zeroKey(keyB);10621063    // slotA is non-null when payloadA is truthy (keyA was derived from slotA).1064    if (slotA) {1065      const needsRecoverySetup =1066        !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1067      if (!needsRecoverySetup) {1068        rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1069      }1070      // Pre-v2.12 vault, or a vault whose pw_root_* fields haven't been written1071      // yet: generate the recovery escrow and seed pw_root_* so subsequent1072      // unlocks can rehydrate rootKey without the user's recovery words.1073      // Best-effort — a failure here must not block unlock.1074      if (needsRecoverySetup || !rootKey) {1075        try {1076          await migrateToRecovery();1077          if (needsRecoverySetup) {1078            // String "1" matches the popup's existing peach_recovery_pending1079            // check; surfaced so the user re-confirms recovery words after a1080            // legacy-vault unlock (H2/COR-006).1081            await chrome.storage.local.set({ peach_recovery_pending: '1' });1082          }1083        } catch (migrationErr) {1084          console.warn('[Peach] recovery migration failed:', migrationErr);1085        }1086      }1087    }10881089    // If the payload had a pre-v2.11.0 shape (legacy passkey type, flat1090    // passkey fields, or pre-A-2 created/updated keys), rewrite it now so1091    // the next save uses the new format and migration doesn't re-run forever.1092    if (payloadA.migratedFromLegacy) {1093      await persistSlotA();1094    }1095    return true;1096  }10971098  if (payloadB && keyB) {1099    derivedKey = keyB;1100    vaultMode = "decoy";1101    entries = payloadB.entries;1102    duressConfigured = false;1103    buildDomainIndexSync(entries);1104    await loadSettings();1105    touchAutoLock();1106    if (keyA) zeroKey(keyA);1107    return true;1108  }11091110  if (keyA) zeroKey(keyA);1111  if (keyB) zeroKey(keyB);1112  // Surface corruption (good AES tag, broken schema) distinctly from1113  // wrong-password so the popup can offer history-restore (M7/COR-007).1114  if (resultA.status === "corrupted") {1115    lastUnlockFailureReason = resultA.reason;1116  } else if (resultB.status === "corrupted") {1117    lastUnlockFailureReason = resultB.reason;1118  }1119  return false;1120}11211122export function lock(): void {1123  if (derivedKey) {1124    zeroKey(derivedKey);1125    derivedKey = null;1126  }1127  if (rootKey) {1128    rootKey.fill(0);1129    rootKey = null;1130  }11311132  entries = [];1133  domainIndex = new Map();1134  vaultMode = "real";1135  duressConfigured = false;1136  clearAutoLock();1137}11381139export function isUnlocked(): boolean {1140  return derivedKey !== null;1141}11421143// Opens the vault using a pre-derived AES-256 key (skipping Argon2id).1144// Used by Android biometric unlock — the key was derived on the native side.1145export async function unlockWithKey(1146  masterKeyHex: string,1147): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1148  try {1149    const masterKeyBytes = unhex(masterKeyHex);1150    const stored = await loadSlot(SLOT_A);1151    if (!stored) return { ok: false, error: "invalid_key" };1152    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1153    if (!payload) return { ok: false, error: "invalid_key" };11541155    derivedKey = masterKeyBytes;1156    vaultMode = "real";1157    entries = payload.entries;1158    duressConfigured = payload.duressConfigured;1159    buildDomainIndexSync(entries);1160    await loadSettings();1161    touchAutoLock();1162    return { ok: true, entries };1163  } catch {1164    return { ok: false, error: "invalid_key" };1165  }1166}11671168/**1169 * Restore the vault from a parsed Peach Codex payload.1170 *1171 * The supplied passphrase is verified against the Codex's slotA *before* any1172 * storage mutation — wrong passphrases return without touching disk. On1173 * success, the current vault state is snapshotted (reason: "restore") so the1174 * user can revert via Settings -> Vault History, then slotA/slotB are written1175 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A1176 * regular `unlock()` follows so the rest of the SW sees the standard1177 * post-unlock state.1178 *1179 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex1180 * printed by a Pro user must always be restorable, even on a free device.1181 *1182 * Timing is not held constant here (in contrast to `unlock`): the Codex flow1183 * is an explicit user action with a known passphrase, not a passive lock1184 * screen, so the duress-vs-real timing side channel does not apply.1185 */1186export async function unlockFromCodex(params: {1187  slotA: string; // JSON string of StoredVault for slot A1188  slotB: string | null; // JSON string of StoredVault for slot B, or null1189  timestamp: number;1190  masterPassphrase: string;1191}): Promise<1192  | { ok: true; entryCount: number }1193  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1194> {1195  let parsedSlotA: StoredVault;1196  let parsedSlotB: StoredVault | null = null;1197  try {1198    const rawA = JSON.parse(params.slotA) as unknown;1199    if (!hasValidVaultSchema(rawA)) {1200      return { ok: false, reason: "invalid_slot_format" };1201    }1202    parsedSlotA = rawA;1203    if (params.slotB !== null) {1204      const rawB = JSON.parse(params.slotB) as unknown;1205      if (!hasValidVaultSchema(rawB)) {1206        return { ok: false, reason: "invalid_slot_format" };1207      }1208      parsedSlotB = rawB;1209    }1210  } catch {1211    return { ok: false, reason: "invalid_slot_format" };1212  }12131214  // Verify the passphrase against the supplied slotA WITHOUT writing anything.1215  // If this fails the on-disk vault is left completely untouched.1216  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1217  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1218  if (!trialPayload) {1219    zeroKey(trialKey);1220    return { ok: false, reason: "wrong_passphrase" };1221  }1222  // Drop the trial key; `unlock()` below derives its own copy. Holding two1223  // copies of the master key in memory at once would be a needless surface.1224  zeroKey(trialKey);12251226  // Snapshot the about-to-be-overwritten on-disk state so the user can revert1227  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.1228  await takeVaultSnapshot("restore");12291230  await writeSlot(SLOT_A, parsedSlotA);1231  if (parsedSlotB) {1232    await writeSlot(SLOT_B, parsedSlotB);1233  } else {1234    // Slot B must always exist with ciphertext length matching slot A1235    // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't1236    // configured); regenerate noise to satisfy the invariant.1237    await writeSlot(1238      SLOT_B,1239      generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1240    );1241  }1242  await chrome.storage.local.set({1243    peach_last_modified_at: String(params.timestamp),1244  });12451246  // Standard unlock path so domain index, settings, auto-lock, and1247  // recovery-key state all populate exactly as a normal unlock would.1248  const ok = await unlock(params.masterPassphrase);1249  if (!ok) {1250    // Should be unreachable — we just verified the passphrase against the1251    // same slotA we wrote. Treat as wrong_passphrase to keep the contract1252    // simple if some race or storage anomaly intrudes.1253    return { ok: false, reason: "wrong_passphrase" };1254  }1255  return { ok: true, entryCount: getEntries().length };1256}12571258// Returns the current in-memory derived key as a hex string.1259// Used by the biometric enable flow in Settings to store the key in Android Keystore.1260export function getDerivedKeyHex(): string {1261  if (!derivedKey) throw new Error("vault_not_unlocked");1262  return hex(derivedKey);1263}12641265export function getEntries(): VaultEntry[] {1266  if (!derivedKey) throw new Error("vault_locked");1267  return entries.filter((entry) => !entry.deletedAt);1268}12691270const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;12711272export function getIdentities(): SavedIdentity[] {1273  if (!derivedKey) throw new Error("vault_locked");1274  const active = entries.filter((e) => !e.deletedAt && e.type === "login");12751276  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1277  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();12781279  for (const entry of active) {1280    const value = entry.username?.trim();1281    if (!value) continue;1282    const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1283    const existing = map.get(value);1284    const domain = extractDomain(entry.url ?? "") ?? "";1285    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1286    if (!existing || modifiedAt > existing.lastUsed) {1287      map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1288    } else {1289      map.set(value, { ...existing, count: existing.count + 1 });1290    }1291  }12921293  const toIdentity = (type: "email" | "username") =>1294    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1295      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1296    });12971298  return [1299    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1300    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1301  ];1302}13031304export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1305  if (!derivedKey) throw new Error("vault_locked");13061307  const now = Date.now();1308  const added: VaultEntry[] = [];1309  for (const draft of drafts) {1310    try {1311      const sanitized = sanitizeEntryDraft(draft);1312      const nextEntry: VaultEntry = {1313        ...sanitized,1314        id: crypto.randomUUID(),1315        createdAt: now,1316        modifiedAt: now,1317      };1318      entries.push(nextEntry);1319      added.push(nextEntry);1320    } catch {1321      // Skip invalid drafts silently — caller can see diff between input and output length1322    }1323  }13241325  if (added.length > 0) {1326    await persistEntries();1327  }13281329  return added;1330}13311332/**1333 * Restore a set of entries in full fidelity — preserves id, createdAt,1334 * modifiedAt, and every other field unlike `batchAddEntries` which1335 * assigns fresh identity. Used by restore paths where the backup is the1336 * authoritative source of truth. `mode` controls how entries already in1337 * the vault are treated.1338 */1339export async function restoreEntries(1340  incoming: VaultEntry[],1341  mode: "replace" | "merge"1342): Promise<{ added: number; updated: number; skipped: number }> {1343  if (!derivedKey) throw new Error("vault_locked");13441345  // A replace with zero incoming entries is almost certainly a caller bug1346  // (e.g. an import that silently dropped everything). Refuse rather than1347  // wipe the vault.1348  if (mode === "replace" && incoming.length === 0) {1349    throw new Error("refuse to replace with empty entry set");1350  }13511352  let added = 0;1353  let updated = 0;1354  let skipped = 0;13551356  if (mode === "replace") {1357    entries = [];1358    for (const entry of incoming) {1359      const normalized = sanitizeRestoredEntry(entry);1360      if (!normalized) {1361        skipped += 1;1362        continue;1363      }1364      entries.push(normalized);1365      added += 1;1366    }1367  } else {1368    const byId = new Map<string, VaultEntry>();1369    for (const e of entries) byId.set(e.id, e);13701371    for (const entry of incoming) {1372      const normalized = sanitizeRestoredEntry(entry);1373      if (!normalized) {1374        skipped += 1;1375        continue;1376      }1377      const existing = byId.get(normalized.id);1378      if (!existing) {1379        entries.push(normalized);1380        added += 1;1381      } else if (normalized.modifiedAt > existing.modifiedAt) {1382        const idx = entries.findIndex((e) => e.id === existing.id);1383        if (idx !== -1) {1384          entries[idx] = normalized;1385          updated += 1;1386        }1387      } else {1388        skipped += 1;1389      }1390    }1391  }13921393  await persistEntries("import");1394  return { added, updated, skipped };1395}13961397/**1398 * Validate + normalise a VaultEntry coming from a backup. Accepts1399 * anything structurally sound; keeps all metadata verbatim. Returns1400 * null for values the live vault shouldn't accept (e.g., missing title).1401 */1402function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1403  if (!entry || typeof entry !== "object") return null;1404  const title = typeof entry.title === "string" ? entry.title.trim() : "";1405  if (!title) return null;1406  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1407  const now = Date.now();1408  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1409  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;14101411  // Migrate legacy passkey shape on import (same logic as on-disk read).1412  // Imports from pre-v2.11.0 vault backups still carry type:"passkey" with1413  // flat fields; bring them into the new shape immediately.1414  const migrated: VaultEntry = migrateLegacyPasskey(1415    {1416      ...entry,1417      id,1418      title,1419      type: entry.type ?? "login",1420      createdAt,1421      modifiedAt,1422    },1423    entry1424  );14251426  // Login entries from backup must carry a password (or be passkey-only,1427  // in which case the passkey sub-object replaces the password requirement).1428  if (1429    migrated.type === "login" &&1430    !migrated.passkey &&1431    typeof migrated.password !== "string"1432  ) {1433    return null;1434  }1435  if (migrated.passkey && !migrated.passkey.credentialId) {1436    return null;1437  }14381439  return migrated;1440}14411442// Soft-deletes every non-deleted entry in one call. The literal string1443// argument is a tripwire against accidental calls — any caller that would1444// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this1445// to a boolean.1446export async function clearVaultEntries(1447  confirmation: "REPLACE_ALL"1448): Promise<void> {1449  if (confirmation !== "REPLACE_ALL") {1450    throw new Error("clearVaultEntries requires explicit confirmation");1451  }1452  if (!derivedKey) throw new Error("vault_locked");1453  for (const entry of entries) {1454    if (!entry.deletedAt) {1455      entry.deletedAt = Date.now();1456      entry.modifiedAt = Date.now();1457    }1458  }1459  await persistEntries();1460}14611462export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1463  if (!derivedKey) throw new Error("vault_locked");14641465  const sanitized = sanitizeEntryDraft(entry);1466  const now = Date.now();1467  const nextEntry: VaultEntry = {1468    ...sanitized,1469    id: crypto.randomUUID(),1470    createdAt: now,1471    modifiedAt: now,1472    // Stamp passwordChangedAt at creation so age tracking is accurate from1473    // day one. Non-login entries carry the same stamp harmlessly.1474    passwordChangedAt: sanitized.password ? now : undefined,1475  };14761477  // Clone-and-replace so a failed persist rolls back the in-memory state1478  // (COR-008). Without this, a quota-full or storage error leaves the1479  // module-level `entries` ahead of what's actually on disk.1480  const rollback = entries;1481  entries = [...entries, nextEntry];1482  try {1483    await persistEntries();1484  } catch (err) {1485    entries = rollback;1486    throw err;1487  }14881489  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)1490  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1491    checkBreach(nextEntry.password).then(async (count) => {1492      if (count > 0) {1493        const idx = entries.findIndex((e) => e.id === nextEntry.id);1494        if (idx !== -1) {1495          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1496          await persistEntries();1497        }1498      }1499    });1500  }15011502  return nextEntry;1503}15041505export async function updateEntry(1506  id: string,1507  patch: Partial<VaultEntry>1508): Promise<void> {1509  if (!derivedKey) throw new Error("vault_locked");15101511  const idx = entries.findIndex((entry) => entry.id === id);1512  if (idx === -1 || entries[idx].deletedAt) {1513    throw new Error("entry_not_found");1514  }15151516  const current = entries[idx];1517  const sanitizedPatch = sanitizeEntryPatch(patch);1518  // Refresh passwordChangedAt only when the password value actually changes —1519  // a metadata-only update (title, tags, etc.) must not reset the age clock1520  // that drives the Pro health rotation recommendation.1521  const passwordChanged =1522    "password" in patch &&1523    typeof sanitizedPatch.password === "string" &&1524    sanitizedPatch.password !== current.password;1525  entries[idx] = {1526    ...current,1527    ...sanitizedPatch,1528    modifiedAt: Date.now(),1529    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1530  };15311532  // A login entry must carry SOMETHING fillable — either a password or a1533  // passkey credential. An empty-password login with no passkey is dead1534  // state. Permit the password-clear when a passkey is attached (that's1535  // the legitimate "drop the password, keep the passkey" path).1536  if (1537    entries[idx].type === "login" &&1538    !entries[idx].password &&1539    !entries[idx].passkey1540  ) {1541    entries[idx] = current;1542    throw new Error("password or passkey required");1543  }15441545  // Card entries must retain their card number1546  if (entries[idx].type === "card" && !entries[idx].cardNumber) {1547    entries[idx] = current;1548    throw new Error("card number is required for card entries");1549  }15501551  await persistEntries();15521553  // Breach check when password changes: clear immediately so old breach doesn't linger,1554  // then async-check the new password and only re-set if it's also found.1555  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1556    entries[idx] = { ...entries[idx], breach: undefined };1557    await persistEntries();1558    const password = entries[idx].password;1559    if (password) {1560      checkBreach(password).then(async (count) => {1561        const i = entries.findIndex((e) => e.id === id);1562        if (i !== -1 && count > 0) {1563          entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1564          await persistEntries();1565        }1566      });1567    }1568  }1569}15701571export async function deleteEntry(id: string): Promise<void> {1572  if (!derivedKey) throw new Error("vault_locked");15731574  const entry = entries.find((item) => item.id === id);1575  if (!entry || entry.deletedAt) {1576    throw new Error("entry_not_found");1577  }15781579  entry.deletedAt = Date.now();1580  entry.modifiedAt = Date.now();1581  await persistEntries();1582}15831584export function getDeletedEntries(): VaultEntry[] {1585  if (!derivedKey) return [];1586  return entries.filter((e) => !!e.deletedAt);1587}15881589export async function restoreEntry(id: string): Promise<void> {1590  if (!derivedKey) throw new Error("vault_locked");1591  const entry = entries.find((e) => e.id === id && e.deletedAt);1592  if (!entry) throw new Error("deleted entry not found");1593  delete entry.deletedAt;1594  entry.modifiedAt = Date.now();1595  await persistEntries();1596  buildDomainIndexSync(entries);1597}15981599export async function purgeEntry(id: string): Promise<void> {1600  if (!derivedKey) throw new Error("vault_locked");1601  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1602  if (idx === -1) throw new Error("deleted entry not found");1603  entries.splice(idx, 1);1604  await persistEntries();1605}16061607/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */1608export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1609  if (!derivedKey || retentionMs === 0) return 0;1610  const cutoff = Date.now() - retentionMs;1611  const before = entries.length;1612  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1613  const purged = before - entries.length;1614  if (purged > 0) await persistEntries();1615  return purged;1616}16171618export function queryDomain(domain: string): VaultEntry[] {1619  return domainIndex.get(domain) ?? [];1620}16211622export function getEntryById(id: string): VaultEntry | undefined {1623  return entries.find((entry) => entry.id === id && !entry.deletedAt);1624}16251626async function persistSlotA(1627  reason: VaultSnapshot["reason"] = "local_save",1628): Promise<StoredVault> {1629  if (!derivedKey) throw new Error("vault_locked");16301631  const slotA = await loadSlot(SLOT_A);1632  if (!slotA) throw new Error("missing slot A");16331634  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures1635  // are swallowed by takeVaultSnapshot — history must never block a save.1636  await takeVaultSnapshot(reason);16371638  const plaintext = encodePayload(entries, duressConfigured);1639  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");16401641  // Preserve existing recovery escrow fields.1642  const nextSlotA: StoredVault = {1643    ...slotA,1644    nonce,1645    ciphertext,1646    tag,1647    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot1648  };1649  await writeSlot(SLOT_A, nextSlotA);16501651  // When duress is not configured, keep slot B size-matched with slot A by1652  // regenerating its noise. When duress IS configured, leave slot B alone —1653  // we don't have the decoy key to re-encrypt it. This is a known trade-off.1654  if (!duressConfigured) {1655    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1656  }1657  await stampLocalVaultMutation();16581659  return nextSlotA;1660}16611662async function persistSlotB(): Promise<void> {1663  if (!derivedKey) throw new Error("vault_locked");16641665  const slotB = await loadSlot(SLOT_B);1666  if (!slotB) throw new Error("missing slot B");16671668  // Decoy plaintext always reports duressConfigured=false — the decoy user1669  // sees a clean slate.1670  const plaintext = encodePayload(entries, false);1671  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");16721673  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1674  await stampLocalVaultMutation();1675}16761677async function persistEntries(1678  reason: VaultSnapshot["reason"] = "local_save",1679): Promise<void> {1680  if (!derivedKey) throw new Error("vault_locked");16811682  if (vaultMode === "decoy") {1683    await persistSlotB();1684  } else {1685    await persistSlotA(reason);1686  }1687  buildDomainIndexSync(entries);1688}16891690function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1691  if (!Array.isArray(values)) return undefined;1692  const seen = new Set<string>();1693  const cleaned: string[] = [];1694  for (const value of values) {1695    const trimmed = typeof value === "string" ? value.trim() : "";1696    if (!trimmed) continue;1697    const key = trimmed.toLowerCase();1698    if (seen.has(key)) continue;1699    seen.add(key);1700    cleaned.push(trimmed);1701  }1702  return cleaned.length > 0 ? cleaned : undefined;1703}17041705function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1706  const title = entry.title.trim();1707  // Accept legacy `type:"passkey"` drafts but rewrite to "login" — the new1708  // model is "any entry, optionally with a passkey sub-object." Imports and1709  // any in-flight callers from before v2.11.0 keep working without throwing.1710  let type: VaultEntry["type"] = entry.type ?? "login";1711  if (type === "passkey") type = "login";17121713  if (!title) throw new Error("title is required");1714  // A login entry needs either a password or a passkey credential to be useful.1715  // Passkey-only entries (passkey present, no password) are valid.1716  if (type === "login" && !entry.password?.trim() && !entry.passkey) {1717    throw new Error("password or passkey required");1718  }1719  if (entry.passkey) {1720    const p = entry.passkey;1721    if (1722      !p.rpId ||1723      !p.credentialId ||1724      !p.publicKeyCose ||1725      !p.userHandle ||1726      !p.userName ||1727      (p.algorithm !== -7 && p.algorithm !== -8)1728    ) {1729      throw new Error("invalid passkey credential");1730    }1731  }1732  if (type === "card") {1733    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1734      throw new Error("cardholder name and card number are required");1735    }1736  }17371738  return {1739    type,1740    title,1741    username: type === "login" ? entry.username?.trim() ?? "" : undefined,1742    password: type === "login" ? entry.password : undefined,1743    url: type === "login" ? entry.url?.trim() ?? "" : undefined,1744    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1745    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1746    notes: entry.notes?.trim() || undefined,1747    breach: type === "login" ? entry.breach : undefined,1748    canary: type === "login" ? entry.canary : undefined,1749    tags: entry.tags,1750    passkey: entry.passkey,1751    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1752    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1753    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1754    expYear: type === "card" ? entry.expYear?.trim() : undefined,1755    cvc: type === "card" ? entry.cvc?.trim() : undefined,1756    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1757    network: type === "card" ? entry.network : undefined,1758    last4: type === "card" ? entry.last4?.trim() : undefined,1759  };1760}17611762function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1763  const nextPatch: Partial<VaultEntry> = {};17641765  if ("type" in patch) {1766    nextPatch.type = patch.type;1767  }17681769  if ("title" in patch) {1770    const title = patch.title?.trim() ?? "";1771    if (!title) throw new Error("title is required");1772    nextPatch.title = title;1773  }17741775  if ("url" in patch) {1776    nextPatch.url = patch.url?.trim() ?? "";1777  }17781779  if ("additionalUrls" in patch) {1780    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1781    const seen = new Set<string>();1782    const cleaned: string[] = [];1783    for (const value of incoming) {1784      const trimmed = typeof value === "string" ? value.trim() : "";1785      if (!trimmed) continue;1786      const key = trimmed.toLowerCase();1787      if (seen.has(key)) continue;1788      seen.add(key);1789      cleaned.push(trimmed);1790    }1791    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1792  }17931794  if ("username" in patch) {1795    nextPatch.username = patch.username?.trim() ?? "";1796  }17971798  if ("email" in patch) {1799    nextPatch.email = patch.email?.trim() || undefined;1800  }18011802  if ("password" in patch) {1803    const password = patch.password ?? "";1804    // Allow clearing a password (e.g. on passkey-only entries)1805    nextPatch.password = password || undefined;1806  }18071808  if ("totp" in patch) {1809    nextPatch.totp = patch.totp?.trim() || undefined;1810  }18111812  if ("notes" in patch) {1813    nextPatch.notes = patch.notes?.trim() || undefined;1814  }18151816  if ("breach" in patch) {1817    nextPatch.breach = patch.breach;1818  }18191820  if ("canary" in patch) {1821    nextPatch.canary = patch.canary;1822  }18231824  if ("passkey" in patch) {1825    // Passkey sub-object replaces all the legacy flat passkey fields. A1826    // patch with `passkey: undefined` removes the credential from the entry1827    // (used by "remove passkey" without deleting the whole entry).1828    nextPatch.passkey = patch.passkey;1829  }18301831  if ("tags" in patch) {1832    nextPatch.tags = patch.tags;1833  }18341835  if ("cardholderName" in patch) {1836    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1837  }1838  if ("cardNumber" in patch) {1839    const n = patch.cardNumber ?? "";1840    nextPatch.cardNumber = n || undefined;1841  }1842  if ("expMonth" in patch) {1843    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1844  }1845  if ("expYear" in patch) {1846    nextPatch.expYear = patch.expYear?.trim() || undefined;1847  }1848  if ("cvc" in patch) {1849    nextPatch.cvc = patch.cvc?.trim() || undefined;1850  }1851  if ("cardNickname" in patch) {1852    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1853  }1854  if ("network" in patch) {1855    nextPatch.network = patch.network;1856  }1857  if ("last4" in patch) {1858    nextPatch.last4 = patch.last4?.trim() || undefined;1859  }18601861  return nextPatch;1862}18631864function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1865  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1866  const modifiedAt =1867    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;18681869  const base: VaultEntry = {1870    id: entry.id,1871    type: "type" in entry ? entry.type : "login",1872    title: entry.title,1873    username: entry.username,1874    password: entry.password,1875    url: entry.url,1876    totp: entry.totp,1877    notes: entry.notes,1878    breach: entry.breach,1879    canary: "canary" in entry ? entry.canary : undefined,1880    passkey: "passkey" in entry ? entry.passkey : undefined,1881    createdAt,1882    modifiedAt,1883    deletedAt: entry.deletedAt,1884    tags: "tags" in entry ? entry.tags : undefined,1885    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1886    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1887    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1888    expYear: "expYear" in entry ? entry.expYear : undefined,1889    cvc: "cvc" in entry ? entry.cvc : undefined,1890    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1891    network: "network" in entry ? entry.network : undefined,1892    last4: "last4" in entry ? entry.last4 : undefined,1893  };18941895  return migrateLegacyPasskey(base, entry);1896}18971898/**1899 * v2.11.0 read migration. Pre-v2.11.0 passkey entries used `type:"passkey"`1900 * with flat top-level fields (rpId, credentialId, etc.). This converts them1901 * to the new shape — `type:"login"` with a `passkey` sub-object — so all1902 * downstream consumers can rely on the new model. The result is rewritten1903 * to disk on next vault save (hasLegacyShape detects the un-migrated case).1904 */1905function migrateLegacyPasskey(1906  base: VaultEntry,1907  raw: VaultEntry | LegacyVaultEntry1908): VaultEntry {1909  if (base.passkey) return base;1910  const flat = raw as Partial<VaultEntry>;1911  const isPasskeyType = base.type === "passkey";1912  const hasFlatPasskeyFields =1913    typeof flat.rpId === "string" &&1914    typeof flat.credentialId === "string" &&1915    typeof flat.publicKeyCose === "string" &&1916    typeof flat.userHandle === "string" &&1917    typeof flat.userName === "string";1918  if (!isPasskeyType && !hasFlatPasskeyFields) return base;1919  if (!hasFlatPasskeyFields) return base;19201921  const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1922  base.passkey = {1923    rpId: flat.rpId!,1924    rpName: flat.rpName ?? flat.rpId!,1925    credentialId: flat.credentialId!,1926    publicKeyCose: flat.publicKeyCose!,1927    privateKeyJwk: flat.privateKeyJwk,1928    userHandle: flat.userHandle!,1929    userName: flat.userName!,1930    userDisplayName: flat.userDisplayName ?? flat.userName!,1931    algorithm,1932    signCount: flat.signCount ?? 0,1933    aaguid: flat.aaguid ?? "",1934    backupEligible: flat.backupEligible === true,1935    backupState: flat.backupState === true,1936    lastUsedAt: flat.lastUsedAt,1937    browser: flat.browser,1938    transports: flat.transports,1939    createdAt: base.createdAt,1940  };1941  // Promote to "login" so the unified Entry view renders. Username gets1942  // populated from the passkey's userName so the entry is searchable in the1943  // existing autofill flow without a separate code path.1944  if (isPasskeyType) base.type = "login";1945  if (!base.username) base.username = flat.userName;1946  if (!base.url) base.url = `https://${flat.rpId}`;1947  return base;1948}194919501951// ---------------------------------------------------------------------------1952// Deduplication1953// ---------------------------------------------------------------------------19541955function entryDupeKey(e: VaultEntry): string {1956  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1957  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins1958  // on different sites that happen to share a username are still distinct;1959  // two passkey credentials for the same RP+user are duplicates regardless1960  // of any login fields they may also carry.1961  if (e.passkey) {1962    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1963  }1964  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1965}19661967/** Returns the number of duplicate entries (those that would be removed). */1968export function countDuplicates(): number {1969  const best = new Map<string, VaultEntry>();1970  let dupeCount = 0;1971  for (const e of entries.filter((e) => !e.deletedAt)) {1972    const key = entryDupeKey(e);1973    const existing = best.get(key);1974    if (!existing) {1975      best.set(key, e);1976    } else {1977      dupeCount++;1978      // Keep the more recently modified one in the map1979      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1980        best.set(key, e);1981      }1982    }1983  }1984  return dupeCount;1985}19861987/**1988 * Soft-deletes duplicate entries, keeping the most recently modified copy of1989 * each group. Returns the number of entries moved to trash.1990 */1991export async function deduplicateEntries(): Promise<number> {1992  if (!derivedKey) throw new Error("vault_locked");1993  // Dedup reshapes slot B permanently under the decoy key, which is1994  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1995  if (vaultMode === "decoy") {1996    throw new Error("deduplicate disabled in decoy mode");1997  }19981999  const best = new Map<string, VaultEntry>();2000  for (const e of entries.filter((en) => !en.deletedAt)) {2001    const key = entryDupeKey(e);2002    const existing = best.get(key);2003    if (!existing) {2004      best.set(key, e);2005    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2006      best.set(key, e);2007    }2008  }20092010  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2011  const now = Date.now();2012  let removed = 0;2013  entries = entries.map((e) => {2014    if (!e.deletedAt && !keepIds.has(e.id)) {2015      removed++;2016      return { ...e, deletedAt: now };2017    }2018    return e;2019  });20202021  if (removed > 0) await persistEntries();2022  return removed;2023}20242025// ---------------------------------------------------------------------------2026// Vault history — snapshot BEFORE every vault write so the saved blob is2027// what is about to be lost, not what was just written. Capped at 10. The2028// stored blobs are AES-256-GCM ciphertext under the master password, so2029// history adds zero security surface.2030// ---------------------------------------------------------------------------20312032const HISTORY_KEY = "peach_vault_history";2033const HISTORY_MAX = 10;20342035/**2036 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE2037 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost2038 * state. Identical-to-most-recent blobs are skipped to avoid filling history2039 * with no-op writes.2040 */2041export async function takeVaultSnapshot(2042  reason: VaultSnapshot["reason"],2043): Promise<void> {2044  try {2045    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2046    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2047    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];20482049    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,2050    // pre-createVault). createVault skips snapshots intentionally — there is2051    // no prior state to preserve.2052    if (!slotA) return;20532054    const vault_a = JSON.stringify(slotA);2055    const vault_b = JSON.stringify(slotB ?? slotA);20562057    // Best-effort entry count: only meaningful when the in-memory state is2058    // unlocked AND in real mode. Decoy mode would leak the decoy entry2059    // count; locked mode has no entries to count. Both fall through to null.2060    let entryCount: number | null = null;2061    if (derivedKey && vaultMode === "real") {2062      try {2063        entryCount = entries.filter((e) => !e.deletedAt).length;2064      } catch {2065        entryCount = null;2066      }2067    }20682069    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2070    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2071      ? existingRaw2072      : { snapshots: [] };20732074    // Deduplication: if the most recent snapshot has byte-identical blobs,2075    // skip — saves the same vault N times in a row would otherwise burn the2076    // entire history window with no recovery value.2077    const latest = history.snapshots[0];2078    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2079      return;2080    }20812082    const snapshot: VaultSnapshot = {2083      id: crypto.randomUUID(),2084      savedAt: Date.now(),2085      reason,2086      vault_a,2087      vault_b,2088      entryCount,2089    };20902091    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2092    await chrome.storage.local.set({ [HISTORY_KEY]: history });2093  } catch (err) {2094    // Non-fatal — history must never block a save. A failed snapshot just2095    // means the user loses one rung of the safety ladder for this write.2096    // M4/QUA-006: log so the failure is visible; if it looks like a quota2097    // problem, notify the popup so the user can prune history themselves.2098    console.warn('[Peach] takeVaultSnapshot failed:', err);2099    if (String(err).includes('QUOTA') || String(err).includes('quota')) {2100      try {2101        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2102      } catch {2103        // popup may be closed; surfaced via console.warn above2104      }2105    }2106  }2107}21082109/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */2110export async function clearVaultHistory(): Promise<void> {2111  try {2112    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2113  } catch {2114    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().2115  }2116}21172118/** Read all snapshots, newest first, for the Settings vault history view. */2119export async function getVaultHistory(): Promise<VaultSnapshot[]> {2120  const raw = await chrome.storage.local.get(HISTORY_KEY);2121  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2122  if (!history || !Array.isArray(history.snapshots)) return [];2123  return history.snapshots;2124}21252126/**2127 * Replace the on-disk vault with the chosen snapshot's blobs and lock the2128 * vault. The current state is itself snapshotted with reason 'restore' first2129 * so the restore is reversible. After this returns, the user must unlock2130 * with the password that matches the restored blob.2131 */2132export async function restoreVaultSnapshot(2133  snapshotId: string,2134): Promise<{ ok: true } | { ok: false; error: string }> {2135  const history = await getVaultHistory();2136  const snapshot = history.find((s) => s.id === snapshotId);2137  if (!snapshot) return { ok: false, error: "snapshot_not_found" };21382139  // Snapshot the about-to-be-replaced current state so the restore is2140  // reversible. takeVaultSnapshot is no-op when the most recent entry is2141  // byte-identical, so a restore-of-a-restore stays clean.2142  await takeVaultSnapshot("restore");21432144  let restoredA: StoredVault;2145  let restoredB: StoredVault;2146  try {2147    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2148    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2149  } catch {2150    return { ok: false, error: "snapshot_corrupt" };2151  }21522153  await writeSlot(SLOT_A, restoredA);2154  await writeSlot(SLOT_B, restoredB);2155  await stampLocalVaultMutation();21562157  // Lock so the user must prove they can decrypt the restored blob. If they2158  // restored a pre-password-change snapshot they discover it immediately at2159  // the lock screen rather than silently holding an inaccessible vault.2160  lock();21612162  return { ok: true };2163}21642165/**2166 * Change the master password. Re-encrypts slot A with a fresh salt + new2167 * derived key. Slot B (duress or noise) is left untouched when duress is2168 * configured — it uses an independent key derived from the duress password —2169 * and regenerated as noise when duress is not configured.2170 */2171export async function changePassword(2172  currentPassword: string,2173  newPassword: string2174): Promise<boolean> {2175  if (!derivedKey || vaultMode !== "real") return false;21762177  // Verify current password before allowing any change2178  const valid = await verifyMainPassword(currentPassword);2179  if (!valid) return false;21802181  const slotA = await loadSlot(SLOT_A);2182  if (!slotA) return false;2183  if (!rootKey) return false;21842185  // Derive fresh key from new password + new random salt2186  const newSalt = generateSalt();2187  const newKey = await deriveKey(newPassword, newSalt);21882189  // Re-encrypt the in-memory entries with the new key2190  const plaintext = encodePayload(entries, duressConfigured);2191  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");21922193  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey2194  // under recovery words and remain untouched — recovery phrase keeps working2195  // across password changes (H4/COR-002).2196  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);21972198  const finalSlotA: StoredVault = {2199    ...slotA,2200    salt: hex(newSalt),2201    nonce,2202    ciphertext,2203    tag,2204    ...pwRoot,2205  };22062207  // Snapshot the OLD-key blob before overwriting it. If the user later2208  // restores from this snapshot they will need the OLD password — the2209  // post-restore lock surfaces that immediately.2210  await takeVaultSnapshot("local_save");22112212  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in2213  // one storage transaction. An SW kill before this point leaves the old2214  // slot intact; after this point everything tied to the new password is2215  // already on disk together (M6/COR-004).2216  await writeSlot(SLOT_A, finalSlotA);22172218  // Swap the in-memory key synchronously before any further await. If we2219  // threw between the Slot A write and this swap, the next persistEntries()2220  // would overwrite Slot A with old-key ciphertext, locking the user out.2221  zeroKey(derivedKey);2222  derivedKey = newKey;22232224  // Regenerate noise for slot B when duress is not in use. Runs after the2225  // key swap so a failure here is a disposable noise regeneration, not a2226  // lockout.2227  if (!duressConfigured) {2228    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2229  }2230  await stampLocalVaultMutation();22312232  return true;2233}22342235// ---------------------------------------------------------------------------2236// Sync helpers2237// ---------------------------------------------------------------------------22382239export async function getVaultBlobForSync(): Promise<{2240  blob: string;2241  timestamp: number;2242  license?: string;2243} | null> {2244  const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2245  const slotA = (raw as Record<string, unknown>)[SLOT_A];2246  if (!slotA) return null;2247  const slotB = (raw as Record<string, unknown>)[SLOT_B];2248  const blob = JSON.stringify({ a: slotA, b: slotB });2249  const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2250  const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2251  const license = (raw as Record<string, unknown>)['peach_license'];2252  return {2253    blob,2254    timestamp,2255    license: typeof license === 'string' ? license : undefined,2256  };2257}22582259export async function applyIncomingVault(2260  blobJson: string,2261  remoteTimestamp: number,2262  remoteLicense?: string,2263): Promise<boolean> {2264  const raw = await chrome.storage.local.get('peach_last_modified_at');2265  const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2266  const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;22672268  if (remoteLicense) {2269    const licenseRaw = await chrome.storage.local.get('peach_license');2270    const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2271    if (!existing) {2272      // L16/SEC-010: validate signature before storing. An unverified peer2273      // could otherwise plant an arbitrary string as the local license and2274      // poison the next isProUser() / activateLicense() check.2275      const { validateLicense } = await import('./license');2276      const validation = await validateLicense(remoteLicense);2277      if (validation.valid) {2278        await chrome.storage.local.set({ peach_license: remoteLicense });2279      }2280      // Silently ignore invalid — never store garbage.2281    }2282  }22832284  // AUD-006: reject implausibly future timestamps. A paired peer with a bad2285  // clock — or a malicious peer — can send Number.MAX_SAFE_INTEGER and pin2286  // last-write-wins so no local change ever wins. 5 minutes covers legitimate2287  // clock drift; anything further is implausible.2288  const SYNC_CLOCK_SKEW_BUDGET_MS = 5 * 60 * 1_000;2289  if (remoteTimestamp > Date.now() + SYNC_CLOCK_SKEW_BUDGET_MS) {2290    console.warn(2291      '[Peach] sync rejected: remote timestamp too far in future',2292      { remoteTimestamp, now: Date.now(), delta: remoteTimestamp - Date.now() },2293    );2294    return false;2295  }22962297  if (remoteTimestamp <= localTimestamp) return false;22982299  // Parse + shape-validate before any storage mutation. A malformed blob2300  // (non-JSON, non-object, missing slot fields) must NOT touch slot A —2301  // otherwise an unauthenticated remote could poison the local vault by2302  // sending '[]' or '42' (M10/COR-012). Mirror the structural check2303  // already used in unlockFromCodex.2304  let parsed: unknown;2305  try {2306    parsed = JSON.parse(blobJson);2307  } catch {2308    throw new Error('invalid_incoming_vault_blob');2309  }2310  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {2311    throw new Error('invalid_incoming_vault_blob');2312  }2313  const { a, b } = parsed as { a?: unknown; b?: unknown };2314  if (!hasValidVaultSchema(a)) {2315    throw new Error('invalid_incoming_vault_blob');2316  }23172318  // Snapshot before overwriting — non-fatal but user-visible.2319  // M4/QUA-006: a silent snapshot failure leaves the user without their2320  // safety net for an incoming sync write; surface it.2321  try {2322    await takeVaultSnapshot('sync_received');2323  } catch (snapshotErr) {2324    console.warn('[Peach] vault snapshot failed during sync:', snapshotErr);2325    if (String(snapshotErr).includes('QUOTA') || String(snapshotErr).includes('quota')) {2326      try {2327        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2328      } catch {2329        // popup may be closed; surfaced via console.warn above2330      }2331    }2332  }23332334  await writeSlot(SLOT_A, a);2335  if (hasValidVaultSchema(b)) await writeSlot(SLOT_B, b);2336  await chrome.storage.local.set({2337    peach_last_sync_at: String(Date.now()),2338    peach_last_modified_at: String(remoteTimestamp),2339  });23402341  return true;2342}23432344/**2345 * Returns the X25519 sync identity, encrypting the private key on first call2346 * and on any unencrypted-shape migration. Requires the vault to be unlocked2347 * — the private key is wrapped under the same derived key as the vault, so2348 * a locked browser cannot leak it from storage alone (M12/SEC-003).2349 *2350 * Persistence shapes:2351 *   v0 (pre-M12, migrated on first read):2352 *     { priv: base64, pub: base64 }2353 *   v1 (encrypted):2354 *     { pub: base64, enc_priv: hex, enc_nonce: hex, enc_tag: hex }2355 */2356export async function getSyncIdentity(): Promise<{2357  privateKeyBytes: Uint8Array;2358  publicKeyBytes: Uint8Array;2359  publicKeyHex: string;2360}> {2361  if (!derivedKey) throw new Error('vault_locked');2362  const raw = await chrome.storage.local.get('peach_sync_identity');2363  const stored = (raw as Record<string, unknown>)['peach_sync_identity'];23642365  if (typeof stored === 'string') {2366    const parsed = JSON.parse(stored) as {2367      pub: string;2368      priv?: string;2369      enc_priv?: string;2370      enc_nonce?: string;2371      enc_tag?: string;2372    };2373    const publicKeyBytes = base64DecodeBytes(parsed.pub);23742375    if (parsed.enc_priv && parsed.enc_nonce && parsed.enc_tag) {2376      const privHex = await decrypt(2377        derivedKey,2378        parsed.enc_nonce,2379        parsed.enc_priv,2380        parsed.enc_tag,2381        "sync_identity_v1",2382      );2383      const privateKeyBytes = unhex(privHex);2384      return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2385    }23862387    if (parsed.priv) {2388      // Migrate v0 (unencrypted private key) → v1.2389      const privateKeyBytes = base64DecodeBytes(parsed.priv);2390      await persistEncryptedSyncIdentity(privateKeyBytes, publicKeyBytes);2391      return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2392    }2393  }23942395  // Generate a fresh X25519 keypair and persist it encrypted.2396  const { generateKeyPair } = await import('@stablelib/x25519');2397  const keypair = generateKeyPair();2398  await persistEncryptedSyncIdentity(keypair.secretKey, keypair.publicKey);2399  return {2400    privateKeyBytes: keypair.secretKey,2401    publicKeyBytes: keypair.publicKey,2402    publicKeyHex: bytesToHex(keypair.publicKey),2403  };2404}24052406async function persistEncryptedSyncIdentity(2407  privateKeyBytes: Uint8Array,2408  publicKeyBytes: Uint8Array,2409): Promise<void> {2410  if (!derivedKey) throw new Error('vault_locked');2411  const enc = await encrypt(derivedKey, hex(privateKeyBytes), "sync_identity_v1");2412  await chrome.storage.local.set({2413    peach_sync_identity: JSON.stringify({2414      pub: base64EncodeBytes(publicKeyBytes),2415      enc_priv: enc.ciphertext,2416      enc_nonce: enc.nonce,2417      enc_tag: enc.tag,2418    }),2419  });2420}24212422// Re-decrypt vault entries from storage using the current in-memory key.2423// Called after applyIncomingVault so the in-memory state stays consistent2424// with what was just written to storage.2425export async function reloadEntriesAfterSync(): Promise<boolean> {2426  if (!derivedKey) return false;2427  const slotA = await loadSlot(SLOT_A);2428  if (!slotA) return false;2429  try {2430    const payload = await tryDecryptSlot(slotA, derivedKey, "slot_a_v1");2431    if (!payload) return false;2432    entries = payload.entries;2433    buildDomainIndexSync(entries);2434    return true;2435  } catch {2436    return false;2437  }2438}24392440function bytesToHex(bytes: Uint8Array): string {2441  return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2442}24432444function base64EncodeBytes(bytes: Uint8Array): string {2445  let binary = '';2446  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2447  return btoa(binary);2448}24492450function base64DecodeBytes(b64: string): Uint8Array {2451  const binary = atob(b64);2452  const bytes = new Uint8Array(binary.length);2453  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2454  return bytes;2455}