vault.ts

84.6 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  const encoder = new TextEncoder();182  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));183  const hashHex = Array.from(new Uint8Array(hashBuffer))184    .map((b) => b.toString(16).padStart(2, "0"))185    .join("")186    .toUpperCase();187188  const prefix = hashHex.slice(0, 5);189  const suffix = hashHex.slice(5);190191  try {192    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);193    if (!res.ok) return 0;194    const text = await res.text();195    const match = text.split("\r\n").find((l) => l.startsWith(suffix));196    return match ? parseInt(match.split(":")[1], 10) : 0;197  } catch {198    return 0; // Network unavailable — don't block199  }200}201202export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {203  const fingerprint = await cardFingerprint(last4, expMonth, expYear);204  await loadSettings();205  settings = {206    ...settings,207    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],208  };209  await persistSettings();210}211212export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {213  await loadSettings();214  const fingerprint = await cardFingerprint(last4, expMonth, expYear);215  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);216}217218async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {219  const encoder = new TextEncoder();220  const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));221  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");222}223224function hex(buf: Uint8Array): string {225  return Array.from(buf)226    .map((b) => b.toString(16).padStart(2, "0"))227    .join("");228}229230function unhex(s: string): Uint8Array {231  const out = new Uint8Array(s.length / 2);232  for (let i = 0; i < s.length; i += 2) {233    out[i / 2] = parseInt(s.slice(i, i + 2), 16);234  }235  return out;236}237238// ---------------------------------------------------------------------------239// Root key generation and recovery word wrapping240// ---------------------------------------------------------------------------241242/**243 * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key244 * derived from the recovery word material via HKDF-SHA256. The wrapped blob245 * fields are stored in slot A alongside the password-wrapped ciphertext. The246 * root key itself is kept in memory for the session so getRecoveryWords() can247 * encode it without re-deriving.248 */249async function generateAndWrapRootKey(): Promise<{250  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey251  recoverySalt: string;252  wrappedRootKey: string;253  wrappedNonce: string;254  wrappedTag: string;255}> {256  const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));257258  // Derive the recovery key from the BIP39 words encoding of the root key.259  const words = await rootKeyToWords(rootKeyBytes);260  const wordMaterial = await wordsToRootKeyMaterial(words);261  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));262  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);263264  // Wrap the root key with the recovery key using AES-256-GCM directly.265  // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.266  const nonce = crypto.getRandomValues(new Uint8Array(12));267  const encoded = new TextEncoder().encode(hex(rootKeyBytes));268  const raw = await crypto.subtle.encrypt(269    {270      name: "AES-GCM",271      iv: nonce,272      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,273      tagLength: 128,274    },275    recoveryKey,276    encoded277  );278  // WebCrypto returns ciphertext || auth tag concatenated; split them.279  const rawU8 = new Uint8Array(raw);280  const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));281  const tag = hex(rawU8.slice(rawU8.length - 16));282283  return {284    rawRootKey: hex(rootKeyBytes),285    recoverySalt: hex(recoverySalt),286    wrappedRootKey: ciphertext,287    wrappedNonce: hex(nonce),288    wrappedTag: tag,289  };290}291292/**293 * Unwrap the root key using recovery words. Called during recovery flow when294 * the user provides their 12-word phrase and a new password.295 * Exported for use by the RECOVER_WITH_WORDS message handler.296 */297export async function unwrapRootKeyForRecovery(298  wrappedRootKey: string,299  wrappedNonce: string,300  wrappedTag: string,301  recoverySaltHex: string,302  words: string[]303): Promise<void> {304  const wordMaterial = await wordsToRootKeyMaterial(words);305  const recoverySalt = unhex(recoverySaltHex);306  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);307308  // Decrypt and cache the root key. The root key bytes were hex-encoded before309  // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt310  // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.311  // Each component is stored separately in the vault slot; decode them individually312  // so the nonce is always exactly the 12 bytes from wrappedNonce.313  const nonceRaw = unhex(wrappedNonce);314  const ciphertextRaw = unhex(wrappedRootKey);315  const tagRaw = unhex(wrappedTag);316  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).317  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.318  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;319  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);320  combined.set(ciphertextRaw, 0);321  combined.set(tagRaw, ciphertextRaw.length);322  const raw = await crypto.subtle.decrypt(323    { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },324    recoveryKey,325    combined326  );327  const decryptedHex = new TextDecoder().decode(raw);328  rootKey = unhex(decryptedHex);329}330331/**332 * Re-wrap the cached root key under a new derived key (from the user's new333 * password). Called after password change and after recovery re-key so the334 * same recovery words continue to work with the updated password — and so335 * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.336 *337 * recovery_* fields are intentionally not touched: those wrap rootKey under338 * the recovery words, which do not change when the password changes.339 */340export async function rewrapRootKeyForPassword(341  newDerivedKey: Uint8Array,342  _newSalt: Uint8Array343): Promise<void> {344  if (!rootKey) throw new Error("root key not set");345  const slotA = await loadSlot(SLOT_A);346  if (!slotA) throw new Error("missing slot A");347  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);348  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });349}350351/**352 * Change the vault encryption to a new derived key (already derived from353 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.354 * Caller is responsible for key swap and snapshot.355 */356async function reencryptVaultForNewKey(357  newKey: Uint8Array,358  slotA: StoredVault359): Promise<void> {360  const plaintext = encodePayload(entries, duressConfigured);361  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);362  await writeSlot(SLOT_A, {363    ...slotA,364    nonce,365    ciphertext,366    tag,367  });368}369370/**371 * Full password change with an already-derived key (used after recovery).372 * Unlike changePassword(), this skips re-verification of current password373 * since recovery flow has already validated the user's identity via words.374 */375export async function changePasswordWithNewKey(376  newSalt: Uint8Array,377  newKey: Uint8Array378): Promise<void> {379  const slotA = await loadSlot(SLOT_A);380  if (!slotA) throw new Error("missing slot A");381  // reencryptVaultForNewKey must receive the updated salt so that subsequent382  // UNLOCK calls with the new password can re-derive the correct key.383  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });384  if (derivedKey) zeroKey(derivedKey);385  derivedKey = newKey;386  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works387  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow388  // should have populated it — fall back silently rather than blowing up the389  // recovery path; migrateToRecovery will rehydrate on the next unlock.390  if (rootKey) {391    const slotAfter = await loadSlot(SLOT_A);392    if (slotAfter) {393      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);394      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });395    }396  }397  if (!duressConfigured) {398    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));399  }400  await stampLocalVaultMutation();401}402403// ---------------------------------------------------------------------------404// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive405// a new key after password reset without re-importing the crypto module.406// ---------------------------------------------------------------------------407408export { deriveKey, generateSalt };409410export async function loadSlotA(): Promise<StoredVault | null> {411  return loadSlot(SLOT_A);412}413414/**415 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields416 * (a second wrapping of the root key under the password-derived key). This417 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without418 * the user re-entering their recovery phrase: the same root key bytes —419 * and therefore the same 12 words — survive every unlock.420 *421 * Returns null when the pw_root fields are absent (pre-v2.12 vault that422 * hasn't been migrated yet) or when decryption fails (wrong derived key,423 * tampered ciphertext). On null the caller should fall through to424 * migrateToRecovery() to populate the fields.425 */426async function deriveRootKeyFromDerivedKey(427  derivedKeyBytes: Uint8Array,428  slotA: StoredVault,429): Promise<Uint8Array | null> {430  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {431    return null;432  }433  try {434    const rootHex = await decrypt(435      derivedKeyBytes,436      slotA.pw_root_nonce,437      slotA.pw_root_ciphertext,438      slotA.pw_root_tag,439    );440    return unhex(rootHex);441  } catch {442    return null;443  }444}445446// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the447// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.448async function wrapRootKeyForDerivedKey(449  derivedKeyBytes: Uint8Array,450  rootKeyBytes: Uint8Array,451): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {452  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes));453  return {454    pw_root_ciphertext: ciphertext,455    pw_root_nonce: nonce,456    pw_root_tag: tag,457  };458}459460/**461 * For vaults created before v2.12 (no recovery_salt/recovery_wrapped_root_key),462 * generate the root key, wrap it under the recovery words, and persist the463 * recovery escrow into slot A. The caller must show the recovery words to the464 * user before calling this — once called, those words are the vault's recovery465 * path. This runs once, on the first unlock of an upgraded vault.466 */467export async function migrateToRecovery(): Promise<void> {468  if (!derivedKey || vaultMode !== "real") {469    throw new Error("vault must be unlocked in real mode");470  }471  const slotA = await loadSlot(SLOT_A);472  if (!slotA) throw new Error("missing slot A");473  const hasRecoveryEscrow =474    !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;475  const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;476  if (hasRecoveryEscrow && hasPwRoot) return; // already done477478  // If recovery escrow already exists but pw_root doesn't, we cannot recover479  // the original rootKey without the user's words. The least-surprising path480  // is to rotate to a fresh root key — the user's old recovery words become481  // invalid but we surface peach_recovery_pending so the popup re-presents482  // the words. The unlock-path callers all set that flag.483  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =484    await generateAndWrapRootKey();485  rootKey = unhex(rawRootKey);486487  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);488  const nextSlotA: StoredVault = {489    ...slotA,490    recovery_salt: recoverySalt,491    recovery_wrapped_root_key: wrappedRootKey,492    recovery_nonce: wrappedNonce,493    recovery_tag: wrappedTag,494    ...pwRoot,495  };496  await writeSlot(SLOT_A, nextSlotA);497  await stampLocalVaultMutation();498}499500/**501 * Returns the 12 recovery words for the current session's root key.502 * Must be called while the vault is unlocked. Throws if recovery has not503 * been set up (pre-v2.12 vault that hasn't migrated yet).504 */505export async function getRecoveryWords(): Promise<string[]> {506  if (!rootKey) {507    throw new Error("recovery not set up — vault may need migration");508  }509  return rootKeyToWords(rootKey);510}511512async function loadSlot(key: string): Promise<StoredVault | null> {513  const result = await chrome.storage.local.get(key);514  return (result as Record<string, StoredVault | undefined>)[key] ?? null;515}516517async function writeSlot(key: string, vault: StoredVault): Promise<void> {518  try {519    await chrome.storage.local.set({ [key]: vault });520  } catch (err) {521    throw mapStorageError(err);522  }523}524525async function stampLocalVaultMutation(): Promise<void> {526  await chrome.storage.local.set({527    peach_last_modified_at: String(Date.now()),528  });529}530531/**532 * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault533 * + peach_duress flag) to the slot-neutral schema (peach_vault_a +534 * peach_vault_b). Runs at most once per session; safe to call repeatedly.535 */536async function ensureMigrated(): Promise<void> {537  if (migrationRan) return;538  migrationRan = true;539540  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];541  const raw = await chrome.storage.local.get(keys);542  const slotAExists = raw[SLOT_A] !== undefined;543  const slotBExists = raw[SLOT_B] !== undefined;544  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;545  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;546547  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.548  if (!slotAExists && !legacyMain) return;549550  // Already migrated.551  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;552553  let didMigrate = false;554  if (legacyMain && !slotAExists) {555    await writeSlot(SLOT_A, legacyMain);556    didMigrate = true;557  }558559  // If slot B isn't set, populate it: use legacy decoy when present, else noise.560  if (!slotBExists) {561    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;562    if (legacyDecoy) {563      await writeSlot(SLOT_B, legacyDecoy);564      didMigrate = true;565    } else if (target) {566      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));567      didMigrate = true;568    }569  }570571  const toRemove: string[] = [];572  if (legacyMain) toRemove.push(LEGACY_MAIN);573  if (legacyDecoy) toRemove.push(LEGACY_DECOY);574  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);575  if (toRemove.length > 0) {576    await chrome.storage.local.remove(toRemove);577    didMigrate = true;578  }579  if (didMigrate) {580    await stampLocalVaultMutation();581  }582}583584// ---------------------------------------------------------------------------585// Padding + noise helpers586// ---------------------------------------------------------------------------587588/**589 * Pads the plaintext string with trailing spaces so that the encoded byte590 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing591 * whitespace after a complete document, so the padding is transparent on592 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.593 */594function padPlaintext(json: string): string {595  const encoded = new TextEncoder().encode(json);596  const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);597  const padLen = target - encoded.length;598  return json + " ".repeat(padLen);599}600601/**602 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The603 * ciphertext length matches the given byte count. Because GCM ciphertext is604 * pseudo-random, this blob is indistinguishable at rest from a real605 * encrypted vault without the key.606 *607 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto608 * spec hard limit). Chunk the fill so vaults larger than that — common once609 * the user has dozens of entries or any passkey — don't throw.610 */611const CSPRNG_CHUNK = 65_536;612613function fillRandom(buf: Uint8Array): Uint8Array {614  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {615    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);616    const chunk = new Uint8Array(end - offset);617    crypto.getRandomValues(chunk);618    buf.set(chunk, offset);619  }620  return buf;621}622623function generateNoiseBlob(ciphertextBytes: number): StoredVault {624  const salt = crypto.getRandomValues(new Uint8Array(16));625  const nonce = crypto.getRandomValues(new Uint8Array(12));626  const cipher = fillRandom(new Uint8Array(ciphertextBytes));627  const tag = crypto.getRandomValues(new Uint8Array(16));628  return {629    version: 1,630    salt: hex(salt),631    nonce: hex(nonce),632    ciphertext: hex(cipher),633    tag: hex(tag),634  };635}636637// ---------------------------------------------------------------------------638// Encrypted plaintext envelope639// ---------------------------------------------------------------------------640641type VaultPayload = {642  entries: VaultEntry[];643  duressConfigured?: boolean;644};645646type DecryptedPayload = {647  entries: VaultEntry[];648  duressConfigured: boolean;649  /**650   * True when at least one entry in the on-disk payload had a legacy shape651   * (pre-v2.11.0 passkey type/flat fields, or pre-A-2 created/updated keys)652   * and got rewritten by normalizeEntry. The unlock path uses this to653   * persist the new shape immediately — without it, the migration would654   * re-run on every unlock and old shape never disappears from disk.655   */656  migratedFromLegacy: boolean;657};658659function encodePayload(entries: VaultEntry[], duress: boolean): string {660  const envelope: VaultPayload = { entries, duressConfigured: duress };661  return padPlaintext(JSON.stringify(envelope));662}663664function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {665  if ("created" in raw || "updated" in raw) return true;666  const flat = raw as Partial<VaultEntry>;667  if (flat.type === "passkey") return true;668  // Flat rpId without a passkey sub-object means pre-v2.11.0 schema.669  if (flat.rpId !== undefined && flat.passkey === undefined) return true;670  return false;671}672673function decodePayload(plain: string): DecryptedPayload {674  const parsed = JSON.parse(plain) as675    | Array<VaultEntry | LegacyVaultEntry>676    | VaultPayload;677678  const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];679  const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);680681  const normalized = rawEntries.map(normalizeEntry);682  const { merged } = mergePasskeyOnlyIntoLogins(normalized);683684  if (Array.isArray(parsed)) {685    return {686      entries: normalized,687      duressConfigured: false,688      migratedFromLegacy: true, // pre-A-2 array shape always needs save689    };690  }691692  return {693    entries: normalized,694    duressConfigured: parsed.duressConfigured === true,695    migratedFromLegacy: flatMigrationNeeded || merged,696  };697}698699/**700 * One-time consolidation pass run alongside the legacy passkey migration.701 *702 * Pre-v2.11.0 the Chrome passkey path always created a fresh `type:"passkey"`703 * entry per registration — so users who tested registration multiple times704 * accumulated several passkey-bearing entries for the same RP, and the705 * migration to the unified shape carried that duplication forward. This706 * pass collapses the duplicates:707 *708 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.709 *   2. Within each group, the most recently-active credential wins (prefer710 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).711 *   3. If a NON-passkey login entry exists for that RP (matched by url or712 *      additionalUrls hostname), attach the winning passkey to it and713 *      soft-delete every passkey-bearing entry in the group.714 *   4. Otherwise, soft-delete every loser in the group, keeping the winner715 *      as a passkey-only entry.716 *717 * Soft delete (deletedAt) rather than splice — the trash flow keeps the718 * record around for retention and the user can restore if the heuristic719 * picked wrong. The `merged` flag goes back to decodePayload so the720 * unlock path persists the consolidated shape immediately.721 */722function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {723  let merged = false;724  const now = Date.now();725726  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);727  const byRpId = new Map<string, VaultEntry[]>();728  for (const e of candidates) {729    const rpId = e.passkey!.rpId;730    const list = byRpId.get(rpId);731    if (list) list.push(e);732    else byRpId.set(rpId, [e]);733  }734735  for (const [rpId, group] of byRpId) {736    if (group.length === 0) continue;737    group.sort((a, b) => {738      const aT =739        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;740      const bT =741        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;742      return bT - aT;743    });744    const winner = group[0]!;745746    // Look for a NON-passkey login entry matching this RP. Use the URL747    // hostname rather than a substring search so a "login.foo.com" entry748    // doesn't accidentally absorb a passkey for "foo.com".749    const loginTarget = entries.find((e) => {750      if (e.deletedAt || e.passkey || e.type !== "login") return false;751      if (e.url && extractDomain(e.url) === rpId) return true;752      for (const extra of e.additionalUrls ?? []) {753        if (extractDomain(extra) === rpId) return true;754      }755      return false;756    });757758    if (loginTarget) {759      loginTarget.passkey = winner.passkey;760      loginTarget.modifiedAt = now;761      for (const dup of group) {762        if (!dup.deletedAt) {763          dup.deletedAt = now;764          dup.modifiedAt = now;765        }766      }767      merged = true;768    } else if (group.length > 1) {769      for (let i = 1; i < group.length; i += 1) {770        const dup = group[i]!;771        if (!dup.deletedAt) {772          dup.deletedAt = now;773          dup.modifiedAt = now;774        }775      }776      merged = true;777    }778  }779780  return { merged };781}782783// Validates that a `StoredVault` record on disk has the field shape we expect784// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only785// authenticates the static VERSION_BYTE AAD, not the `version` field on the786// outer record — a corrupted `version: 42` slot still decrypts cleanly. This787// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a788// history-restore path instead of falsely reporting "wrong password".789function hasValidVaultSchema(v: unknown): v is StoredVault {790  if (typeof v !== "object" || v === null) return false;791  const r = v as Record<string, unknown>;792  return (793    r.version === 1 &&794    typeof r.salt === "string" &&795    typeof r.nonce === "string" &&796    typeof r.ciphertext === "string" &&797    typeof r.tag === "string"798  );799}800801export type TryDecryptResult =802  | { status: "ok"; payload: DecryptedPayload }803  | { status: "wrong_key" }804  | { status: "corrupted"; reason: string };805806// Exported solely for `unlockFromCodex` below, which needs to verify a807// supplied passphrase against a slotA blob *without* committing anything to808// storage. Keep this internal otherwise — callers crossing the messages.ts809// boundary should always go through `unlock` / `verifyMainPassword` so the810// constant-time invariants in Projects.md §3.4 are not bypassed.811export async function tryDecryptSlot(812  vault: StoredVault,813  key: Uint8Array814): Promise<DecryptedPayload | null> {815  const res = await tryDecryptSlotResult(vault, key);816  return res.status === "ok" ? res.payload : null;817}818819export async function tryDecryptSlotResult(820  vault: StoredVault,821  key: Uint8Array822): Promise<TryDecryptResult> {823  if (!hasValidVaultSchema(vault)) {824    return { status: "corrupted", reason: "invalid_slot_schema" };825  }826  let plain: string;827  try {828    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);829  } catch {830    return { status: "wrong_key" };831  }832  try {833    const parsed = JSON.parse(plain) as unknown;834    if (typeof parsed !== "object" || parsed === null) {835      return { status: "corrupted", reason: "invalid_payload_shape" };836    }837    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {838      return { status: "corrupted", reason: "invalid_payload_shape" };839    }840    return { status: "ok", payload: decodePayload(plain) };841  } catch {842    return { status: "corrupted", reason: "invalid_payload_json" };843  }844}845846// ---------------------------------------------------------------------------847// Core operations848// ---------------------------------------------------------------------------849850function buildDomainIndexSync(all: VaultEntry[]): void {851  domainIndex = new Map();852853  for (const entry of all) {854    if (entry.deletedAt) continue;855    if (entry.type === "note" || entry.type === "card") continue;856857    // An entry can match multiple sites via `additionalUrls`. Index each858    // hostname so `queryDomain` returns the entry regardless of which alias859    // the user is currently visiting. Dedupe per-entry so a single entry860    // never appears twice in one bucket.861    const domains = new Set<string>();862    const primary = extractDomain(entry.url ?? "");863    if (primary) domains.add(primary);864    for (const extra of entry.additionalUrls ?? []) {865      const d = extractDomain(extra);866      if (d) domains.add(d);867    }868    if (domains.size === 0) continue;869870    for (const domain of domains) {871      const bucket = domainIndex.get(domain) ?? [];872      bucket.push(entry);873      domainIndex.set(domain, bucket);874    }875  }876}877878export async function createVault(password: string): Promise<void> {879  await ensureMigrated();880881  const salt = generateSalt();882  derivedKey = await deriveKey(password, salt);883  entries = [];884  vaultMode = "real";885  duressConfigured = false;886  buildDomainIndexSync(entries);887888  // Generate and wrap the root key for recovery word escrow.889  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =890    await generateAndWrapRootKey();891  rootKey = unhex(rawRootKey);892893  const plaintext = encodePayload(entries, duressConfigured);894  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);895896  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate897  // it without recovery words (H2/COR-006).898  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);899900  const slotA: StoredVault = {901    version: 1,902    salt: hex(salt),903    nonce,904    ciphertext,905    tag,906    recovery_salt: recoverySalt,907    recovery_wrapped_root_key: wrappedRootKey,908    recovery_nonce: wrappedNonce,909    recovery_tag: wrappedTag,910    ...pwRoot,911  };912  await writeSlot(SLOT_A, slotA);913914  // Matching-size noise blob for slot B, so a fresh install shows two915  // similarly-shaped records regardless of whether duress is ever set up.916  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));917  await stampLocalVaultMutation();918919  touchAutoLock();920}921922export async function hasVault(): Promise<boolean> {923  await ensureMigrated();924  return (await loadSlot(SLOT_A)) !== null;925}926927/**928 * Returns whether the real vault has recorded that a duress passphrase is929 * configured. Reliable only after a real-mode unlock — before unlock or in930 * decoy mode, returns false because we cannot read slot A's plaintext.931 */932export async function isDuressEnabled(): Promise<boolean> {933  return vaultMode === "real" && duressConfigured;934}935936export async function setupDuress(duressPassword: string): Promise<void> {937  if (!derivedKey || vaultMode !== "real") {938    throw new Error("vault must be unlocked in real mode");939  }940  await ensureMigrated();941942  // Encrypt the decoy payload (empty entries) under the duress key. Pad to943  // at least the current slot A ciphertext size so the two slots look the944  // same at rest at setup time.945  const slotA = await loadSlot(SLOT_A);946  if (!slotA) throw new Error("missing slot A");947948  const decoySalt = generateSalt();949  const decoyKey = await deriveKey(duressPassword, decoySalt);950  try {951    // Pad the decoy plaintext to match slot A's ciphertext length (minus the952    // 16-byte tag) so encrypted sizes align exactly.953    const targetBytes = slotA.ciphertext.length / 2;954    let plaintext = encodePayload([], false);955    const encoded = new TextEncoder().encode(plaintext);956    if (encoded.length < targetBytes) {957      plaintext = plaintext + " ".repeat(targetBytes - encoded.length);958    }959    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);960    await writeSlot(SLOT_B, {961      version: 1,962      salt: hex(decoySalt),963      nonce,964      ciphertext,965      tag,966    });967  } finally {968    zeroKey(decoyKey);969  }970971  // Re-encrypt slot A with the duressConfigured flag set to true. No external972  // flag is written — the bit lives inside the encrypted envelope.973  duressConfigured = true;974  await persistSlotA();975}976977export async function disableDuress(): Promise<void> {978  if (!derivedKey || vaultMode !== "real") {979    throw new Error("vault must be unlocked in real mode");980  }981  await ensureMigrated();982983  // Overwrite slot B with fresh noise that matches slot A's size. The slot984  // remains occupied so its mere existence does not signal that duress was985  // ever configured.986  const slotA = await loadSlot(SLOT_A);987  if (slotA) {988    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));989  }990991  duressConfigured = false;992  await persistSlotA();993}994995export async function verifyMainPassword(password: string): Promise<boolean> {996  await ensureMigrated();997  const slotA = await loadSlot(SLOT_A);998  if (!slotA) return false;999  const key = await deriveKey(password, unhex(slotA.salt));1000  try {1001    return (await tryDecryptSlot(slotA, key)) !== null;1002  } finally {1003    zeroKey(key);1004  }1005}10061007export async function unlock(password: string): Promise<boolean> {1008  await ensureMigrated();10091010  // Load both slots in parallel. Both always exist post-migration; the1011  // parallel load and the parallel derivation below keep wall-clock time1012  // constant regardless of which slot's passphrase (real or decoy) the1013  // caller supplied, so a timing side channel cannot distinguish the1014  // duress path from the real-unlock path (DUR-002).1015  const [slotA, slotB] = await Promise.all([1016    loadSlot(SLOT_A),1017    loadSlot(SLOT_B),1018  ]);10191020  // Derive both keys in parallel, unconditionally.1021  const [keyA, keyB] = await Promise.all([1022    slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1023    slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1024  ]);10251026  // Attempt both decrypts in parallel. The Result variant lets us distinguish1027  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1028  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1029  const [resultA, resultB] = await Promise.all([1030    keyA && slotA1031      ? tryDecryptSlotResult(slotA, keyA)1032      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1033    keyB && slotB1034      ? tryDecryptSlotResult(slotB, keyB)1035      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1036  ]);1037  const payloadA = resultA.status === "ok" ? resultA.payload : null;1038  const payloadB = resultB.status === "ok" ? resultB.payload : null;10391040  if (payloadA && keyA) {1041    derivedKey = keyA;1042    vaultMode = "real";1043    entries = payloadA.entries;1044    duressConfigured = payloadA.duressConfigured;1045    buildDomainIndexSync(entries);1046    await loadSettings();1047    touchAutoLock();1048    if (keyB) zeroKey(keyB);10491050    // slotA is non-null when payloadA is truthy (keyA was derived from slotA).1051    if (slotA) {1052      const needsRecoverySetup =1053        !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1054      if (!needsRecoverySetup) {1055        rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1056      }1057      // Pre-v2.12 vault, or a vault whose pw_root_* fields haven't been written1058      // yet: generate the recovery escrow and seed pw_root_* so subsequent1059      // unlocks can rehydrate rootKey without the user's recovery words.1060      // Best-effort — a failure here must not block unlock.1061      if (needsRecoverySetup || !rootKey) {1062        try {1063          await migrateToRecovery();1064          if (needsRecoverySetup) {1065            // String "1" matches the popup's existing peach_recovery_pending1066            // check; surfaced so the user re-confirms recovery words after a1067            // legacy-vault unlock (H2/COR-006).1068            await chrome.storage.local.set({ peach_recovery_pending: '1' });1069          }1070        } catch (migrationErr) {1071          console.warn('[Peach] recovery migration failed:', migrationErr);1072        }1073      }1074    }10751076    // If the payload had a pre-v2.11.0 shape (legacy passkey type, flat1077    // passkey fields, or pre-A-2 created/updated keys), rewrite it now so1078    // the next save uses the new format and migration doesn't re-run forever.1079    if (payloadA.migratedFromLegacy) {1080      await persistSlotA();1081    }1082    return true;1083  }10841085  if (payloadB && keyB) {1086    derivedKey = keyB;1087    vaultMode = "decoy";1088    entries = payloadB.entries;1089    duressConfigured = false;1090    buildDomainIndexSync(entries);1091    await loadSettings();1092    touchAutoLock();1093    if (keyA) zeroKey(keyA);1094    return true;1095  }10961097  if (keyA) zeroKey(keyA);1098  if (keyB) zeroKey(keyB);1099  // Surface corruption (good AES tag, broken schema) distinctly from1100  // wrong-password so the popup can offer history-restore (M7/COR-007).1101  if (resultA.status === "corrupted") {1102    lastUnlockFailureReason = resultA.reason;1103  } else if (resultB.status === "corrupted") {1104    lastUnlockFailureReason = resultB.reason;1105  }1106  return false;1107}11081109export function lock(): void {1110  if (derivedKey) {1111    zeroKey(derivedKey);1112    derivedKey = null;1113  }1114  if (rootKey) {1115    rootKey.fill(0);1116    rootKey = null;1117  }11181119  entries = [];1120  domainIndex = new Map();1121  vaultMode = "real";1122  duressConfigured = false;1123  clearAutoLock();1124}11251126export function isUnlocked(): boolean {1127  return derivedKey !== null;1128}11291130// Opens the vault using a pre-derived AES-256 key (skipping Argon2id).1131// Used by Android biometric unlock — the key was derived on the native side.1132export async function unlockWithKey(1133  masterKeyHex: string,1134): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1135  try {1136    const masterKeyBytes = unhex(masterKeyHex);1137    const stored = await loadSlot(SLOT_A);1138    if (!stored) return { ok: false, error: "invalid_key" };1139    const payload = await tryDecryptSlot(stored, masterKeyBytes);1140    if (!payload) return { ok: false, error: "invalid_key" };11411142    derivedKey = masterKeyBytes;1143    vaultMode = "real";1144    entries = payload.entries;1145    duressConfigured = payload.duressConfigured;1146    buildDomainIndexSync(entries);1147    await loadSettings();1148    touchAutoLock();1149    return { ok: true, entries };1150  } catch {1151    return { ok: false, error: "invalid_key" };1152  }1153}11541155/**1156 * Restore the vault from a parsed Peach Codex payload.1157 *1158 * The supplied passphrase is verified against the Codex's slotA *before* any1159 * storage mutation — wrong passphrases return without touching disk. On1160 * success, the current vault state is snapshotted (reason: "restore") so the1161 * user can revert via Settings -> Vault History, then slotA/slotB are written1162 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A1163 * regular `unlock()` follows so the rest of the SW sees the standard1164 * post-unlock state.1165 *1166 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex1167 * printed by a Pro user must always be restorable, even on a free device.1168 *1169 * Timing is not held constant here (in contrast to `unlock`): the Codex flow1170 * is an explicit user action with a known passphrase, not a passive lock1171 * screen, so the duress-vs-real timing side channel does not apply.1172 */1173export async function unlockFromCodex(params: {1174  slotA: string; // JSON string of StoredVault for slot A1175  slotB: string | null; // JSON string of StoredVault for slot B, or null1176  timestamp: number;1177  masterPassphrase: string;1178}): Promise<1179  | { ok: true; entryCount: number }1180  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1181> {1182  let parsedSlotA: StoredVault;1183  let parsedSlotB: StoredVault | null = null;1184  try {1185    const rawA = JSON.parse(params.slotA) as unknown;1186    if (!hasValidVaultSchema(rawA)) {1187      return { ok: false, reason: "invalid_slot_format" };1188    }1189    parsedSlotA = rawA;1190    if (params.slotB !== null) {1191      const rawB = JSON.parse(params.slotB) as unknown;1192      if (!hasValidVaultSchema(rawB)) {1193        return { ok: false, reason: "invalid_slot_format" };1194      }1195      parsedSlotB = rawB;1196    }1197  } catch {1198    return { ok: false, reason: "invalid_slot_format" };1199  }12001201  // Verify the passphrase against the supplied slotA WITHOUT writing anything.1202  // If this fails the on-disk vault is left completely untouched.1203  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1204  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1205  if (!trialPayload) {1206    zeroKey(trialKey);1207    return { ok: false, reason: "wrong_passphrase" };1208  }1209  // Drop the trial key; `unlock()` below derives its own copy. Holding two1210  // copies of the master key in memory at once would be a needless surface.1211  zeroKey(trialKey);12121213  // Snapshot the about-to-be-overwritten on-disk state so the user can revert1214  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.1215  await takeVaultSnapshot("restore");12161217  await writeSlot(SLOT_A, parsedSlotA);1218  if (parsedSlotB) {1219    await writeSlot(SLOT_B, parsedSlotB);1220  } else {1221    // Slot B must always exist with ciphertext length matching slot A1222    // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't1223    // configured); regenerate noise to satisfy the invariant.1224    await writeSlot(1225      SLOT_B,1226      generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1227    );1228  }1229  await chrome.storage.local.set({1230    peach_last_modified_at: String(params.timestamp),1231  });12321233  // Standard unlock path so domain index, settings, auto-lock, and1234  // recovery-key state all populate exactly as a normal unlock would.1235  const ok = await unlock(params.masterPassphrase);1236  if (!ok) {1237    // Should be unreachable — we just verified the passphrase against the1238    // same slotA we wrote. Treat as wrong_passphrase to keep the contract1239    // simple if some race or storage anomaly intrudes.1240    return { ok: false, reason: "wrong_passphrase" };1241  }1242  return { ok: true, entryCount: getEntries().length };1243}12441245// Returns the current in-memory derived key as a hex string.1246// Used by the biometric enable flow in Settings to store the key in Android Keystore.1247export function getDerivedKeyHex(): string {1248  if (!derivedKey) throw new Error("vault_not_unlocked");1249  return hex(derivedKey);1250}12511252export function getEntries(): VaultEntry[] {1253  if (!derivedKey) throw new Error("vault locked");1254  return entries.filter((entry) => !entry.deletedAt);1255}12561257const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;12581259export function getIdentities(): SavedIdentity[] {1260  if (!derivedKey) throw new Error("vault locked");1261  const active = entries.filter((e) => !e.deletedAt && e.type === "login");12621263  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1264  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();12651266  for (const entry of active) {1267    const value = entry.username?.trim();1268    if (!value) continue;1269    const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1270    const existing = map.get(value);1271    const domain = extractDomain(entry.url ?? "") ?? "";1272    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1273    if (!existing || modifiedAt > existing.lastUsed) {1274      map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1275    } else {1276      map.set(value, { ...existing, count: existing.count + 1 });1277    }1278  }12791280  const toIdentity = (type: "email" | "username") =>1281    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1282      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1283    });12841285  return [1286    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1287    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1288  ];1289}12901291export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1292  if (!derivedKey) throw new Error("vault locked");12931294  const now = Date.now();1295  const added: VaultEntry[] = [];1296  for (const draft of drafts) {1297    try {1298      const sanitized = sanitizeEntryDraft(draft);1299      const nextEntry: VaultEntry = {1300        ...sanitized,1301        id: crypto.randomUUID(),1302        createdAt: now,1303        modifiedAt: now,1304      };1305      entries.push(nextEntry);1306      added.push(nextEntry);1307    } catch {1308      // Skip invalid drafts silently — caller can see diff between input and output length1309    }1310  }13111312  if (added.length > 0) {1313    await persistEntries();1314  }13151316  return added;1317}13181319/**1320 * Restore a set of entries in full fidelity — preserves id, createdAt,1321 * modifiedAt, and every other field unlike `batchAddEntries` which1322 * assigns fresh identity. Used by restore paths where the backup is the1323 * authoritative source of truth. `mode` controls how entries already in1324 * the vault are treated.1325 */1326export async function restoreEntries(1327  incoming: VaultEntry[],1328  mode: "replace" | "merge"1329): Promise<{ added: number; updated: number; skipped: number }> {1330  if (!derivedKey) throw new Error("vault locked");13311332  // A replace with zero incoming entries is almost certainly a caller bug1333  // (e.g. an import that silently dropped everything). Refuse rather than1334  // wipe the vault.1335  if (mode === "replace" && incoming.length === 0) {1336    throw new Error("refuse to replace with empty entry set");1337  }13381339  let added = 0;1340  let updated = 0;1341  let skipped = 0;13421343  if (mode === "replace") {1344    entries = [];1345    for (const entry of incoming) {1346      const normalized = sanitizeRestoredEntry(entry);1347      if (!normalized) {1348        skipped += 1;1349        continue;1350      }1351      entries.push(normalized);1352      added += 1;1353    }1354  } else {1355    const byId = new Map<string, VaultEntry>();1356    for (const e of entries) byId.set(e.id, e);13571358    for (const entry of incoming) {1359      const normalized = sanitizeRestoredEntry(entry);1360      if (!normalized) {1361        skipped += 1;1362        continue;1363      }1364      const existing = byId.get(normalized.id);1365      if (!existing) {1366        entries.push(normalized);1367        added += 1;1368      } else if (normalized.modifiedAt > existing.modifiedAt) {1369        const idx = entries.findIndex((e) => e.id === existing.id);1370        if (idx !== -1) {1371          entries[idx] = normalized;1372          updated += 1;1373        }1374      } else {1375        skipped += 1;1376      }1377    }1378  }13791380  await persistEntries("import");1381  return { added, updated, skipped };1382}13831384/**1385 * Validate + normalise a VaultEntry coming from a backup. Accepts1386 * anything structurally sound; keeps all metadata verbatim. Returns1387 * null for values the live vault shouldn't accept (e.g., missing title).1388 */1389function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1390  if (!entry || typeof entry !== "object") return null;1391  const title = typeof entry.title === "string" ? entry.title.trim() : "";1392  if (!title) return null;1393  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1394  const now = Date.now();1395  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1396  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;13971398  // Migrate legacy passkey shape on import (same logic as on-disk read).1399  // Imports from pre-v2.11.0 vault backups still carry type:"passkey" with1400  // flat fields; bring them into the new shape immediately.1401  const migrated: VaultEntry = migrateLegacyPasskey(1402    {1403      ...entry,1404      id,1405      title,1406      type: entry.type ?? "login",1407      createdAt,1408      modifiedAt,1409    },1410    entry1411  );14121413  // Login entries from backup must carry a password (or be passkey-only,1414  // in which case the passkey sub-object replaces the password requirement).1415  if (1416    migrated.type === "login" &&1417    !migrated.passkey &&1418    typeof migrated.password !== "string"1419  ) {1420    return null;1421  }1422  if (migrated.passkey && !migrated.passkey.credentialId) {1423    return null;1424  }14251426  return migrated;1427}14281429// Soft-deletes every non-deleted entry in one call. The literal string1430// argument is a tripwire against accidental calls — any caller that would1431// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this1432// to a boolean.1433export async function clearVaultEntries(1434  confirmation: "REPLACE_ALL"1435): Promise<void> {1436  if (confirmation !== "REPLACE_ALL") {1437    throw new Error("clearVaultEntries requires explicit confirmation");1438  }1439  if (!derivedKey) throw new Error("vault locked");1440  for (const entry of entries) {1441    if (!entry.deletedAt) {1442      entry.deletedAt = Date.now();1443      entry.modifiedAt = Date.now();1444    }1445  }1446  await persistEntries();1447}14481449export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1450  if (!derivedKey) throw new Error("vault locked");14511452  const sanitized = sanitizeEntryDraft(entry);1453  const now = Date.now();1454  const nextEntry: VaultEntry = {1455    ...sanitized,1456    id: crypto.randomUUID(),1457    createdAt: now,1458    modifiedAt: now,1459    // Stamp passwordChangedAt at creation so age tracking is accurate from1460    // day one. Non-login entries carry the same stamp harmlessly.1461    passwordChangedAt: sanitized.password ? now : undefined,1462  };14631464  entries.push(nextEntry);1465  await persistEntries();14661467  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)1468  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1469    checkBreach(nextEntry.password).then(async (count) => {1470      if (count > 0) {1471        const idx = entries.findIndex((e) => e.id === nextEntry.id);1472        if (idx !== -1) {1473          entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1474          await persistEntries();1475        }1476      }1477    });1478  }14791480  return nextEntry;1481}14821483export async function updateEntry(1484  id: string,1485  patch: Partial<VaultEntry>1486): Promise<void> {1487  if (!derivedKey) throw new Error("vault locked");14881489  const idx = entries.findIndex((entry) => entry.id === id);1490  if (idx === -1 || entries[idx].deletedAt) {1491    throw new Error("entry not found");1492  }14931494  const current = entries[idx];1495  const sanitizedPatch = sanitizeEntryPatch(patch);1496  // Refresh passwordChangedAt only when the password value actually changes —1497  // a metadata-only update (title, tags, etc.) must not reset the age clock1498  // that drives the Pro health rotation recommendation.1499  const passwordChanged =1500    "password" in patch &&1501    typeof sanitizedPatch.password === "string" &&1502    sanitizedPatch.password !== current.password;1503  entries[idx] = {1504    ...current,1505    ...sanitizedPatch,1506    modifiedAt: Date.now(),1507    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1508  };15091510  // A login entry must carry SOMETHING fillable — either a password or a1511  // passkey credential. An empty-password login with no passkey is dead1512  // state. Permit the password-clear when a passkey is attached (that's1513  // the legitimate "drop the password, keep the passkey" path).1514  if (1515    entries[idx].type === "login" &&1516    !entries[idx].password &&1517    !entries[idx].passkey1518  ) {1519    entries[idx] = current;1520    throw new Error("password or passkey required");1521  }15221523  // Card entries must retain their card number1524  if (entries[idx].type === "card" && !entries[idx].cardNumber) {1525    entries[idx] = current;1526    throw new Error("card number is required for card entries");1527  }15281529  await persistEntries();15301531  // Breach check when password changes: clear immediately so old breach doesn't linger,1532  // then async-check the new password and only re-set if it's also found.1533  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1534    entries[idx] = { ...entries[idx], breach: undefined };1535    await persistEntries();1536    const password = entries[idx].password;1537    if (password) {1538      checkBreach(password).then(async (count) => {1539        const i = entries.findIndex((e) => e.id === id);1540        if (i !== -1 && count > 0) {1541          entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1542          await persistEntries();1543        }1544      });1545    }1546  }1547}15481549export async function deleteEntry(id: string): Promise<void> {1550  if (!derivedKey) throw new Error("vault locked");15511552  const entry = entries.find((item) => item.id === id);1553  if (!entry || entry.deletedAt) {1554    throw new Error("entry not found");1555  }15561557  entry.deletedAt = Date.now();1558  entry.modifiedAt = Date.now();1559  await persistEntries();1560}15611562export function getDeletedEntries(): VaultEntry[] {1563  if (!derivedKey) return [];1564  return entries.filter((e) => !!e.deletedAt);1565}15661567export async function restoreEntry(id: string): Promise<void> {1568  if (!derivedKey) throw new Error("vault locked");1569  const entry = entries.find((e) => e.id === id && e.deletedAt);1570  if (!entry) throw new Error("deleted entry not found");1571  delete entry.deletedAt;1572  entry.modifiedAt = Date.now();1573  await persistEntries();1574  buildDomainIndexSync(entries);1575}15761577export async function purgeEntry(id: string): Promise<void> {1578  if (!derivedKey) throw new Error("vault locked");1579  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1580  if (idx === -1) throw new Error("deleted entry not found");1581  entries.splice(idx, 1);1582  await persistEntries();1583}15841585/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */1586export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1587  if (!derivedKey || retentionMs === 0) return 0;1588  const cutoff = Date.now() - retentionMs;1589  const before = entries.length;1590  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1591  const purged = before - entries.length;1592  if (purged > 0) await persistEntries();1593  return purged;1594}15951596export function queryDomain(domain: string): VaultEntry[] {1597  return domainIndex.get(domain) ?? [];1598}15991600export function getEntryById(id: string): VaultEntry | undefined {1601  return entries.find((entry) => entry.id === id && !entry.deletedAt);1602}16031604async function persistSlotA(1605  reason: VaultSnapshot["reason"] = "local_save",1606): Promise<StoredVault> {1607  if (!derivedKey) throw new Error("vault locked");16081609  const slotA = await loadSlot(SLOT_A);1610  if (!slotA) throw new Error("missing slot A");16111612  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures1613  // are swallowed by takeVaultSnapshot — history must never block a save.1614  await takeVaultSnapshot(reason);16151616  const plaintext = encodePayload(entries, duressConfigured);1617  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16181619  // Preserve existing recovery escrow fields.1620  const nextSlotA: StoredVault = {1621    ...slotA,1622    nonce,1623    ciphertext,1624    tag,1625    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot1626  };1627  await writeSlot(SLOT_A, nextSlotA);16281629  // When duress is not configured, keep slot B size-matched with slot A by1630  // regenerating its noise. When duress IS configured, leave slot B alone —1631  // we don't have the decoy key to re-encrypt it. This is a known trade-off.1632  if (!duressConfigured) {1633    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1634  }1635  await stampLocalVaultMutation();16361637  return nextSlotA;1638}16391640async function persistSlotB(): Promise<void> {1641  if (!derivedKey) throw new Error("vault locked");16421643  const slotB = await loadSlot(SLOT_B);1644  if (!slotB) throw new Error("missing slot B");16451646  // Decoy plaintext always reports duressConfigured=false — the decoy user1647  // sees a clean slate.1648  const plaintext = encodePayload(entries, false);1649  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16501651  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1652  await stampLocalVaultMutation();1653}16541655async function persistEntries(1656  reason: VaultSnapshot["reason"] = "local_save",1657): Promise<void> {1658  if (!derivedKey) throw new Error("vault locked");16591660  if (vaultMode === "decoy") {1661    await persistSlotB();1662  } else {1663    await persistSlotA(reason);1664  }1665  buildDomainIndexSync(entries);1666}16671668function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1669  if (!Array.isArray(values)) return undefined;1670  const seen = new Set<string>();1671  const cleaned: string[] = [];1672  for (const value of values) {1673    const trimmed = typeof value === "string" ? value.trim() : "";1674    if (!trimmed) continue;1675    const key = trimmed.toLowerCase();1676    if (seen.has(key)) continue;1677    seen.add(key);1678    cleaned.push(trimmed);1679  }1680  return cleaned.length > 0 ? cleaned : undefined;1681}16821683function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1684  const title = entry.title.trim();1685  // Accept legacy `type:"passkey"` drafts but rewrite to "login" — the new1686  // model is "any entry, optionally with a passkey sub-object." Imports and1687  // any in-flight callers from before v2.11.0 keep working without throwing.1688  let type: VaultEntry["type"] = entry.type ?? "login";1689  if (type === "passkey") type = "login";16901691  if (!title) throw new Error("title is required");1692  // A login entry needs either a password or a passkey credential to be useful.1693  // Passkey-only entries (passkey present, no password) are valid.1694  if (type === "login" && !entry.password?.trim() && !entry.passkey) {1695    throw new Error("password or passkey required");1696  }1697  if (entry.passkey) {1698    const p = entry.passkey;1699    if (1700      !p.rpId ||1701      !p.credentialId ||1702      !p.publicKeyCose ||1703      !p.userHandle ||1704      !p.userName ||1705      (p.algorithm !== -7 && p.algorithm !== -8)1706    ) {1707      throw new Error("invalid passkey credential");1708    }1709  }1710  if (type === "card") {1711    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1712      throw new Error("cardholder name and card number are required");1713    }1714  }17151716  return {1717    type,1718    title,1719    username: type === "login" ? entry.username?.trim() ?? "" : undefined,1720    password: type === "login" ? entry.password : undefined,1721    url: type === "login" ? entry.url?.trim() ?? "" : undefined,1722    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1723    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1724    notes: entry.notes?.trim() || undefined,1725    breach: type === "login" ? entry.breach : undefined,1726    canary: type === "login" ? entry.canary : undefined,1727    tags: entry.tags,1728    passkey: entry.passkey,1729    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1730    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1731    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1732    expYear: type === "card" ? entry.expYear?.trim() : undefined,1733    cvc: type === "card" ? entry.cvc?.trim() : undefined,1734    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1735    network: type === "card" ? entry.network : undefined,1736    last4: type === "card" ? entry.last4?.trim() : undefined,1737  };1738}17391740function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1741  const nextPatch: Partial<VaultEntry> = {};17421743  if ("type" in patch) {1744    nextPatch.type = patch.type;1745  }17461747  if ("title" in patch) {1748    const title = patch.title?.trim() ?? "";1749    if (!title) throw new Error("title is required");1750    nextPatch.title = title;1751  }17521753  if ("url" in patch) {1754    nextPatch.url = patch.url?.trim() ?? "";1755  }17561757  if ("additionalUrls" in patch) {1758    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1759    const seen = new Set<string>();1760    const cleaned: string[] = [];1761    for (const value of incoming) {1762      const trimmed = typeof value === "string" ? value.trim() : "";1763      if (!trimmed) continue;1764      const key = trimmed.toLowerCase();1765      if (seen.has(key)) continue;1766      seen.add(key);1767      cleaned.push(trimmed);1768    }1769    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1770  }17711772  if ("username" in patch) {1773    nextPatch.username = patch.username?.trim() ?? "";1774  }17751776  if ("email" in patch) {1777    nextPatch.email = patch.email?.trim() || undefined;1778  }17791780  if ("password" in patch) {1781    const password = patch.password ?? "";1782    // Allow clearing a password (e.g. on passkey-only entries)1783    nextPatch.password = password || undefined;1784  }17851786  if ("totp" in patch) {1787    nextPatch.totp = patch.totp?.trim() || undefined;1788  }17891790  if ("notes" in patch) {1791    nextPatch.notes = patch.notes?.trim() || undefined;1792  }17931794  if ("breach" in patch) {1795    nextPatch.breach = patch.breach;1796  }17971798  if ("canary" in patch) {1799    nextPatch.canary = patch.canary;1800  }18011802  if ("passkey" in patch) {1803    // Passkey sub-object replaces all the legacy flat passkey fields. A1804    // patch with `passkey: undefined` removes the credential from the entry1805    // (used by "remove passkey" without deleting the whole entry).1806    nextPatch.passkey = patch.passkey;1807  }18081809  if ("tags" in patch) {1810    nextPatch.tags = patch.tags;1811  }18121813  if ("cardholderName" in patch) {1814    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1815  }1816  if ("cardNumber" in patch) {1817    const n = patch.cardNumber ?? "";1818    nextPatch.cardNumber = n || undefined;1819  }1820  if ("expMonth" in patch) {1821    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1822  }1823  if ("expYear" in patch) {1824    nextPatch.expYear = patch.expYear?.trim() || undefined;1825  }1826  if ("cvc" in patch) {1827    nextPatch.cvc = patch.cvc?.trim() || undefined;1828  }1829  if ("cardNickname" in patch) {1830    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1831  }1832  if ("network" in patch) {1833    nextPatch.network = patch.network;1834  }1835  if ("last4" in patch) {1836    nextPatch.last4 = patch.last4?.trim() || undefined;1837  }18381839  return nextPatch;1840}18411842function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1843  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1844  const modifiedAt =1845    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;18461847  const base: VaultEntry = {1848    id: entry.id,1849    type: "type" in entry ? entry.type : "login",1850    title: entry.title,1851    username: entry.username,1852    password: entry.password,1853    url: entry.url,1854    totp: entry.totp,1855    notes: entry.notes,1856    breach: entry.breach,1857    canary: "canary" in entry ? entry.canary : undefined,1858    passkey: "passkey" in entry ? entry.passkey : undefined,1859    createdAt,1860    modifiedAt,1861    deletedAt: entry.deletedAt,1862    tags: "tags" in entry ? entry.tags : undefined,1863    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1864    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1865    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1866    expYear: "expYear" in entry ? entry.expYear : undefined,1867    cvc: "cvc" in entry ? entry.cvc : undefined,1868    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1869    network: "network" in entry ? entry.network : undefined,1870    last4: "last4" in entry ? entry.last4 : undefined,1871  };18721873  return migrateLegacyPasskey(base, entry);1874}18751876/**1877 * v2.11.0 read migration. Pre-v2.11.0 passkey entries used `type:"passkey"`1878 * with flat top-level fields (rpId, credentialId, etc.). This converts them1879 * to the new shape — `type:"login"` with a `passkey` sub-object — so all1880 * downstream consumers can rely on the new model. The result is rewritten1881 * to disk on next vault save (hasLegacyShape detects the un-migrated case).1882 */1883function migrateLegacyPasskey(1884  base: VaultEntry,1885  raw: VaultEntry | LegacyVaultEntry1886): VaultEntry {1887  if (base.passkey) return base;1888  const flat = raw as Partial<VaultEntry>;1889  const isPasskeyType = base.type === "passkey";1890  const hasFlatPasskeyFields =1891    typeof flat.rpId === "string" &&1892    typeof flat.credentialId === "string" &&1893    typeof flat.publicKeyCose === "string" &&1894    typeof flat.userHandle === "string" &&1895    typeof flat.userName === "string";1896  if (!isPasskeyType && !hasFlatPasskeyFields) return base;1897  if (!hasFlatPasskeyFields) return base;18981899  const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1900  base.passkey = {1901    rpId: flat.rpId!,1902    rpName: flat.rpName ?? flat.rpId!,1903    credentialId: flat.credentialId!,1904    publicKeyCose: flat.publicKeyCose!,1905    privateKeyJwk: flat.privateKeyJwk,1906    userHandle: flat.userHandle!,1907    userName: flat.userName!,1908    userDisplayName: flat.userDisplayName ?? flat.userName!,1909    algorithm,1910    signCount: flat.signCount ?? 0,1911    aaguid: flat.aaguid ?? "",1912    backupEligible: flat.backupEligible === true,1913    backupState: flat.backupState === true,1914    lastUsedAt: flat.lastUsedAt,1915    browser: flat.browser,1916    transports: flat.transports,1917    createdAt: base.createdAt,1918  };1919  // Promote to "login" so the unified Entry view renders. Username gets1920  // populated from the passkey's userName so the entry is searchable in the1921  // existing autofill flow without a separate code path.1922  if (isPasskeyType) base.type = "login";1923  if (!base.username) base.username = flat.userName;1924  if (!base.url) base.url = `https://${flat.rpId}`;1925  return base;1926}192719281929// ---------------------------------------------------------------------------1930// Deduplication1931// ---------------------------------------------------------------------------19321933function entryDupeKey(e: VaultEntry): string {1934  if (e.type === "note") return `note|${e.title.toLowerCase()}`;1935  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins1936  // on different sites that happen to share a username are still distinct;1937  // two passkey credentials for the same RP+user are duplicates regardless1938  // of any login fields they may also carry.1939  if (e.passkey) {1940    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1941  }1942  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1943}19441945/** Returns the number of duplicate entries (those that would be removed). */1946export function countDuplicates(): number {1947  const best = new Map<string, VaultEntry>();1948  let dupeCount = 0;1949  for (const e of entries.filter((e) => !e.deletedAt)) {1950    const key = entryDupeKey(e);1951    const existing = best.get(key);1952    if (!existing) {1953      best.set(key, e);1954    } else {1955      dupeCount++;1956      // Keep the more recently modified one in the map1957      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1958        best.set(key, e);1959      }1960    }1961  }1962  return dupeCount;1963}19641965/**1966 * Soft-deletes duplicate entries, keeping the most recently modified copy of1967 * each group. Returns the number of entries moved to trash.1968 */1969export async function deduplicateEntries(): Promise<number> {1970  if (!derivedKey) throw new Error("vault locked");1971  // Dedup reshapes slot B permanently under the decoy key, which is1972  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.1973  if (vaultMode === "decoy") {1974    throw new Error("deduplicate disabled in decoy mode");1975  }19761977  const best = new Map<string, VaultEntry>();1978  for (const e of entries.filter((en) => !en.deletedAt)) {1979    const key = entryDupeKey(e);1980    const existing = best.get(key);1981    if (!existing) {1982      best.set(key, e);1983    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1984      best.set(key, e);1985    }1986  }19871988  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1989  const now = Date.now();1990  let removed = 0;1991  entries = entries.map((e) => {1992    if (!e.deletedAt && !keepIds.has(e.id)) {1993      removed++;1994      return { ...e, deletedAt: now };1995    }1996    return e;1997  });19981999  if (removed > 0) await persistEntries();2000  return removed;2001}20022003// ---------------------------------------------------------------------------2004// Vault history — snapshot BEFORE every vault write so the saved blob is2005// what is about to be lost, not what was just written. Capped at 10. The2006// stored blobs are AES-256-GCM ciphertext under the master password, so2007// history adds zero security surface.2008// ---------------------------------------------------------------------------20092010const HISTORY_KEY = "peach_vault_history";2011const HISTORY_MAX = 10;20122013/**2014 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE2015 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost2016 * state. Identical-to-most-recent blobs are skipped to avoid filling history2017 * with no-op writes.2018 */2019export async function takeVaultSnapshot(2020  reason: VaultSnapshot["reason"],2021): Promise<void> {2022  try {2023    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2024    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2025    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];20262027    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,2028    // pre-createVault). createVault skips snapshots intentionally — there is2029    // no prior state to preserve.2030    if (!slotA) return;20312032    const vault_a = JSON.stringify(slotA);2033    const vault_b = JSON.stringify(slotB ?? slotA);20342035    // Best-effort entry count: only meaningful when the in-memory state is2036    // unlocked AND in real mode. Decoy mode would leak the decoy entry2037    // count; locked mode has no entries to count. Both fall through to null.2038    let entryCount: number | null = null;2039    if (derivedKey && vaultMode === "real") {2040      try {2041        entryCount = entries.filter((e) => !e.deletedAt).length;2042      } catch {2043        entryCount = null;2044      }2045    }20462047    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2048    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2049      ? existingRaw2050      : { snapshots: [] };20512052    // Deduplication: if the most recent snapshot has byte-identical blobs,2053    // skip — saves the same vault N times in a row would otherwise burn the2054    // entire history window with no recovery value.2055    const latest = history.snapshots[0];2056    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2057      return;2058    }20592060    const snapshot: VaultSnapshot = {2061      id: crypto.randomUUID(),2062      savedAt: Date.now(),2063      reason,2064      vault_a,2065      vault_b,2066      entryCount,2067    };20682069    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2070    await chrome.storage.local.set({ [HISTORY_KEY]: history });2071  } catch (err) {2072    // Non-fatal — history must never block a save. A failed snapshot just2073    // means the user loses one rung of the safety ladder for this write.2074    // M4/QUA-006: log so the failure is visible; if it looks like a quota2075    // problem, notify the popup so the user can prune history themselves.2076    console.warn('[Peach] takeVaultSnapshot failed:', err);2077    if (String(err).includes('QUOTA') || String(err).includes('quota')) {2078      try {2079        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2080      } catch {2081        // popup may be closed; surfaced via console.warn above2082      }2083    }2084  }2085}20862087/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */2088export async function clearVaultHistory(): Promise<void> {2089  try {2090    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2091  } catch {2092    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().2093  }2094}20952096/** Read all snapshots, newest first, for the Settings vault history view. */2097export async function getVaultHistory(): Promise<VaultSnapshot[]> {2098  const raw = await chrome.storage.local.get(HISTORY_KEY);2099  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2100  if (!history || !Array.isArray(history.snapshots)) return [];2101  return history.snapshots;2102}21032104/**2105 * Replace the on-disk vault with the chosen snapshot's blobs and lock the2106 * vault. The current state is itself snapshotted with reason 'restore' first2107 * so the restore is reversible. After this returns, the user must unlock2108 * with the password that matches the restored blob.2109 */2110export async function restoreVaultSnapshot(2111  snapshotId: string,2112): Promise<{ ok: true } | { ok: false; error: string }> {2113  const history = await getVaultHistory();2114  const snapshot = history.find((s) => s.id === snapshotId);2115  if (!snapshot) return { ok: false, error: "snapshot_not_found" };21162117  // Snapshot the about-to-be-replaced current state so the restore is2118  // reversible. takeVaultSnapshot is no-op when the most recent entry is2119  // byte-identical, so a restore-of-a-restore stays clean.2120  await takeVaultSnapshot("restore");21212122  let restoredA: StoredVault;2123  let restoredB: StoredVault;2124  try {2125    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2126    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2127  } catch {2128    return { ok: false, error: "snapshot_corrupt" };2129  }21302131  await writeSlot(SLOT_A, restoredA);2132  await writeSlot(SLOT_B, restoredB);2133  await stampLocalVaultMutation();21342135  // Lock so the user must prove they can decrypt the restored blob. If they2136  // restored a pre-password-change snapshot they discover it immediately at2137  // the lock screen rather than silently holding an inaccessible vault.2138  lock();21392140  return { ok: true };2141}21422143/**2144 * Change the master password. Re-encrypts slot A with a fresh salt + new2145 * derived key. Slot B (duress or noise) is left untouched when duress is2146 * configured — it uses an independent key derived from the duress password —2147 * and regenerated as noise when duress is not configured.2148 */2149export async function changePassword(2150  currentPassword: string,2151  newPassword: string2152): Promise<boolean> {2153  if (!derivedKey || vaultMode !== "real") return false;21542155  // Verify current password before allowing any change2156  const valid = await verifyMainPassword(currentPassword);2157  if (!valid) return false;21582159  const slotA = await loadSlot(SLOT_A);2160  if (!slotA) return false;2161  if (!rootKey) return false;21622163  // Derive fresh key from new password + new random salt2164  const newSalt = generateSalt();2165  const newKey = await deriveKey(newPassword, newSalt);21662167  // Re-encrypt the in-memory entries with the new key2168  const plaintext = encodePayload(entries, duressConfigured);2169  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);21702171  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey2172  // under recovery words and remain untouched — recovery phrase keeps working2173  // across password changes (H4/COR-002).2174  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);21752176  const finalSlotA: StoredVault = {2177    ...slotA,2178    salt: hex(newSalt),2179    nonce,2180    ciphertext,2181    tag,2182    ...pwRoot,2183  };21842185  // Snapshot the OLD-key blob before overwriting it. If the user later2186  // restores from this snapshot they will need the OLD password — the2187  // post-restore lock surfaces that immediately.2188  await takeVaultSnapshot("local_save");21892190  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in2191  // one storage transaction. An SW kill before this point leaves the old2192  // slot intact; after this point everything tied to the new password is2193  // already on disk together (M6/COR-004).2194  await writeSlot(SLOT_A, finalSlotA);21952196  // Swap the in-memory key synchronously before any further await. If we2197  // threw between the Slot A write and this swap, the next persistEntries()2198  // would overwrite Slot A with old-key ciphertext, locking the user out.2199  zeroKey(derivedKey);2200  derivedKey = newKey;22012202  // Regenerate noise for slot B when duress is not in use. Runs after the2203  // key swap so a failure here is a disposable noise regeneration, not a2204  // lockout.2205  if (!duressConfigured) {2206    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2207  }2208  await stampLocalVaultMutation();22092210  return true;2211}22122213// ---------------------------------------------------------------------------2214// Sync helpers2215// ---------------------------------------------------------------------------22162217export async function getVaultBlobForSync(): Promise<{2218  blob: string;2219  timestamp: number;2220  license?: string;2221} | null> {2222  const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2223  const slotA = (raw as Record<string, unknown>)[SLOT_A];2224  if (!slotA) return null;2225  const slotB = (raw as Record<string, unknown>)[SLOT_B];2226  const blob = JSON.stringify({ a: slotA, b: slotB });2227  const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2228  const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2229  const license = (raw as Record<string, unknown>)['peach_license'];2230  return {2231    blob,2232    timestamp,2233    license: typeof license === 'string' ? license : undefined,2234  };2235}22362237export async function applyIncomingVault(2238  blobJson: string,2239  remoteTimestamp: number,2240  remoteLicense?: string,2241): Promise<boolean> {2242  const raw = await chrome.storage.local.get('peach_last_modified_at');2243  const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2244  const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;22452246  if (remoteLicense) {2247    const licenseRaw = await chrome.storage.local.get('peach_license');2248    const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2249    if (!existing) {2250      await chrome.storage.local.set({ peach_license: remoteLicense });2251    }2252  }22532254  if (remoteTimestamp <= localTimestamp) return false;22552256  // Parse + shape-validate before any storage mutation. A malformed blob2257  // (non-JSON, non-object, missing slot fields) must NOT touch slot A —2258  // otherwise an unauthenticated remote could poison the local vault by2259  // sending '[]' or '42' (M10/COR-012). Mirror the structural check2260  // already used in unlockFromCodex.2261  let parsed: unknown;2262  try {2263    parsed = JSON.parse(blobJson);2264  } catch {2265    throw new Error('invalid_incoming_vault_blob');2266  }2267  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {2268    throw new Error('invalid_incoming_vault_blob');2269  }2270  const { a, b } = parsed as { a?: unknown; b?: unknown };2271  if (!hasValidVaultSchema(a)) {2272    throw new Error('invalid_incoming_vault_blob');2273  }22742275  // Snapshot before overwriting — non-fatal but user-visible.2276  // M4/QUA-006: a silent snapshot failure leaves the user without their2277  // safety net for an incoming sync write; surface it.2278  try {2279    await takeVaultSnapshot('sync_received');2280  } catch (snapshotErr) {2281    console.warn('[Peach] vault snapshot failed during sync:', snapshotErr);2282    if (String(snapshotErr).includes('QUOTA') || String(snapshotErr).includes('quota')) {2283      try {2284        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2285      } catch {2286        // popup may be closed; surfaced via console.warn above2287      }2288    }2289  }22902291  await writeSlot(SLOT_A, a);2292  if (hasValidVaultSchema(b)) await writeSlot(SLOT_B, b);2293  await chrome.storage.local.set({2294    peach_last_sync_at: String(Date.now()),2295    peach_last_modified_at: String(remoteTimestamp),2296  });22972298  return true;2299}23002301/**2302 * Returns the X25519 sync identity, encrypting the private key on first call2303 * and on any unencrypted-shape migration. Requires the vault to be unlocked2304 * — the private key is wrapped under the same derived key as the vault, so2305 * a locked browser cannot leak it from storage alone (M12/SEC-003).2306 *2307 * Persistence shapes:2308 *   v0 (pre-M12, migrated on first read):2309 *     { priv: base64, pub: base64 }2310 *   v1 (encrypted):2311 *     { pub: base64, enc_priv: hex, enc_nonce: hex, enc_tag: hex }2312 */2313export async function getSyncIdentity(): Promise<{2314  privateKeyBytes: Uint8Array;2315  publicKeyBytes: Uint8Array;2316  publicKeyHex: string;2317}> {2318  if (!derivedKey) throw new Error('vault_locked');2319  const raw = await chrome.storage.local.get('peach_sync_identity');2320  const stored = (raw as Record<string, unknown>)['peach_sync_identity'];23212322  if (typeof stored === 'string') {2323    const parsed = JSON.parse(stored) as {2324      pub: string;2325      priv?: string;2326      enc_priv?: string;2327      enc_nonce?: string;2328      enc_tag?: string;2329    };2330    const publicKeyBytes = base64DecodeBytes(parsed.pub);23312332    if (parsed.enc_priv && parsed.enc_nonce && parsed.enc_tag) {2333      const privHex = await decrypt(2334        derivedKey,2335        parsed.enc_nonce,2336        parsed.enc_priv,2337        parsed.enc_tag,2338      );2339      const privateKeyBytes = unhex(privHex);2340      return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2341    }23422343    if (parsed.priv) {2344      // Migrate v0 (unencrypted private key) → v1.2345      const privateKeyBytes = base64DecodeBytes(parsed.priv);2346      await persistEncryptedSyncIdentity(privateKeyBytes, publicKeyBytes);2347      return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2348    }2349  }23502351  // Generate a fresh X25519 keypair and persist it encrypted.2352  const { generateKeyPair } = await import('@stablelib/x25519');2353  const keypair = generateKeyPair();2354  await persistEncryptedSyncIdentity(keypair.secretKey, keypair.publicKey);2355  return {2356    privateKeyBytes: keypair.secretKey,2357    publicKeyBytes: keypair.publicKey,2358    publicKeyHex: bytesToHex(keypair.publicKey),2359  };2360}23612362async function persistEncryptedSyncIdentity(2363  privateKeyBytes: Uint8Array,2364  publicKeyBytes: Uint8Array,2365): Promise<void> {2366  if (!derivedKey) throw new Error('vault_locked');2367  const enc = await encrypt(derivedKey, hex(privateKeyBytes));2368  await chrome.storage.local.set({2369    peach_sync_identity: JSON.stringify({2370      pub: base64EncodeBytes(publicKeyBytes),2371      enc_priv: enc.ciphertext,2372      enc_nonce: enc.nonce,2373      enc_tag: enc.tag,2374    }),2375  });2376}23772378// Re-decrypt vault entries from storage using the current in-memory key.2379// Called after applyIncomingVault so the in-memory state stays consistent2380// with what was just written to storage.2381export async function reloadEntriesAfterSync(): Promise<boolean> {2382  if (!derivedKey) return false;2383  const slotA = await loadSlot(SLOT_A);2384  if (!slotA) return false;2385  try {2386    const payload = await tryDecryptSlot(slotA, derivedKey);2387    if (!payload) return false;2388    entries = payload.entries;2389    buildDomainIndexSync(entries);2390    return true;2391  } catch {2392    return false;2393  }2394}23952396function bytesToHex(bytes: Uint8Array): string {2397  return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2398}23992400function base64EncodeBytes(bytes: Uint8Array): string {2401  let binary = '';2402  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2403  return btoa(binary);2404}24052406function base64DecodeBytes(b64: string): Uint8Array {2407  const binary = atob(b64);2408  const bytes = new Uint8Array(binary.length);2409  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2410  return bytes;2411}