vault.ts

111.4 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 { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";16// Static, not `await import()`: runtime import() throws in an MV3 service17// worker. Neither module imports vault.ts back, so there is no cycle to break.18import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21  rootKeyToWords,22  wordsToRootKeyMaterial,23  deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26  AccentColorName,27  Canary,28  ManualSummonRecord,29  PasskeyData,30  SavedIdentity,31  StoredVault,32  VaultEntry,33  VaultHistory,34  VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import { setRecoveryConfirmPending } from "./recovery-pending";3940type EntryDraft = Omit<41  VaultEntry,42  "id" | "createdAt" | "modifiedAt" | "deletedAt"43>;4445// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B46// either holds the decoy vault (when duress is configured) or random noise47// that looks identical at rest. No external indicator is stored — duress48// configuration is recorded only inside the encrypted payload of slot A.49const SLOT_A = "peach_vault_a";50const SLOT_B = "peach_vault_b";5152// Plaintext is padded to the next multiple of this size before encryption.53// This coarsens ciphertext-length leakage into 1 KiB buckets.54const PAD_BLOCK = 1024;5556// How many past passwords to retain per entry. Generous enough to be useful as57// a recovery aid while bounding the secret material we hold (and the ciphertext58// growth) for an entry whose password is rotated frequently.59const PASSWORD_HISTORY_MAX = 50;6061// When duress is configured, BOTH slots are padded to this fixed ceiling so62// their ciphertext lengths are identical and cannot reveal that a hidden decoy63// vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably64// holds a very large vault (thousands of entries incl. passkeys). The decoy65// slot can't be re-encrypted on every real-vault save (the real session has no66// decoy key), so a fixed ceiling — rather than size-matching on each write — is67// what keeps the two slots equal. A real vault whose plaintext exceeds this68// would grow past the ceiling and re-expose the bit; that is an extreme,69// documented edge well beyond normal use.70const DURESS_SLOT_SIZE = 262_144;7172let derivedKey: Uint8Array | null = null;73// 32-byte random root key, separate from the derivedKey used for vault encryption.74// The root key is wrapped (encrypted) under the recovery key derived from the75// 12-word phrase. When the user changes their password, only the wrapping changes;76// the root key and recovery words remain stable.77let rootKey: Uint8Array | null = null;78let entries: VaultEntry[] = [];79// Tombstones outlive their full entry records so a peer that was offline80// during a purge cannot reintroduce an old ciphertext envelope. This map is81// serialized only inside the encrypted vault payload.82let tombstones = new Map<string, number>();83let domainIndex: Map<string, VaultEntry[]> = new Map();84let vaultMode: "real" | "decoy" = "real";85// Cached post-unlock view of whether the real vault records that duress is86// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock87// this stays false because we never see slot A's plaintext.88let duressConfigured = false;8990// Set to a corruption reason during the last unlock() attempt when both slots91// decrypted but failed schema validation. Consumed and cleared by92// consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted93// distinctly from wrong-password (M7/COR-007).94//95// NOTE: module-level state — if vault.ts is ever moved to a worker context,96// this must be replaced with a structured return value from unlock() or a97// postMessage channel. The signal is invisible across module-instance98// boundaries.99let lastUnlockFailureReason: string | null = null;100101export function consumeLastUnlockFailureReason(): string | null {102  const r = lastUnlockFailureReason;103  lastUnlockFailureReason = null;104  return r;105}106107// ---------------------------------------------------------------------------108// Settings (persisted inside the encrypted vault store as a sidecar key)109// ---------------------------------------------------------------------------110111export type TextSize = "small" | "medium" | "large";112113export interface VaultSettings {114  accentColor?: AccentColorName;115  theme?: "dark" | "light";116  autoLockMinutes?: number;117  clipboardClearSeconds?: number;118  lockOnClose?: boolean;119  breachMonitoring?: boolean;120  passkeyProviderEnabled?: boolean;121  trashRetentionDays?: number;122  manualSummonDomains?: Record<string, ManualSummonRecord>;123  declinedCardFingerprints?: string[];124  /**125   * Text size scale for body copy and entry titles. Stops at "large" by126   * design — XL clips dense screens (Vault, Health) inside the fixed127   * 330×480 popup surface.128   */129  textSize?: TextSize;130  /**131   * When true, all entrance/exit transitions and ambient animations are132   * collapsed to instant. Honoured alongside the user's OS-level133   * `prefers-reduced-motion`.134   */135  reduceMotion?: boolean;136  /** Whether this vault has a local PIN unlock wrapper configured. */137  pinEnabled?: boolean;138  /** Whether the lock screen should open on PIN instead of the password. */139  pinPreferred?: boolean;140  /**141   * User overrides for keyboard shortcuts. Keys are ShortcutAction names142   * (see src/shared/shortcuts.ts). Missing actions fall through to143   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the144   * canonical lookup. Storing only the deltas keeps the vault payload145   * small and lets default updates land for users who never rebind.146   */147  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;148}149150interface StoredPinWrap {151  version: 1;152  salt: string;153  nonce: string;154  ciphertext: string;155  tag: string;156}157158const PIN_WRAP_STORAGE = "peach_pin_wrap";159const PIN_MIN_LENGTH = 4;160const PIN_MAX_LENGTH = 12;161162let settings: VaultSettings = {};163164export function getSettings(): VaultSettings {165  return settings;166}167168export async function ensureSettingsLoaded(): Promise<VaultSettings> {169  await loadSettings();170  return settings;171}172173export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {174  await loadSettings();175  // LV-7: cheap insurance against prototype pollution if a future caller ever176  // feeds parsed external JSON here (native spread keeps __proto__ an own-177  // property today, but Object.assign-style consumers and JSON revivers have178  // been burned before).179  const safe = Object.fromEntries(180    Object.entries(patch).filter(181      ([k]) => !["__proto__", "constructor", "prototype"].includes(k),182    ),183  ) as Partial<VaultSettings>;184  settings = { ...settings, ...safe };185  await persistSettings();186}187188function isValidPin(pin: string): boolean {189  return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);190}191192/** Set up a local PIN wrapper after proving the current master password. */193export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {194  if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;195  if (!(await verifyMainPassword(currentPassword))) return false;196197  const salt = generateSalt();198  const pinKey = await deriveKey(pin, salt);199  try {200    const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");201    await chrome.storage.local.set({202      [PIN_WRAP_STORAGE]: { version: 1, salt: hex(salt), ...wrapped } satisfies StoredPinWrap,203    });204    await updateSettings({ pinEnabled: true });205    return true;206  } finally {207    zeroKey(pinKey);208  }209}210211async function clearPinUnlockStorage(): Promise<void> {212  await chrome.storage.local.remove(PIN_WRAP_STORAGE);213  await updateSettings({ pinEnabled: false });214}215216/** Remove the PIN wrapper after proving the current master password. */217export async function disablePinUnlock(currentPassword: string): Promise<boolean> {218  if (!derivedKey || vaultMode !== "real") return false;219  if (!(await verifyMainPassword(currentPassword))) return false;220  await clearPinUnlockStorage();221  return true;222}223224/** Unlock the real vault from its locally stored PIN-wrapped derived key. */225export async function unlockWithPin(pin: string): Promise<boolean> {226  if (!isValidPin(pin)) return false;227  await loadSettings();228  if (!settings.pinEnabled) return false;229230  const stored = (await chrome.storage.local.get(PIN_WRAP_STORAGE))[PIN_WRAP_STORAGE] as231    | StoredPinWrap232    | undefined;233  if (234    !stored ||235    stored.version !== 1 ||236    !stored.salt ||237    !stored.nonce ||238    !stored.ciphertext ||239    !stored.tag240  ) return false;241242  const pinKey = await deriveKey(pin, unhex(stored.salt));243  let unwrappedKey: Uint8Array | null = null;244  try {245    const masterKeyHex = await decrypt(246      pinKey,247      stored.nonce,248      stored.ciphertext,249      stored.tag,250      "pin_wrap_v1",251    );252    if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;253    unwrappedKey = unhex(masterKeyHex);254    const result = await unlockWithKey(masterKeyHex);255    if (!result.ok) return false;256    await saveSessionKey(unwrappedKey, "real");257    return true;258  } catch {259    return false;260  } finally {261    zeroKey(pinKey);262    if (unwrappedKey) zeroKey(unwrappedKey);263  }264}265266async function persistSettings(): Promise<void> {267  try {268    await chrome.storage.local.set({ peach_settings: settings });269  } catch (err) {270    throw mapStorageError(err);271  }272}273274function mapStorageError(err: unknown): Error {275  const message = err instanceof Error ? err.message : String(err);276  if (/QUOTA_BYTES|quota/i.test(message)) {277    return new Error("STORAGE_QUOTA_EXCEEDED");278  }279  return err instanceof Error ? err : new Error(message);280}281282async function loadSettings(): Promise<void> {283  const result = await chrome.storage.local.get("peach_settings");284  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};285}286287// Fire-and-forget sync trigger after a committed vault write. A failure here288// must never fail the write that caused it.289function fireSyncTrigger(reason: "vault_change"): void {290  void Promise.resolve()291    .then(() => triggerSyncIfReachable({ reason }))292    .catch(() => {293      /* no sync runner registered, or nothing reachable — non-fatal */294    });295}296297let lockedSyncRefreshHook: (() => Promise<void>) | null = null;298let lockedSyncUnlockHook: (() => Promise<void>) | null = null;299300export function setLockedSyncHooks(hooks: {301  refresh: () => Promise<void>;302  afterUnlock: () => Promise<void>;303}): void {304  lockedSyncRefreshHook = hooks.refresh;305  lockedSyncUnlockHook = hooks.afterUnlock;306}307308async function refreshLockedSyncState(): Promise<void> {309  if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;310  await lockedSyncRefreshHook().catch((error) => {311    console.warn("[Peach sync] locked spool refresh failed:", error);312  });313}314315// ---------------------------------------------------------------------------316// HIBP breach check (k-anonymity model)317// ---------------------------------------------------------------------------318319export async function checkBreach(password: string): Promise<number> {320  // HIBP k-anonymity API is keyed on SHA-1 prefixes — this is dictated by321  // the public API, not a security choice. Do not migrate to SHA-256 here.322  const encoder = new TextEncoder();323  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));324  const hashHex = Array.from(new Uint8Array(hashBuffer))325    .map((b) => b.toString(16).padStart(2, "0"))326    .join("")327    .toUpperCase();328329  const prefix = hashHex.slice(0, 5);330  const suffix = hashHex.slice(5);331332  try {333    // PCH-02-007: request HIBP response padding so response length does not334    // reveal how many suffixes a queried prefix returned.335    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {336      headers: { "Add-Padding": "true" },337    });338    if (!res.ok) return 0;339    const text = await res.text();340    const match = text.split("\r\n").find((l) => l.startsWith(suffix));341    return match ? parseInt(match.split(":")[1], 10) : 0;342  } catch {343    return 0; // Network unavailable — don't block344  }345}346347export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {348  const fingerprint = await cardFingerprint(last4, expMonth, expYear);349  await loadSettings();350  settings = {351    ...settings,352    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],353  };354  await persistSettings();355}356357export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {358  await loadSettings();359  const fingerprint = await cardFingerprint(last4, expMonth, expYear);360  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);361}362363async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {364  const encoder = new TextEncoder();365  const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));366  return Array.from(new Uint8Array(buf).slice(0, 8))367    .map((b) => b.toString(16).padStart(2, "0"))368    .join("");369}370371function hex(buf: Uint8Array): string {372  return Array.from(buf)373    .map((b) => b.toString(16).padStart(2, "0"))374    .join("");375}376377function unhex(s: string): Uint8Array {378  const out = new Uint8Array(s.length / 2);379  for (let i = 0; i < s.length; i += 2) {380    out[i / 2] = parseInt(s.slice(i, i + 2), 16);381  }382  return out;383}384385// ---------------------------------------------------------------------------386// Root key generation and recovery word wrapping387// ---------------------------------------------------------------------------388389/**390 * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key391 * derived from the recovery word material via HKDF-SHA256. The wrapped blob392 * fields are stored in slot A alongside the password-wrapped ciphertext. The393 * root key itself is kept in memory for the session so getRecoveryWords() can394 * encode it without re-deriving.395 *396 * SECURITY MODEL (PCH-01-005): the recovery KEK is derived from the 12-word397 * material, which encodes only rootKey[0..16] (recovery-words.ts). The KEK is thus398 * derived from a PREFIX of the very plaintext it wraps, and effective recovery399 * security is 128-bit (standard for a 12-word phrase), NOT 256-bit. This is safe —400 * unwrapping requires the words, and the GCM ciphertext leaks nothing without them —401 * but it is intentional, not an oversight. A future escrow version wanting 256-bit402 * recovery must derive the words from INDEPENDENT entropy, not from the root key.403 */404async function generateAndWrapRootKey(): Promise<{405  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey406  recoverySalt: string;407  wrappedRootKey: string;408  wrappedNonce: string;409  wrappedTag: string;410}> {411  const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));412413  // Derive the recovery key from the BIP39 words encoding of the root key.414  const words = await rootKeyToWords(rootKeyBytes);415  const wordMaterial = await wordsToRootKeyMaterial(words);416  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));417  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);418419  // Wrap the root key with the recovery key using AES-256-GCM directly.420  // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.421  const nonce = crypto.getRandomValues(new Uint8Array(12));422  const encoded = new TextEncoder().encode(hex(rootKeyBytes));423  const raw = await crypto.subtle.encrypt(424    {425      name: "AES-GCM",426      iv: nonce,427      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,428      tagLength: 128,429    },430    recoveryKey,431    encoded432  );433  // WebCrypto returns ciphertext || auth tag concatenated; split them.434  const rawU8 = new Uint8Array(raw);435  const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));436  const tag = hex(rawU8.slice(rawU8.length - 16));437438  return {439    rawRootKey: hex(rootKeyBytes),440    recoverySalt: hex(recoverySalt),441    wrappedRootKey: ciphertext,442    wrappedNonce: hex(nonce),443    wrappedTag: tag,444  };445}446447async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{448  recoverySalt: string;449  wrappedRootKey: string;450  wrappedNonce: string;451  wrappedTag: string;452}> {453  if (rootKeyBytes.length !== 32) {454    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");455  }456457  const words = await rootKeyToWords(rootKeyBytes);458  const wordMaterial = await wordsToRootKeyMaterial(words);459  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));460  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);461  const nonce = crypto.getRandomValues(new Uint8Array(12));462  const encoded = new TextEncoder().encode(hex(rootKeyBytes));463  const raw = await crypto.subtle.encrypt(464    {465      name: "AES-GCM",466      iv: nonce,467      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,468      tagLength: 128,469    },470    recoveryKey,471    encoded,472  );473  const rawU8 = new Uint8Array(raw);474  return {475    recoverySalt: hex(recoverySalt),476    wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),477    wrappedNonce: hex(nonce),478    wrappedTag: hex(rawU8.slice(rawU8.length - 16)),479  };480}481482/**483 * Unwrap the root key using recovery words. Called during recovery flow when484 * the user provides their 12-word phrase and a new password.485 * Exported for use by the RECOVER_WITH_WORDS message handler.486 */487export async function unwrapRootKeyForRecovery(488  wrappedRootKey: string,489  wrappedNonce: string,490  wrappedTag: string,491  recoverySaltHex: string,492  words: string[]493): Promise<void> {494  const wordMaterial = await wordsToRootKeyMaterial(words);495  const recoverySalt = unhex(recoverySaltHex);496  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);497498  // Decrypt and cache the root key. The root key bytes were hex-encoded before499  // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt500  // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.501  // Each component is stored separately in the vault slot; decode them individually502  // so the nonce is always exactly the 12 bytes from wrappedNonce.503  const nonceRaw = unhex(wrappedNonce);504  const ciphertextRaw = unhex(wrappedRootKey);505  const tagRaw = unhex(wrappedTag);506  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).507  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.508  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;509  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);510  combined.set(ciphertextRaw, 0);511  combined.set(tagRaw, ciphertextRaw.length);512  const raw = await crypto.subtle.decrypt(513    { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },514    recoveryKey,515    combined516  );517  const decryptedHex = new TextDecoder().decode(raw);518  rootKey = unhex(decryptedHex);519}520521/**522 * Re-wrap the cached root key under a new derived key (from the user's new523 * password). Called after password change and after recovery re-key so the524 * same recovery words continue to work with the updated password — and so525 * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.526 *527 * recovery_* fields are intentionally not touched: those wrap rootKey under528 * the recovery words, which do not change when the password changes.529 */530export async function rewrapRootKeyForPassword(531  newDerivedKey: Uint8Array,532  _newSalt: Uint8Array533): Promise<void> {534  if (!rootKey) throw new Error("root key not set");535  const slotA = await loadSlot(SLOT_A);536  if (!slotA) throw new Error("missing slot A");537  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);538  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });539}540541/**542 * Change the vault encryption to a new derived key (already derived from543 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.544 * Caller is responsible for key swap and snapshot.545 */546async function reencryptVaultForNewKey(547  newKey: Uint8Array,548  slotA: StoredVault549): Promise<void> {550  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);551  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");552  await writeSlot(SLOT_A, {553    ...slotA,554    nonce,555    ciphertext,556    tag,557  });558}559560/**561 * Full password change with an already-derived key (used after recovery).562 * Unlike changePassword(), this skips re-verification of current password563 * since recovery flow has already validated the user's identity via words.564 */565export async function changePasswordWithNewKey(566  newSalt: Uint8Array,567  newKey: Uint8Array568): Promise<void> {569  const slotA = await loadSlot(SLOT_A);570  if (!slotA) throw new Error("missing slot A");571  // reencryptVaultForNewKey must receive the updated salt so that subsequent572  // UNLOCK calls with the new password can re-derive the correct key.573  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });574  if (derivedKey) zeroKey(derivedKey);575  derivedKey = newKey;576  // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow577  // reaches this path, so a post-recovery SW restart would otherwise lock out578  // a user who just reset their password.579  void saveSessionKey(newKey, "real");580  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works581  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow582  // should have populated it — fall back silently rather than blowing up the583  // recovery path; the escrow is already present on every supported vault.584  if (rootKey) {585    const slotAfter = await loadSlot(SLOT_A);586    if (slotAfter) {587      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);588      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });589    }590  }591  if (!duressConfigured) {592    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));593  }594  await clearPinUnlockStorage();595  await stampLocalVaultMutation();596}597598// ---------------------------------------------------------------------------599// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive600// a new key after password reset without re-importing the crypto module.601// ---------------------------------------------------------------------------602603export { deriveKey, generateSalt };604605export async function loadSlotA(): Promise<StoredVault | null> {606  return loadSlot(SLOT_A);607}608609/**610 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields611 * (a second wrapping of the root key under the password-derived key). This612 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without613 * the user re-entering their recovery phrase: the same root key bytes —614 * and therefore the same 12 words — survive every unlock.615 *616 * Returns null when the escrow is absent or decryption fails.617 */618async function deriveRootKeyFromDerivedKey(619  derivedKeyBytes: Uint8Array,620  slotA: StoredVault,621): Promise<Uint8Array | null> {622  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {623    return null;624  }625  try {626    const rootHex = await decrypt(627      derivedKeyBytes,628      slotA.pw_root_nonce,629      slotA.pw_root_ciphertext,630      slotA.pw_root_tag,631      "pw_root_v1",632    );633    return unhex(rootHex);634  } catch {635    return null;636  }637}638639// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the640// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.641async function wrapRootKeyForDerivedKey(642  derivedKeyBytes: Uint8Array,643  rootKeyBytes: Uint8Array,644): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {645  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");646  return {647    pw_root_ciphertext: ciphertext,648    pw_root_nonce: nonce,649    pw_root_tag: tag,650  };651}652653/** Returns the 12 recovery words for the current session's root key. */654export async function getRecoveryWords(): Promise<string[]> {655  if (!rootKey) {656    throw new Error("recovery not set up");657  }658  return rootKeyToWords(rootKey);659}660661async function loadSlot(key: string): Promise<StoredVault | null> {662  const result = await chrome.storage.local.get(key);663  return (result as Record<string, StoredVault | undefined>)[key] ?? null;664}665666async function writeSlot(key: string, vault: StoredVault): Promise<void> {667  try {668    await chrome.storage.local.set({ [key]: vault });669  } catch (err) {670    throw mapStorageError(err);671  }672}673674/**675 * Roll back a vault that was created by [createVaultFromRootKey] but676 * couldn't be paired (e.g. the subsequent license activation, identity677 * derivation, or paired-device upsert failed). Without this, a failed678 * receiver session leaves behind an empty vault — the user has to679 * re-pair to get a working setup, but the empty vault sits in the way.680 *681 * The call is idempotent: removing keys that aren't there is a no-op.682 */683export async function rollbackAdoptedVault(): Promise<void> {684  lock();685  try {686    await chrome.storage.local.remove([687      SLOT_A,688      SLOT_B,689      "peach_last_modified_at",690    ]);691  } catch (err) {692    throw mapStorageError(err);693  }694}695696async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {697  const raw = await chrome.storage.local.get("peach_last_modified_at");698  const previous = Number(699    (raw as Record<string, unknown>).peach_last_modified_at,700  );701  const next = Math.max(702    Date.now(),703    Number.isFinite(previous) ? previous + 1 : 0,704  );705  try {706    await chrome.storage.local.set({707      [SLOT_A]: slotA,708      [SLOT_B]: slotB,709      peach_last_modified_at: String(next),710    });711  } catch (err) {712    throw mapStorageError(err);713  }714}715716// Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the717// sync layer's last-write-wins merge against wall-clock regressions (NTP step,718// manual clock change, DST glitch): a backwards Date.now() must never produce719// an entry timestamp that's <= the entry's existing modifiedAt, or a genuine720// local edit would lose to a stale remote copy. The returned value is always721// strictly greater than both the entry's current modifiedAt and the previous722// call's result (SEV-2-E).723let lastEntryTimestamp = 0;724function monotonicEntryTimestamp(currentModifiedAt?: number): number {725  const base = Math.max(726    Date.now(),727    (currentModifiedAt ?? 0) + 1,728    lastEntryTimestamp + 1,729  );730  lastEntryTimestamp = base;731  return base;732}733734async function stampLocalVaultMutation(): Promise<void> {735  const raw = await chrome.storage.local.get("peach_last_modified_at");736  const previous = Number(737    (raw as Record<string, unknown>).peach_last_modified_at,738  );739  const next = Math.max(740    Date.now(),741    Number.isFinite(previous) ? previous + 1 : 0,742  );743  await chrome.storage.local.set({744    peach_last_modified_at: String(next),745  });746}747748// ---------------------------------------------------------------------------749// Padding + noise helpers750// ---------------------------------------------------------------------------751752/**753 * Pads the plaintext string with trailing spaces so that the encoded byte754 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing755 * whitespace after a complete document, so the padding is transparent on756 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.757 */758function padPlaintext(json: string, minBytes = 0): string {759  const encoded = new TextEncoder().encode(json);760  const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);761  // minBytes lets the duress path force a fixed ceiling so both slots match.762  const target = Math.max(bucket, minBytes);763  const padLen = target - encoded.length;764  return json + " ".repeat(padLen);765}766767/**768 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The769 * ciphertext length matches the given byte count. Because GCM ciphertext is770 * pseudo-random, this blob is indistinguishable at rest from a real771 * encrypted vault without the key.772 *773 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto774 * spec hard limit). Chunk the fill so vaults larger than that — common once775 * the user has dozens of entries or any passkey — don't throw.776 */777const CSPRNG_CHUNK = 65_536;778779function fillRandom(buf: Uint8Array): Uint8Array {780  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {781    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);782    const chunk = new Uint8Array(end - offset);783    crypto.getRandomValues(chunk);784    buf.set(chunk, offset);785  }786  return buf;787}788789function generateNoiseBlob(ciphertextBytes: number): StoredVault {790  const salt = crypto.getRandomValues(new Uint8Array(16));791  const nonce = crypto.getRandomValues(new Uint8Array(12));792  const cipher = fillRandom(new Uint8Array(ciphertextBytes));793  const tag = crypto.getRandomValues(new Uint8Array(16));794  return {795    version: 1,796    salt: hex(salt),797    nonce: hex(nonce),798    ciphertext: hex(cipher),799    tag: hex(tag),800  };801}802803// ---------------------------------------------------------------------------804// Encrypted plaintext envelope805// ---------------------------------------------------------------------------806807type VaultPayload = {808  entries: VaultEntry[];809  tombstones?: Record<string, number>;810  duressConfigured?: boolean;811};812813type DecryptedPayload = {814  entries: VaultEntry[];815  tombstones: Map<string, number>;816  duressConfigured: boolean;817};818819function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {820  const envelope: VaultPayload = {821    entries,822    tombstones: Object.fromEntries(tombstones),823    duressConfigured: duress,824  };825  return padPlaintext(JSON.stringify(envelope), minBytes);826}827828function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {829  const result = new Map<string, number>();830  if (raw && typeof raw === "object" && !Array.isArray(raw)) {831    for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {832      if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {833        result.set(id, timestamp);834      }835    }836  }837  // Older payloads had no ledger. Rebuild the durable portion from any838  // soft-deleted entries before they are eligible for a later purge.839  for (const entry of allEntries) {840    if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {841      continue;842    }843    if ((result.get(entry.id) ?? 0) < entry.deletedAt) {844      result.set(entry.id, entry.deletedAt);845    }846  }847  return result;848}849850function reconcileTombstones(): void {851  const byId = new Map(entries.map((entry) => [entry.id, entry]));852  for (const [id] of tombstones) {853    if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);854  }855  for (const entry of entries) {856    if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {857      if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {858        tombstones.set(entry.id, entry.deletedAt);859      }860    }861  }862}863864function decodePayload(plain: string): DecryptedPayload {865  const parsed = JSON.parse(plain) as VaultPayload;866  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {867    throw new Error("invalid vault payload");868  }869  const normalized = parsed.entries.map(normalizeEntry);870  return {871    entries: normalized,872    tombstones: decodeTombstones(parsed.tombstones, normalized),873    duressConfigured: parsed.duressConfigured === true,874  };875}876877/**878 * Consolidate duplicate passkey records after registration.879 *880 * Multiple registrations for one RP can leave duplicate passkey records. This881 * pass collapses the duplicates:882 *883 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.884 *   2. Within each group, the most recently-active credential wins (prefer885 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).886 *   3. If a NON-passkey login entry exists for that RP (matched by url or887 *      additionalUrls hostname), attach the winning passkey to it and888 *      soft-delete every passkey-bearing entry in the group.889 *   4. Otherwise, soft-delete every loser in the group, keeping the winner890 *      as a passkey-only entry.891 *892 * Soft delete (deletedAt) rather than splice — the trash flow keeps the893 * record around for retention and the user can restore if the heuristic894 * picked wrong. The `merged` flag goes back to decodePayload so the895 * unlock path persists the consolidated shape immediately.896 */897function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {898  let merged = false;899  const now = Date.now();900901  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);902  const byRpId = new Map<string, VaultEntry[]>();903  for (const e of candidates) {904    const rpId = e.passkey!.rpId;905    const list = byRpId.get(rpId);906    if (list) list.push(e);907    else byRpId.set(rpId, [e]);908  }909910  for (const [rpId, group] of byRpId) {911    if (group.length === 0) continue;912    group.sort((a, b) => {913      const aT =914        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;915      const bT =916        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;917      return bT - aT;918    });919    const winner = group[0]!;920921    // Look for a NON-passkey login entry matching this RP. Use the URL922    // hostname rather than a substring search so a "login.foo.com" entry923    // doesn't accidentally absorb a passkey for "foo.com".924    const loginTarget = entries.find((e) => {925      if (e.deletedAt || e.passkey || e.type !== "login") return false;926      if (e.url && extractDomain(e.url) === rpId) return true;927      for (const extra of e.additionalUrls ?? []) {928        if (extractDomain(extra) === rpId) return true;929      }930      return false;931    });932933    if (loginTarget) {934      loginTarget.passkey = winner.passkey;935      // A passkey entry may also carry the site's TOTP seed. Preserve it when936      // the passkey is consolidated onto an existing login instead of937      // soft-deleting the only record that held the 2FA configuration.938      if (!loginTarget.totp && winner.totp) {939        loginTarget.totp = winner.totp;940        loginTarget.totpAlgorithm = winner.totpAlgorithm;941        loginTarget.totpDigits = winner.totpDigits;942        loginTarget.totpPeriod = winner.totpPeriod;943      }944      loginTarget.modifiedAt = now;945      for (const dup of group) {946        if (!dup.deletedAt) {947          dup.deletedAt = now;948          dup.modifiedAt = now;949        }950      }951      merged = true;952    } else if (group.length > 1) {953      for (let i = 1; i < group.length; i += 1) {954        const dup = group[i]!;955        if (!dup.deletedAt) {956          dup.deletedAt = now;957          dup.modifiedAt = now;958        }959      }960      merged = true;961    }962  }963964  return { merged };965}966967// Validates that a `StoredVault` record on disk has the field shape we expect968// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only969// authenticates the static VERSION_BYTE AAD, not the `version` field on the970// outer record — a corrupted `version: 42` slot still decrypts cleanly. This971// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a972// history-restore path instead of falsely reporting "wrong password".973function hasValidVaultSchema(v: unknown): v is StoredVault {974  if (typeof v !== "object" || v === null) return false;975  const r = v as Record<string, unknown>;976  return (977    r.version === 1 &&978    typeof r.salt === "string" &&979    typeof r.nonce === "string" &&980    typeof r.ciphertext === "string" &&981    typeof r.tag === "string"982  );983}984985export type TryDecryptResult =986  | { status: "ok"; payload: DecryptedPayload }987  | { status: "wrong_key" }988  | { status: "corrupted"; reason: string };989990// Exported solely for `unlockFromCodex` below, which needs to verify a991// supplied passphrase against a slotA blob *without* committing anything to992// storage. Keep this internal otherwise — callers crossing the messages.ts993// boundary should always go through `unlock` / `verifyMainPassword` so the994// constant-time invariants in Projects.md §3.4 are not bypassed.995export async function tryDecryptSlot(996  vault: StoredVault,997  key: Uint8Array,998  aadTag?: string,999): Promise<DecryptedPayload | null> {1000  const res = await tryDecryptSlotResult(vault, key, aadTag);1001  return res.status === "ok" ? res.payload : null;1002}10031004export async function tryDecryptSlotResult(1005  vault: StoredVault,1006  key: Uint8Array,1007  aadTag?: string,1008): Promise<TryDecryptResult> {1009  if (!hasValidVaultSchema(vault)) {1010    return { status: "corrupted", reason: "invalid_slot_schema" };1011  }1012  let plain: string;1013  try {1014    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1015  } catch {1016    return { status: "wrong_key" };1017  }1018  try {1019    const parsed = JSON.parse(plain) as unknown;1020    if (typeof parsed !== "object" || parsed === null) {1021      return { status: "corrupted", reason: "invalid_payload_shape" };1022    }1023    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1024      return { status: "corrupted", reason: "invalid_payload_shape" };1025    }1026    return { status: "ok", payload: decodePayload(plain) };1027  } catch {1028    return { status: "corrupted", reason: "invalid_payload_json" };1029  }1030}10311032// ---------------------------------------------------------------------------1033// Core operations1034// ---------------------------------------------------------------------------10351036function buildDomainIndexSync(all: VaultEntry[]): void {1037  domainIndex = new Map();10381039  for (const entry of all) {1040    if (entry.deletedAt) continue;1041    if (entry.type === "note" || entry.type === "card") continue;10421043    // An entry can match multiple sites via `additionalUrls`. Index each1044    // hostname so `queryDomain` returns the entry regardless of which alias1045    // the user is currently visiting. Dedupe per-entry so a single entry1046    // never appears twice in one bucket.1047    const domains = new Set<string>();1048    const primary = extractDomain(entry.url ?? "");1049    if (primary) domains.add(primary);1050    for (const extra of entry.additionalUrls ?? []) {1051      const d = extractDomain(extra);1052      if (d) domains.add(d);1053    }1054    if (domains.size === 0) continue;10551056    for (const domain of domains) {1057      const bucket = domainIndex.get(domain) ?? [];1058      bucket.push(entry);1059      domainIndex.set(domain, bucket);1060    }1061  }1062}10631064export async function createVault(password: string): Promise<void> {1065  const salt = generateSalt();1066  derivedKey = await deriveKey(password, salt);1067  entries = [];1068  tombstones = new Map();1069  vaultMode = "real";1070  duressConfigured = false;1071  buildDomainIndexSync(entries);10721073  // Generate and wrap the root key for recovery word escrow.1074  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1075    await generateAndWrapRootKey();1076  rootKey = unhex(rawRootKey);10771078  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1079  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");10801081  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate1082  // it without recovery words (H2/COR-006).1083  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);10841085  const slotA: StoredVault = {1086    version: 1,1087    salt: hex(salt),1088    nonce,1089    ciphertext,1090    tag,1091    recovery_salt: recoverySalt,1092    recovery_wrapped_root_key: wrappedRootKey,1093    recovery_nonce: wrappedNonce,1094    recovery_tag: wrappedTag,1095    ...pwRoot,1096  };1097  await writeSlot(SLOT_A, slotA);10981099  // Matching-size noise blob for slot B, so a fresh install shows two1100  // similarly-shaped records regardless of whether duress is ever set up.1101  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1102  await stampLocalVaultMutation();11031104  touchAutoLock();1105}11061107/**1108 * Creates a local vault from an existing vault root key received through the1109 * SAS-gated adoption protocol. The password is local to this device; it wraps1110 * the adopted root key at rest and does not need to match the source device.1111 */1112export async function createVaultFromRootKey(1113  password: string,1114  rootKeyHex: string,1115): Promise<{ vaultId: string }> {1116  if (typeof password !== "string" || password.length === 0) {1117    throw new Error("ADOPT_INVALID_PASSWORD");1118  }1119  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1120    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1121  }11221123  const rootKeyBytes = unhex(rootKeyHex);1124  if (rootKeyBytes.length !== 32) {1125    rootKeyBytes.fill(0);1126    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1127  }11281129  let nextDerivedKey: Uint8Array | null = null;1130  let sessionRootKey: Uint8Array | null = null;1131  try {1132    if ((await loadSlot(SLOT_A)) !== null) {1133      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1134    }11351136    const nextEntries: VaultEntry[] = [];1137    const nextDuressConfigured = false;1138    const vaultId = await computeVaultId(rootKeyBytes);1139    const salt = generateSalt();1140    nextDerivedKey = await deriveKey(password, salt);1141    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);11421143    sessionRootKey = new Uint8Array(rootKeyBytes);1144    const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1145    const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1146    const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1147    const slotA: StoredVault = {1148      version: 1,1149      salt: hex(salt),1150      nonce,1151      ciphertext,1152      tag,1153      recovery_salt: recovery.recoverySalt,1154      recovery_wrapped_root_key: recovery.wrappedRootKey,1155      recovery_nonce: recovery.wrappedNonce,1156      recovery_tag: recovery.wrappedTag,1157      ...pwRoot,1158    };1159    const slotB = generateNoiseBlob(ciphertext.length / 2);1160    await commitCreatedVaultSlots(slotA, slotB);11611162    const previousDerivedKey = derivedKey;1163    const previousRootKey = rootKey;1164    derivedKey = nextDerivedKey;1165    rootKey = sessionRootKey;1166    nextDerivedKey = null;1167    sessionRootKey = null;1168    entries = nextEntries;1169    tombstones = new Map();1170    vaultMode = "real";1171    duressConfigured = nextDuressConfigured;1172    buildDomainIndexSync(entries);1173    if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1174    if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1175    touchAutoLock();11761177    return { vaultId };1178  } finally {1179    rootKeyBytes.fill(0);1180    if (nextDerivedKey) zeroKey(nextDerivedKey);1181    if (sessionRootKey) sessionRootKey.fill(0);1182  }1183}11841185export async function hasVault(): Promise<boolean> {1186  return (await loadSlot(SLOT_A)) !== null;1187}11881189/**1190 * Returns whether the real vault has recorded that a duress passphrase is1191 * configured. Reliable only after a real-mode unlock — before unlock or in1192 * decoy mode, returns false because we cannot read slot A's plaintext.1193 */1194export async function isDuressEnabled(): Promise<boolean> {1195  return vaultMode === "real" && duressConfigured;1196}11971198export async function setupDuress(duressPassword: string): Promise<void> {1199  if (!derivedKey || vaultMode !== "real") {1200    throw new Error("vault must be unlocked in real mode");1201  }1202  // Encrypt the decoy payload (empty entries) under the duress key. Pad to1203  // at least the current slot A ciphertext size so the two slots look the1204  // same at rest at setup time.1205  const slotA = await loadSlot(SLOT_A);1206  if (!slotA) throw new Error("missing slot A");12071208  const decoySalt = generateSalt();1209  const decoyKey = await deriveKey(duressPassword, decoySalt);1210  try {1211    // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1212    // same ceiling by the persistSlotA() call below (duressConfigured is now1213    // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1214    // lengths cannot reveal that duress is configured (audit MEDIUM).1215    const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1216    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1217    await writeSlot(SLOT_B, {1218      version: 1,1219      salt: hex(decoySalt),1220      nonce,1221      ciphertext,1222      tag,1223    });1224  } finally {1225    zeroKey(decoyKey);1226  }12271228  // Re-encrypt slot A with the duressConfigured flag set to true. No external1229  // flag is written — the bit lives inside the encrypted envelope.1230  duressConfigured = true;1231  await persistSlotA();1232}12331234export async function disableDuress(): Promise<void> {1235  if (!derivedKey || vaultMode !== "real") {1236    throw new Error("vault must be unlocked in real mode");1237  }1238  // Overwrite slot B with fresh noise that matches slot A's size. The slot1239  // remains occupied so its mere existence does not signal that duress was1240  // ever configured.1241  const slotA = await loadSlot(SLOT_A);1242  if (slotA) {1243    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1244  }12451246  duressConfigured = false;1247  await persistSlotA();1248}12491250// L14/COR-015: this function intentionally checks slot A (real vault) only.1251// A duress-mode user attempting changePassword is rejected earlier by the1252// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1253// distinction here is by design — duress users cannot rotate the real1254// password by guessing it, and the asymmetry in error timing is acceptable1255// because duress mode is opt-in and the user knows their own decoy key.1256export async function verifyMainPassword(password: string): Promise<boolean> {1257  const slotA = await loadSlot(SLOT_A);1258  if (!slotA) return false;1259  const key = await deriveKey(password, unhex(slotA.salt));1260  try {1261    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1262  } finally {1263    zeroKey(key);1264  }1265}12661267export async function unlock(password: string): Promise<boolean> {1268  return attemptUnlockWithDerive(password, deriveKey);1269}12701271async function attemptUnlockWithDerive(1272  password: string,1273  derive: typeof deriveKey,1274): Promise<boolean> {1275  // Load both current-format slots in parallel. Both exist on a supported1276  // install; the1277  // parallel load and the parallel derivation below keep wall-clock time1278  // constant regardless of which slot's passphrase (real or decoy) the1279  // caller supplied, so a timing side channel cannot distinguish the1280  // duress path from the real-unlock path (DUR-002).1281  const [slotA, slotB] = await Promise.all([1282    loadSlot(SLOT_A),1283    loadSlot(SLOT_B),1284  ]);12851286  // Derive both keys in parallel, unconditionally.1287  const [keyA, keyB] = await Promise.all([1288    slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1289    slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1290  ]);12911292  // Attempt both decrypts in parallel. The Result variant lets us distinguish1293  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1294  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1295  const [resultA, resultB] = await Promise.all([1296    keyA && slotA1297      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1298      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1299    keyB && slotB1300      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1301      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1302  ]);1303  const payloadA = resultA.status === "ok" ? resultA.payload : null;1304  const payloadB = resultB.status === "ok" ? resultB.payload : null;13051306  if (payloadA && keyA) {1307    derivedKey = keyA;1308    void saveSessionKey(keyA, "real");1309    vaultMode = "real";1310    entries = payloadA.entries;1311    tombstones = payloadA.tombstones;1312    duressConfigured = payloadA.duressConfigured;1313    buildDomainIndexSync(entries);1314    await loadSettings();1315    touchAutoLock();1316    if (keyB) zeroKey(keyB);13171318    // Current vaults always carry a password-wrapped root key. Older vaults1319    // without this escrow are outside the supported storage format.1320    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1321    if (!rootKey) {1322      lock();1323      return false;1324    }13251326    await lockedSyncUnlockHook?.().catch((error) => {1327      console.warn("[Peach sync] locked inbox drain failed:", error);1328    });1329    return true;1330  }13311332  if (payloadB && keyB) {1333    derivedKey = keyB;1334    void saveSessionKey(keyB, "decoy");1335    vaultMode = "decoy";1336    entries = payloadB.entries;1337    tombstones = payloadB.tombstones;1338    duressConfigured = false;1339    buildDomainIndexSync(entries);1340    await loadSettings();1341    touchAutoLock();1342    if (keyA) zeroKey(keyA);1343    return true;1344  }13451346  if (keyA) zeroKey(keyA);1347  if (keyB) zeroKey(keyB);1348  // Surface corruption (good AES tag, broken schema) distinctly from1349  // wrong-password so the popup can offer history-restore (M7/COR-007).1350  if (resultA.status === "corrupted") {1351    lastUnlockFailureReason = resultA.reason;1352  } else if (resultB.status === "corrupted") {1353    lastUnlockFailureReason = resultB.reason;1354  }1355  return false;1356}13571358// ---------------------------------------------------------------------------1359// MV3 service worker session key — survives SW restarts within a browser session1360// ---------------------------------------------------------------------------13611362// chrome.storage.session is cleared on browser close; survives SW restart.1363// Firefox does not implement it (persistent background script, no restart risk).1364const SESSION_KEY_STORAGE = "peach_session_dk";13651366async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1367  if (!chrome.storage.session) return;1368  await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1369}13701371async function clearSessionKey(): Promise<void> {1372  if (!chrome.storage.session) return;1373  await chrome.storage.session.remove(SESSION_KEY_STORAGE);1374}13751376/**1377 * On SW restart, re-derive all in-memory vault state from the session key1378 * stored in chrome.storage.session (set at unlock time, cleared by lock();1379 * also cleared on browser close or when restoration fails).1380 * Returns true if the vault was restored, false if no session key was found.1381 * No-ops if the vault is already unlocked.1382 */1383export async function restoreFromSession(): Promise<boolean> {1384  if (derivedKey !== null) return true;1385  if (!chrome.storage.session) return false;1386  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1387  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1388  if (!entry?.keyHex) return false;13891390  const key = unhex(entry.keyHex);1391  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1392  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1393  const slot = await loadSlot(slotKey);1394  if (!slot) { await clearSessionKey(); return false; }13951396  const result = await tryDecryptSlotResult(slot, key, aadTag);1397  if (result.status !== "ok") { await clearSessionKey(); return false; }13981399  derivedKey = key;1400  vaultMode = entry.mode;1401  entries = result.payload.entries;1402  tombstones = result.payload.tombstones;1403  duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1404  buildDomainIndexSync(entries);1405  await loadSettings();1406  touchAutoLock();14071408  // Rehydrate the root key used by sync identity derivation from the password1409  // escrow stored on the current vault format.1410  if (entry.mode === "real") {1411    rootKey = await deriveRootKeyFromDerivedKey(key, slot);1412    if (!rootKey) {1413      lock();1414      return false;1415    }1416  }14171418  await lockedSyncUnlockHook?.().catch((error) => {1419    console.warn("[Peach sync] locked inbox drain failed:", error);1420  });14211422  return true;1423}14241425export function lock(): void {1426  // A user-initiated lock must be durable across MV3 service-worker restarts.1427  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1428  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1429  // If the session key survived lock(), that restore would silently re-unlock1430  // the vault ~a minute after the user locked it — a lock that only held1431  // while the SW happened to stay alive. The background-sync convenience the1432  // session key once bought (sync runner re-waking to a restored vault) is1433  // not worth an unlocked vault the user never asked to unlock; when locked,1434  // sync-runner correctly reports VAULT_LOCKED instead.1435  void clearSessionKey();1436  if (derivedKey) {1437    zeroKey(derivedKey);1438    derivedKey = null;1439  }1440  if (rootKey) {1441    rootKey.fill(0);1442    rootKey = null;1443  }14441445  entries = [];1446  tombstones = new Map();1447  domainIndex = new Map();1448  vaultMode = "real";1449  duressConfigured = false;1450  clearAutoLock();1451}14521453export function isUnlocked(): boolean {1454  return derivedKey !== null;1455}14561457// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1458// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1459// always derives from scratch via Argon2id.1460export async function unlockWithKey(1461  masterKeyHex: string,1462): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1463  try {1464    const masterKeyBytes = unhex(masterKeyHex);1465    const stored = await loadSlot(SLOT_A);1466    if (!stored) return { ok: false, error: "invalid_key" };1467    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1468    if (!payload) return { ok: false, error: "invalid_key" };14691470    derivedKey = masterKeyBytes;1471    vaultMode = "real";1472    entries = payload.entries;1473    tombstones = payload.tombstones;1474    duressConfigured = payload.duressConfigured;1475    buildDomainIndexSync(entries);1476    await loadSettings();1477    touchAutoLock();14781479    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1480    if (!rootKey) {1481      lock();1482      return { ok: false, error: "invalid_key" };1483    }1484    return { ok: true, entries };1485  } catch {1486    return { ok: false, error: "invalid_key" };1487  }1488}14891490// --- Adoption duress-slot helpers (passthrough, no decrypt) ---14911492/**1493 * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1494 * duress is configured and slot B exists, so the inline snapshot can ship it1495 * to the receiver. Returns null otherwise. Never decrypts.1496 */1497export async function exportDuressSlotForAdoption(): Promise<string | null> {1498  if (!(await isDuressEnabled())) return null;1499  const slotB = await loadSlot(SLOT_B);1500  if (!slotB) return null;1501  return JSON.stringify(slotB);1502}15031504/**1505 * Adoption RECEIVER helper: installs a duress slot received from a source1506 * device. Validates the StoredVault shape (same guard as unlockFromCodex)1507 * and writes it to slot B. Throws on invalid shape so the caller can treat1508 * it as a non-fatal snapshot failure. Never decrypts.1509 */1510export async function installAdoptedDuressSlot(json: string): Promise<void> {1511  let parsed: StoredVault;1512  try {1513    const raw = JSON.parse(json) as unknown;1514    if (!hasValidVaultSchema(raw)) {1515      throw new Error("invalid_duress_slot_shape");1516    }1517    parsed = raw;1518  } catch (err) {1519    if (err instanceof SyntaxError) {1520      throw new Error("invalid_duress_slot_shape");1521    }1522    throw err;1523  }1524  await writeSlot(SLOT_B, parsed);1525}15261527/**1528 * Restore the vault from a parsed Peach Codex payload.1529 *1530 * The supplied passphrase is verified against the Codex's slotA *before* any1531 * storage mutation — wrong passphrases return without touching disk. On1532 * success, the current vault state is snapshotted (reason: "restore") so the1533 * user can revert via Settings -> Vault History, then slotA/slotB are written1534 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A1535 * regular `unlock()` follows so the rest of the SW sees the standard1536 * post-unlock state.1537 *1538 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex1539 * printed by a Pro user must always be restorable, even on a free device.1540 *1541 * Timing is not held constant here (in contrast to `unlock`): the Codex flow1542 * is an explicit user action with a known passphrase, not a passive lock1543 * screen, so the duress-vs-real timing side channel does not apply.1544 */1545export async function unlockFromCodex(params: {1546  slotA: string; // JSON string of StoredVault for slot A1547  slotB: string | null; // JSON string of StoredVault for slot B, or null1548  timestamp: number;1549  masterPassphrase: string;1550}): Promise<1551  | { ok: true; entryCount: number }1552  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1553> {1554  let parsedSlotA: StoredVault;1555  let parsedSlotB: StoredVault | null = null;1556  try {1557    const rawA = JSON.parse(params.slotA) as unknown;1558    if (!hasValidVaultSchema(rawA)) {1559      return { ok: false, reason: "invalid_slot_format" };1560    }1561    parsedSlotA = rawA;1562    if (params.slotB !== null) {1563      const rawB = JSON.parse(params.slotB) as unknown;1564      if (!hasValidVaultSchema(rawB)) {1565        return { ok: false, reason: "invalid_slot_format" };1566      }1567      parsedSlotB = rawB;1568    }1569  } catch {1570    return { ok: false, reason: "invalid_slot_format" };1571  }15721573  // Verify the passphrase against the supplied slotA WITHOUT writing anything.1574  // If this fails the on-disk vault is left completely untouched.1575  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1576  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1577  if (!trialPayload) {1578    zeroKey(trialKey);1579    return { ok: false, reason: "wrong_passphrase" };1580  }1581  // Drop the trial key; `unlock()` below derives its own copy. Holding two1582  // copies of the master key in memory at once would be a needless surface.1583  zeroKey(trialKey);15841585  // Snapshot the about-to-be-overwritten on-disk state so the user can revert1586  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.1587  await takeVaultSnapshot("restore");15881589  await writeSlot(SLOT_A, parsedSlotA);1590  if (parsedSlotB) {1591    await writeSlot(SLOT_B, parsedSlotB);1592  } else {1593    // Slot B must always exist with ciphertext length matching slot A1594    // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't1595    // configured); regenerate noise to satisfy the invariant.1596    await writeSlot(1597      SLOT_B,1598      generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1599    );1600  }1601  await chrome.storage.local.set({1602    peach_last_modified_at: String(params.timestamp),1603  });16041605  // Standard unlock path so domain index, settings, auto-lock, and1606  // recovery-key state all populate exactly as a normal unlock would.1607  const ok = await unlock(params.masterPassphrase);1608  if (!ok) {1609    // Should be unreachable — we just verified the passphrase against the1610    // same slotA we wrote. Treat as wrong_passphrase to keep the contract1611    // simple if some race or storage anomaly intrudes.1612    return { ok: false, reason: "wrong_passphrase" };1613  }1614  return { ok: true, entryCount: getEntries().length };1615}16161617// Returns the current in-memory derived key as a hex string. Only the PIN1618// unlock path needs this value, and it reads module state directly; this1619// accessor exists so unlockWithKey's root-key rehydration is testable.1620export /* @testable */ function getDerivedKeyHex(): string {1621  if (!derivedKey) throw new Error("vault_not_unlocked");1622  return hex(derivedKey);1623}16241625/**1626 * Returns the vault's stable root key as hex, or null when locked / not yet1627 * populated. The root key is random per-vault, generated at creation, escrowed1628 * under the recovery words, and re-wrapped (not regenerated) on every password1629 * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.1630 *1631 * This is the correct input for sync identity derivation (vault_id, the Noise1632 * NK static key, the Ed25519 manifest signing key, and per-entry envelope1633 * keys): paired devices keep the same identity and can still decrypt each1634 * other's entries after either side rotates its master password (SEV-2-B).1635 *1636 * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED1637 * instead of crashing; rootKey is populated on every supported real-mode1638 * unlock via deriveRootKeyFromDerivedKey.1639 */1640export function getRootKeyHex(): string | null {1641  return rootKey ? hex(rootKey) : null;1642}16431644export function getEntries(): VaultEntry[] {1645  if (!derivedKey) throw new Error("vault_locked");1646  return entries.filter((entry) => !entry.deletedAt);1647}16481649const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;16501651export function getIdentities(): SavedIdentity[] {1652  if (!derivedKey) throw new Error("vault_locked");1653  const active = entries.filter((e) => !e.deletedAt && e.type === "login");16541655  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1656  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();16571658  for (const entry of active) {1659    const domain = extractDomain(entry.url ?? "") ?? "";1660    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;16611662    function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1663      const existing = map.get(v);1664      if (!existing || modifiedAt > existing.lastUsed) {1665        map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1666      } else {1667        map.set(v, { ...existing, count: existing.count + 1 });1668      }1669    }16701671    const uVal = entry.username?.trim();1672    if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);16731674    // Also count the dedicated email field — entries created via the inline1675    // popup new-entry form store email here rather than in username.1676    const eVal = entry.email?.trim();1677    if (eVal) tally(emailMap, eVal);1678  }16791680  const toIdentity = (type: "email" | "username") =>1681    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1682      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1683    });16841685  return [1686    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1687    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1688  ];1689}16901691export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1692  if (!derivedKey) throw new Error("vault_locked");16931694  const now = Date.now();1695  const added: VaultEntry[] = [];1696  for (const draft of drafts) {1697    try {1698      const sanitized = sanitizeEntryDraft(draft);1699      const nextEntry: VaultEntry = {1700        ...sanitized,1701        id: crypto.randomUUID(),1702        createdAt: now,1703        modifiedAt: monotonicEntryTimestamp(),1704      };1705      entries.push(nextEntry);1706      added.push(nextEntry);1707    } catch {1708      // Skip invalid drafts silently — caller can see diff between input and output length1709    }1710  }17111712  if (added.length > 0) {1713    await persistEntries();1714  }17151716  return added;1717}17181719/**1720 * Restore a set of entries in full fidelity — preserves id, createdAt,1721 * modifiedAt, and every other field unlike `batchAddEntries` which1722 * assigns fresh identity. Used by restore paths where the backup is the1723 * authoritative source of truth. `mode` controls how entries already in1724 * the vault are treated.1725 */1726export async function restoreEntries(1727  incoming: VaultEntry[],1728  mode: "replace" | "merge"1729): Promise<{ added: number; updated: number; skipped: number }> {1730  if (!derivedKey) throw new Error("vault_locked");17311732  // A replace with zero incoming entries is almost certainly a caller bug1733  // (e.g. an import that silently dropped everything). Refuse rather than1734  // wipe the vault.1735  if (mode === "replace" && incoming.length === 0) {1736    throw new Error("refuse to replace with empty entry set");1737  }17381739  let added = 0;1740  let updated = 0;1741  let skipped = 0;17421743  if (mode === "replace") {1744    entries = [];1745    tombstones = new Map();1746    for (const entry of incoming) {1747      const normalized = sanitizeRestoredEntry(entry);1748      if (!normalized) {1749        skipped += 1;1750        continue;1751      }1752      entries.push(normalized);1753      if (normalized.deletedAt) {1754        tombstones.set(normalized.id, normalized.deletedAt);1755      }1756      added += 1;1757    }1758  } else {1759    const byId = new Map<string, VaultEntry>();1760    for (const e of entries) byId.set(e.id, e);17611762    for (const entry of incoming) {1763      const normalized = sanitizeRestoredEntry(entry);1764      if (!normalized) {1765        skipped += 1;1766        continue;1767      }1768      const existing = byId.get(normalized.id);1769      if (!existing) {1770        entries.push(normalized);1771        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1772        else tombstones.delete(normalized.id);1773        added += 1;1774      } else if (normalized.modifiedAt > existing.modifiedAt) {1775        const idx = entries.findIndex((e) => e.id === existing.id);1776        if (idx !== -1) {1777          entries[idx] = normalized;1778          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1779          else tombstones.delete(normalized.id);1780          updated += 1;1781        }1782      } else {1783        skipped += 1;1784      }1785    }1786  }17871788  await persistEntries("import");1789  return { added, updated, skipped };1790}17911792/**1793 * Validate + normalise a VaultEntry coming from a backup, import, or sync1794 * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies1795 * only known VaultEntry fields (unknown/attacker keys are dropped), and1796 * nested `passkey`/`canary` shapes are rebuilt field-by-field, never1797 * spread. Returns null for values the live vault shouldn't accept.1798 */1799function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1800  if (!entry || typeof entry !== "object") return null;1801  const title = typeof entry.title === "string" ? entry.title.trim() : "";1802  if (!title) return null;1803  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1804  const now = Date.now();1805  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1806  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;18071808  // normalizeEntry whitelists every known VaultEntry field before the entry1809  // reaches the live vault.1810  const normalized = normalizeEntry({1811    ...entry,1812    id,1813    title,1814    type: entry.type ?? "login",1815    createdAt,1816    modifiedAt,1817  });18181819  if (normalized.passkey) {1820    normalized.passkey = sanitizePasskey(normalized.passkey);1821  }1822  if (normalized.canary) {1823    normalized.canary = sanitizeCanary(normalized.canary);1824  }1825  // PCH-01-016: bound tags on the restore path too, so both import funnels agree.1826  normalized.tags = sanitizeTags(normalized.tags);18271828  // Login entries from backup must carry a password (or be passkey-only,1829  // in which case the passkey sub-object replaces the password requirement).1830  if (1831    normalized.type === "login" &&1832    !normalized.passkey &&1833    typeof normalized.password !== "string"1834  ) {1835    return null;1836  }1837  if (entry.passkey && !normalized.passkey) {1838    // Entry claimed a passkey but the shape didn't validate — refuse the1839    // whole entry rather than silently importing a credential-less husk.1840    return null;1841  }18421843  return normalized;1844}18451846/**1847 * Rebuild a PasskeyData object from untrusted input, field by field.1848 * Returns undefined when required fields are missing/mistyped — a rogue1849 * passkey credential must not activate via import (MV-6 attack scenario).1850 */1851function sanitizePasskey(pk: unknown): PasskeyData | undefined {1852  if (!pk || typeof pk !== "object") return undefined;1853  const r = pk as Record<string, unknown>;1854  if (1855    typeof r.rpId !== "string" || !r.rpId ||1856    typeof r.credentialId !== "string" || !r.credentialId ||1857    typeof r.publicKeyCose !== "string" ||1858    typeof r.userHandle !== "string" ||1859    typeof r.userName !== "string"1860  ) {1861    return undefined;1862  }1863  return {1864    rpId: r.rpId,1865    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1866    credentialId: r.credentialId,1867    publicKeyCose: r.publicKeyCose,1868    privateKeyJwk:1869      r.privateKeyJwk && typeof r.privateKeyJwk === "object"1870        ? (r.privateKeyJwk as JsonWebKey)1871        : undefined,1872    userHandle: r.userHandle,1873    userName: r.userName,1874    userDisplayName:1875      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1876    algorithm: r.algorithm === -8 ? -8 : -7,1877    signCount: typeof r.signCount === "number" ? r.signCount : 0,1878    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1879    backupEligible: r.backupEligible === true,1880    backupState: r.backupState === true,1881    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1882    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1883    transports: Array.isArray(r.transports)1884      ? r.transports.filter((t): t is string => typeof t === "string")1885      : undefined,1886    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1887  };1888}18891890/**1891 * Rebuild a Canary object from untrusted input. The webhook must be a1892 * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing1893 * exfiltration endpoint that the canary subsystem will POST entry titles1894 * to (MV-6 attack scenario). Invalid → the canary is dropped, not the1895 * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire1896 * time (isAllowedCanaryWebhookUrl), so this import-time check is the1897 * outer of two gates.1898 */1899function sanitizeCanary(c: unknown): Canary | undefined {1900  if (!c || typeof c !== "object") return undefined;1901  const r = c as Record<string, unknown>;1902  if (typeof r.webhookUrl !== "string") return undefined;1903  let url: URL;1904  try {1905    url = new URL(r.webhookUrl);1906  } catch {1907    return undefined;1908  }1909  if (url.protocol !== "https:") return undefined;1910  return {1911    webhookUrl: url.toString(),1912    label: typeof r.label === "string" ? r.label : undefined,1913    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1914    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1915  };1916}19171918/**1919 * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted.1920 * Keep only non-empty strings, cap each at 256 chars, and cap the count at 2551921 * (the same per-entry ceiling the export codec enforces, peachPayload.ts).1922 * Non-array or all-invalid input collapses to undefined.1923 */1924function sanitizeTags(tags: unknown): string[] | undefined {1925  if (!Array.isArray(tags)) return undefined;1926  const out: string[] = [];1927  for (const t of tags) {1928    if (typeof t !== "string") continue;1929    const trimmed = t.trim();1930    if (!trimmed) continue;1931    out.push(trimmed.slice(0, 256));1932    if (out.length >= 255) break;1933  }1934  return out.length > 0 ? out : undefined;1935}19361937// Soft-deletes every non-deleted entry in one call. The literal string1938// argument is a tripwire against accidental calls — any caller that would1939// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this1940// to a boolean.1941export async function clearVaultEntries(1942  confirmation: "REPLACE_ALL"1943): Promise<void> {1944  if (confirmation !== "REPLACE_ALL") {1945    throw new Error("clearVaultEntries requires explicit confirmation");1946  }1947  if (!derivedKey) throw new Error("vault_locked");1948  for (const entry of entries) {1949    if (!entry.deletedAt) {1950      const stamp = monotonicEntryTimestamp(Math.max(1951        entry.modifiedAt,1952        tombstones.get(entry.id) ?? 0,1953      ));1954      entry.deletedAt = stamp;1955      entry.modifiedAt = stamp;1956      tombstones.set(entry.id, stamp);1957    }1958  }1959  await persistEntries();1960}19611962export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1963  if (!derivedKey) throw new Error("vault_locked");19641965  const sanitized = sanitizeEntryDraft(entry);1966  const now = Date.now();1967  const stamp = monotonicEntryTimestamp();1968  const nextEntry: VaultEntry = {1969    ...sanitized,1970    id: crypto.randomUUID(),1971    createdAt: now,1972    modifiedAt: stamp,1973    // Stamp passwordChangedAt at creation so age tracking is accurate from1974    // day one. Non-login entries carry the same stamp harmlessly.1975    passwordChangedAt: sanitized.password ? stamp : undefined,1976  };19771978  // Clone-and-replace so a failed persist rolls back the in-memory state1979  // (COR-008). Without this, a quota-full or storage error leaves the1980  // module-level `entries` ahead of what's actually on disk.1981  const rollback = entries;1982  entries = [...entries, nextEntry];1983  try {1984    await persistEntries();1985  } catch (err) {1986    entries = rollback;1987    throw err;1988  }19891990  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)1991  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1992    checkBreach(nextEntry.password).then(async (count) => {1993      if (count > 0) {1994        const idx = entries.findIndex((e) => e.id === nextEntry.id);1995        if (idx !== -1) {1996          entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1997          await persistEntries();1998        }1999      }2000    });2001  }20022003  fireSyncTrigger("vault_change");20042005  return nextEntry;2006}20072008/**2009 * Run the passkey-merge pass on the live in-memory entries and persist if2010 * anything changed. Called immediately after any passkey registration so that2011 * a same-domain TOTP-only entry already in the vault gets consolidated right2012 * away instead of waiting until the next lock/unlock cycle.2013 */2014export async function consolidatePasskeyEntries(): Promise<void> {2015  if (!derivedKey) return;2016  const { merged } = mergePasskeyOnlyIntoLogins(entries);2017  if (merged) {2018    await persistEntries();2019  }2020}20212022export async function updateEntry(2023  id: string,2024  patch: Partial<VaultEntry>2025): Promise<void> {2026  if (!derivedKey) throw new Error("vault_locked");20272028  const idx = entries.findIndex((entry) => entry.id === id);2029  if (idx === -1 || entries[idx].deletedAt) {2030    throw new Error("entry_not_found");2031  }20322033  const current = entries[idx];2034  const sanitizedPatch = sanitizeEntryPatch(patch);2035  // Refresh passwordChangedAt only when the password value actually changes —2036  // a metadata-only update (title, tags, etc.) must not reset the age clock2037  // that drives the Pro health rotation recommendation.2038  const passwordChanged =2039    "password" in patch &&2040    typeof sanitizedPatch.password === "string" &&2041    sanitizedPatch.password !== current.password;2042  const stamp = monotonicEntryTimestamp(current.modifiedAt);2043  // When the password actually changes, push the outgoing one onto the history2044  // (newest first) so the user can reveal/copy/restore it later. We record the2045  // age it carried (passwordChangedAt, falling back to createdAt) rather than2046  // `now`, so the list reflects when each password was last in effect. Only2047  // capture a non-empty prior value — clearing a password leaves no secret to2048  // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)2049  // to avoid noise.2050  const priorPassword = current.password;2051  const shouldCapture =2052    passwordChanged &&2053    typeof priorPassword === "string" &&2054    priorPassword.length > 0 &&2055    current.passwordHistory?.[0]?.password !== priorPassword;2056  const nextHistory = shouldCapture2057    ? [2058        { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2059        ...(current.passwordHistory ?? []),2060      ].slice(0, PASSWORD_HISTORY_MAX)2061    : current.passwordHistory;2062  entries[idx] = {2063    ...current,2064    ...sanitizedPatch,2065    modifiedAt: stamp,2066    ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2067  };2068206920702071  // Card entries must retain their card number2072  if (entries[idx].type === "card" && !entries[idx].cardNumber) {2073    entries[idx] = current;2074    throw new Error("card number is required for card entries");2075  }20762077  // Roll the in-memory entry back to its pre-edit state if the disk write2078  // fails (e.g. storage quota). Without this the module-level `entries` array2079  // would hold an edit that never landed on disk, diverging from the vault2080  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.2081  try {2082    await persistEntries();2083  } catch (err) {2084    entries[idx] = current;2085    throw err;2086  }20872088  // Breach check when password changes: clear immediately so old breach doesn't linger,2089  // then async-check the new password and only re-set if it's also found.2090  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2091    entries[idx] = { ...entries[idx], breach: undefined };2092    await persistEntries();2093    const password = entries[idx].password;2094    if (password) {2095      checkBreach(password).then(async (count) => {2096        const i = entries.findIndex((e) => e.id === id);2097        if (i !== -1 && count > 0) {2098          entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2099          await persistEntries();2100        }2101      });2102    }2103  }21042105  fireSyncTrigger("vault_change");2106}21072108export async function deleteEntry(id: string): Promise<void> {2109  if (!derivedKey) throw new Error("vault_locked");21102111  const entry = entries.find((item) => item.id === id);2112  if (!entry || entry.deletedAt) {2113    throw new Error("entry_not_found");2114  }21152116  // Tombstone timestamp must be strictly greater than the entry's last2117  // modification so the deletion wins LWW against any in-flight edit, even2118  // under clock skew (SEV-2-E).2119  const previousModifiedAt = entry.modifiedAt;2120  const previousTombstone = tombstones.get(id);2121  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2122  entry.deletedAt = stamp;2123  entry.modifiedAt = stamp;2124  tombstones.set(id, stamp);2125  // Roll the tombstone back if the disk write fails, so a quota error doesn't2126  // leave the entry deleted in memory but alive on disk (SEV-3-A).2127  try {2128    await persistEntries();2129  } catch (err) {2130    delete entry.deletedAt;2131    entry.modifiedAt = previousModifiedAt;2132    if (previousTombstone === undefined) tombstones.delete(id);2133    else tombstones.set(id, previousTombstone);2134    throw err;2135  }2136  fireSyncTrigger("vault_change");2137}21382139export function getDeletedEntries(): VaultEntry[] {2140  if (!derivedKey) return [];2141  return entries.filter((e) => !!e.deletedAt);2142}21432144/** Return all durable deletion timestamps, including purged full entries. */2145export function getTombstoneManifest(): Record<string, number> {2146  if (!derivedKey) return {};2147  return Object.fromEntries(tombstones);2148}21492150/**2151 * Returns the vault's monotonic version — the `peach_last_modified_at` value2152 * stamped on every user-initiated write. Remote (sync) writes do NOT advance2153 * this version so a sync round never cascades into a second outbound sync.2154 */2155export async function getVaultVersion(): Promise<number> {2156  try {2157    const raw = await chrome.storage.local.get("peach_last_modified_at");2158    const v = (raw as Record<string, unknown>).peach_last_modified_at;2159    if (typeof v === "number") return v;2160    if (typeof v === "string" && v !== "") {2161      const n = Number.parseInt(v, 10);2162      return Number.isFinite(n) ? n : 0;2163    }2164    return 0;2165  } catch {2166    return 0;2167  }2168}21692170/**2171 * Apply a batch of remote entries + tombstones from a sync peer.2172 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when2173 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and2174 * persists with reason `sync_received` — does NOT fire the sync dispatcher, so2175 * applying a sync round cannot cascade into another outbound sync.2176 *2177 * `applied` counts entries that actually changed local state (new add,2178 * overwrite-by-newer, or new tombstone). `received` is the raw payload size2179 * the caller can surface in the sync result UI.2180 */2181export async function applyMergedRemoteEntries(2182  remoteEntries: VaultEntry[],2183  remoteTombstones: Record<string, number>,2184  _context: { deviceName?: string } = {},2185): Promise<{ received: number; applied: number; conflicts: number }> {2186  // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices2187  // concurrently edit different fields of the same entry, the one with the2188  // newer modifiedAt wins the whole entry and the other device's field edits2189  // are dropped silently. This is the deliberate trade-off for a simple,2190  // conflict-free merge — per-field CRDT merge is out of scope. Guidance for2191  // users: edit a given entry on one device at a time.2192  if (!derivedKey) throw new Error("vault_locked");21932194  const received = remoteEntries.length + Object.keys(remoteTombstones).length;2195  let applied = 0;2196  let conflicts = 0;21972198  const byId = new Map<string, VaultEntry>();2199  for (const e of entries) byId.set(e.id, e);22002201  const mergeNow = Date.now();2202  const remoteTombstoneTimes = new Map<string, number>();2203  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2204    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2205      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2206    }2207  }22082209  for (const remote of remoteEntries) {2210    const sanitized = sanitizeRestoredEntry(remote);2211    if (!sanitized) continue;2212    // HV-1a: a far-future remote modifiedAt must not permanently pin a2213    // malicious value against honest local edits. Clamp BEFORE any2214    // comparison against local timestamps.2215    sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2216    if (typeof sanitized.deletedAt === "number") {2217      sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2218    }2219    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2220    const localTombstoneTs = tombstones.get(sanitized.id);2221    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2222    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2223      continue;2224    }2225    const local = byId.get(sanitized.id);2226    if (!local) {2227      entries.push(sanitized);2228      byId.set(sanitized.id, sanitized);2229      tombstones.delete(sanitized.id);2230      applied += 1;2231    } else if (sanitized.modifiedAt > local.modifiedAt) {2232      const idx = entries.findIndex((e) => e.id === local.id);2233      if (idx !== -1) {2234        entries[idx] = sanitized;2235        byId.set(sanitized.id, sanitized);2236        tombstones.delete(sanitized.id);2237        applied += 1;2238        if (!local.deletedAt) conflicts += 1;2239      }2240    }2241  }22422243  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2244    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2245    // HV-1a/1c: clamp the tombstone timestamp before every comparison.2246    const deletedAt = remoteTombstoneTimes.get(id)!;2247    const knownTombstone = tombstones.get(id);2248    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2249    const local = byId.get(id);2250    if (!local) {2251      tombstones.set(id, deletedAt);2252      applied += 1;2253      continue;2254    }2255    if (local.deletedAt && local.deletedAt >= deletedAt) continue;2256    if (deletedAt < local.modifiedAt) continue;2257    const idx = entries.findIndex((e) => e.id === id);2258    if (idx !== -1) {2259      entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2260      tombstones.set(id, deletedAt);2261      applied += 1;2262    }2263  }22642265  if (applied > 0) {2266    await persistEntries("sync_received");2267    buildDomainIndexSync(entries);2268  }2269  return { received, applied, conflicts };2270}22712272export async function restoreEntry(id: string): Promise<void> {2273  if (!derivedKey) throw new Error("vault_locked");2274  const entry = entries.find((e) => e.id === id && e.deletedAt);2275  if (!entry) throw new Error("deleted entry not found");2276  const previousDeletedAt = entry.deletedAt;2277  const previousModifiedAt = entry.modifiedAt;2278  const previousTombstone = tombstones.get(id);2279  delete entry.deletedAt;2280  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2281    entry.modifiedAt,2282    previousTombstone ?? 0,2283  ));2284  tombstones.delete(id);2285  try {2286    await persistEntries();2287  } catch (err) {2288    entry.deletedAt = previousDeletedAt;2289    entry.modifiedAt = previousModifiedAt;2290    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2291    throw err;2292  }2293  buildDomainIndexSync(entries);2294}22952296export async function purgeEntry(id: string): Promise<void> {2297  if (!derivedKey) throw new Error("vault_locked");2298  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2299  if (idx === -1) throw new Error("deleted entry not found");2300  const entry = entries[idx]!;2301  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2302  entries.splice(idx, 1);2303  await persistEntries();2304}23052306/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */2307export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2308  if (!derivedKey || retentionMs === 0) return 0;2309  const cutoff = Date.now() - retentionMs;2310  const before = entries.length;2311  for (const entry of entries) {2312    if (entry.deletedAt && entry.deletedAt < cutoff) {2313      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2314    }2315  }2316  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2317  const purged = before - entries.length;2318  if (purged > 0) await persistEntries();2319  return purged;2320}23212322export function queryDomain(domain: string): VaultEntry[] {2323  return domainIndex.get(domain) ?? [];2324}23252326export function getEntryById(id: string): VaultEntry | undefined {2327  return entries.find((entry) => entry.id === id && !entry.deletedAt);2328}23292330async function persistSlotA(2331  reason: VaultSnapshot["reason"] = "local_save",2332): Promise<StoredVault> {2333  if (!derivedKey) throw new Error("vault_locked");23342335  const slotA = await loadSlot(SLOT_A);2336  if (!slotA) throw new Error("missing slot A");23372338  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures2339  // are swallowed by takeVaultSnapshot — history must never block a save.2340  await takeVaultSnapshot(reason);23412342  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2343  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");23442345  // Preserve existing recovery escrow fields.2346  const nextSlotA: StoredVault = {2347    ...slotA,2348    nonce,2349    ciphertext,2350    tag,2351    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot2352  };2353  await writeSlot(SLOT_A, nextSlotA);23542355  // Slot A (the real vault) is now committed. Wrap every later write so a2356  // mid-persist failure (e.g. a storage error after slot A landed) restores the2357  // prior slot A before rethrowing. Without this, a "failed" save that already2358  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/2359  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces2360  // as a duplicate on the next unlock (observed under storage-quota pressure).2361  try {2362    // When duress is not configured, keep slot B size-matched with slot A by2363    // regenerating its noise. When duress IS configured, leave slot B alone —2364    // we don't have the decoy key to re-encrypt it. This is a known trade-off.2365    if (!duressConfigured) {2366      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2367    }2368    // Only advance the vault version for locally-originated writes. Entries2369    // applied from a sync peer ("sync_received") must NOT bump the counter —2370    // otherwise applying a remote round makes us look newer than the peer and2371    // triggers a redundant outbound sync, defeating the no-op optimisation2372    // and risking a ping-pong between two devices (SEV-2-C).2373    if (reason !== "sync_received") {2374      await stampLocalVaultMutation();2375    }2376  } catch (err) {2377    // Best-effort restore — the prior slot A is smaller-or-equal, so this write2378    // fits even when the failure was a quota overflow.2379    await writeSlot(SLOT_A, slotA).catch(() => {});2380    throw err;2381  }23822383  return nextSlotA;2384}23852386async function persistSlotB(): Promise<void> {2387  if (!derivedKey) throw new Error("vault_locked");23882389  const slotB = await loadSlot(SLOT_B);2390  if (!slotB) throw new Error("missing slot B");23912392  // Decoy plaintext always reports duressConfigured=false — the decoy user2393  // sees a clean slate. persistSlotB only runs in decoy mode, which only2394  // exists once duress is configured, so pad to the fixed duress ceiling to2395  // keep slot B's length equal to slot A's (audit MEDIUM — deniability).2396  const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2397  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");23982399  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2400  // Symmetric with persistSlotA: if the version stamp fails after the decoy2401  // slot committed, restore the prior slot B so a failed decoy-mode save can't2402  // leave disk ahead of the in-memory rollback.2403  try {2404    await stampLocalVaultMutation();2405  } catch (err) {2406    await writeSlot(SLOT_B, slotB).catch(() => {});2407    throw err;2408  }2409}24102411async function persistEntries(2412  reason: VaultSnapshot["reason"] = "local_save",2413): Promise<void> {2414  if (!derivedKey) throw new Error("vault_locked");24152416  // Reconcile direct soft-delete callers while restoring the previous map if2417  // storage rejects the save.2418  const previousTombstones = tombstones;2419  tombstones = new Map(tombstones);2420  reconcileTombstones();2421  try {2422    if (vaultMode === "decoy") {2423      await persistSlotB();2424    } else {2425      await persistSlotA(reason);2426    }2427  } catch (err) {2428    tombstones = previousTombstones;2429    throw err;2430  }2431  buildDomainIndexSync(entries);2432  await refreshLockedSyncState();2433}24342435// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage2436// boundary. Schemeless values (e.g. "github.com") are inert and kept — they2437// pre-date this guard in existing data. Unparseable values are kept: the2438// reviewed render paths never execute them (favicons via _favicon/, domains2439// via tldts); this is defense-in-depth against future sinks.2440function allowHttpUrl(url: string | undefined): string {2441  const trimmed = url?.trim() ?? "";2442  if (!trimmed) return "";2443  try {2444    const parsed = new URL(trimmed);2445    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2446  } catch {2447    // No scheme to reject.2448  }2449  return trimmed;2450}24512452function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2453  if (!Array.isArray(values)) return undefined;2454  const seen = new Set<string>();2455  const cleaned: string[] = [];2456  for (const value of values) {2457    const trimmed = typeof value === "string" ? value.trim() : "";2458    if (!trimmed) continue;2459    if (!allowHttpUrl(trimmed)) continue;2460    const key = trimmed.toLowerCase();2461    if (seen.has(key)) continue;2462    seen.add(key);2463    cleaned.push(trimmed);2464  }2465  return cleaned.length > 0 ? cleaned : undefined;2466}24672468function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2469  const title = entry.title.trim();2470  const type: VaultEntry["type"] = entry.type ?? "login";24712472  if (!title) throw new Error("title is required");24732474  if (entry.passkey) {2475    const p = entry.passkey;2476    if (2477      !p.rpId ||2478      !p.credentialId ||2479      !p.publicKeyCose ||2480      !p.userHandle ||2481      !p.userName ||2482      (p.algorithm !== -7 && p.algorithm !== -8)2483    ) {2484      throw new Error("invalid passkey credential");2485    }2486  }2487  if (type === "card") {2488    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2489      throw new Error("cardholder name and card number are required");2490    }2491  }24922493  return {2494    type,2495    title,2496    username: type === "login" ? entry.username?.trim() ?? "" : undefined,2497    email: type === "login" ? entry.email?.trim() || undefined : undefined,2498    password: type === "login" ? entry.password : undefined,2499    url: type === "login" ? allowHttpUrl(entry.url) : undefined,2500    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2501    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2502    // PCH-01-019: preserve non-default TOTP params through batch-add/import so2503    // they are not silently stripped (they fall back to SHA-1/6/30 when absent).2504    totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2505    totpDigits: type === "login" ? entry.totpDigits : undefined,2506    totpPeriod: type === "login" ? entry.totpPeriod : undefined,2507    notes: entry.notes?.trim() || undefined,2508    breach: type === "login" ? entry.breach : undefined,2509    // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags2510    // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here.2511    canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2512    tags: sanitizeTags(entry.tags),2513    passkey: entry.passkey,2514    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2515    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2516    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2517    expYear: type === "card" ? entry.expYear?.trim() : undefined,2518    cvc: type === "card" ? entry.cvc?.trim() : undefined,2519    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2520    network: type === "card" ? entry.network : undefined,2521    last4: type === "card" ? entry.last4?.trim() : undefined,2522  };2523}25242525function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2526  const nextPatch: Partial<VaultEntry> = {};25272528  if ("type" in patch) {2529    nextPatch.type = patch.type;2530  }25312532  if ("title" in patch) {2533    const title = patch.title?.trim() ?? "";2534    if (!title) throw new Error("title is required");2535    nextPatch.title = title;2536  }25372538  if ("url" in patch) {2539    nextPatch.url = allowHttpUrl(patch.url);2540  }25412542  if ("additionalUrls" in patch) {2543    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2544    const seen = new Set<string>();2545    const cleaned: string[] = [];2546    for (const value of incoming) {2547      const trimmed = typeof value === "string" ? value.trim() : "";2548      if (!trimmed || !allowHttpUrl(trimmed)) continue;2549      const key = trimmed.toLowerCase();2550      if (seen.has(key)) continue;2551      seen.add(key);2552      cleaned.push(trimmed);2553    }2554    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2555  }25562557  if ("username" in patch) {2558    nextPatch.username = patch.username?.trim() ?? "";2559  }25602561  if ("email" in patch) {2562    nextPatch.email = patch.email?.trim() || undefined;2563  }25642565  if ("password" in patch) {2566    const password = patch.password ?? "";2567    // Allow clearing a password (e.g. on passkey-only entries)2568    nextPatch.password = password || undefined;2569  }25702571  if ("totp" in patch) {2572    nextPatch.totp = patch.totp?.trim() || undefined;2573  }25742575  if ("notes" in patch) {2576    nextPatch.notes = patch.notes?.trim() || undefined;2577  }25782579  if ("breach" in patch) {2580    nextPatch.breach = patch.breach;2581  }25822583  if ("canary" in patch) {2584    // PCH-02-001: route update patches through the same canary whitelist used2585    // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.2586    nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2587  }25882589  if ("passkey" in patch) {2590    // A patch with `passkey: undefined` removes the credential from the entry2591    // (used by "remove passkey" without deleting the whole entry).2592    if (patch.passkey === undefined || patch.passkey === null) {2593      nextPatch.passkey = undefined;2594    } else {2595      const sanitized = sanitizePasskey(patch.passkey);2596      if (!sanitized) throw new Error("invalid passkey credential");2597      nextPatch.passkey = sanitized;2598    }2599  }26002601  if ("tags" in patch) {2602    // PCH-02-001: bound tags exactly like both import funnels.2603    nextPatch.tags = sanitizeTags(patch.tags);2604  }26052606  if ("cardholderName" in patch) {2607    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2608  }2609  if ("cardNumber" in patch) {2610    const n = patch.cardNumber ?? "";2611    nextPatch.cardNumber = n || undefined;2612  }2613  if ("expMonth" in patch) {2614    nextPatch.expMonth = patch.expMonth?.trim() || undefined;2615  }2616  if ("expYear" in patch) {2617    nextPatch.expYear = patch.expYear?.trim() || undefined;2618  }2619  if ("cvc" in patch) {2620    nextPatch.cvc = patch.cvc?.trim() || undefined;2621  }2622  if ("cardNickname" in patch) {2623    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2624  }2625  if ("network" in patch) {2626    nextPatch.network = patch.network;2627  }2628  if ("last4" in patch) {2629    nextPatch.last4 = patch.last4?.trim() || undefined;2630  }26312632  return nextPatch;2633}26342635function normalizeEntry(entry: VaultEntry): VaultEntry {2636  const base: VaultEntry = {2637    id: entry.id,2638    type: entry.type,2639    title: entry.title,2640    username: entry.username,2641    email: entry.email,2642    password: entry.password,2643    url: entry.url,2644    additionalUrls:2645      "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2646        ? entry.additionalUrls2647        : undefined,2648    totp: entry.totp,2649    // PCH-01-019: carry TOTP params through the normalize/restore path too.2650    totpAlgorithm: entry.totpAlgorithm,2651    totpDigits: entry.totpDigits,2652    totpPeriod: entry.totpPeriod,2653    notes: entry.notes,2654    breach: entry.breach,2655    // Preserve the password-age clock across lock/unlock. Without this the2656    // field is dropped on every decode and the advanced-health "aged password"2657    // signal silently resets to createdAt on each unlock (SEV-2-A).2658    passwordChangedAt:2659      "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2660    // Preserve past-password history across lock/unlock. normalizeEntry is a2661    // field whitelist, so an unlisted field is silently dropped on every decode2662    // (same class as the passwordChangedAt SEV-2-A above).2663    passwordHistory:2664      "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2665        ? entry.passwordHistory2666        : undefined,2667    canary: entry.canary,2668    passkey: entry.passkey,2669    createdAt: entry.createdAt,2670    modifiedAt: entry.modifiedAt,2671    deletedAt: entry.deletedAt,2672    tags: entry.tags,2673    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2674    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2675    expMonth: "expMonth" in entry ? entry.expMonth : undefined,2676    expYear: "expYear" in entry ? entry.expYear : undefined,2677    cvc: "cvc" in entry ? entry.cvc : undefined,2678    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2679    network: "network" in entry ? entry.network : undefined,2680    last4: "last4" in entry ? entry.last4 : undefined,2681  };26822683  return base;2684}268526862687// ---------------------------------------------------------------------------2688// Deduplication2689// ---------------------------------------------------------------------------26902691function entryDupeKey(e: VaultEntry): string {2692  if (e.type === "note") return `note|${e.title.toLowerCase()}`;2693  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins2694  // on different sites that happen to share a username are still distinct;2695  // two passkey credentials for the same RP+user are duplicates regardless2696  // of any login fields they may also carry.2697  if (e.passkey) {2698    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2699  }2700  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2701}27022703/** Returns the number of duplicate entries (those that would be removed). */2704export function countDuplicates(): number {2705  const best = new Map<string, VaultEntry>();2706  let dupeCount = 0;2707  for (const e of entries.filter((e) => !e.deletedAt)) {2708    const key = entryDupeKey(e);2709    const existing = best.get(key);2710    if (!existing) {2711      best.set(key, e);2712    } else {2713      dupeCount++;2714      // Keep the more recently modified one in the map2715      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2716        best.set(key, e);2717      }2718    }2719  }2720  return dupeCount;2721}27222723/**2724 * Soft-deletes duplicate entries, keeping the most recently modified copy of2725 * each group. Returns the number of entries moved to trash.2726 */2727export async function deduplicateEntries(): Promise<number> {2728  if (!derivedKey) throw new Error("vault_locked");2729  // Dedup reshapes slot B permanently under the decoy key, which is2730  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.2731  if (vaultMode === "decoy") {2732    throw new Error("deduplicate disabled in decoy mode");2733  }27342735  const best = new Map<string, VaultEntry>();2736  for (const e of entries.filter((en) => !en.deletedAt)) {2737    const key = entryDupeKey(e);2738    const existing = best.get(key);2739    if (!existing) {2740      best.set(key, e);2741    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2742      best.set(key, e);2743    }2744  }27452746  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2747  let removed = 0;2748  entries = entries.map((e) => {2749    if (!e.deletedAt && !keepIds.has(e.id)) {2750      removed++;2751      const deletedAt = monotonicEntryTimestamp(Math.max(2752        e.modifiedAt,2753        tombstones.get(e.id) ?? 0,2754      ));2755      tombstones.set(e.id, deletedAt);2756      return { ...e, deletedAt };2757    }2758    return e;2759  });27602761  if (removed > 0) await persistEntries();2762  return removed;2763}27642765// ---------------------------------------------------------------------------2766// Vault history — snapshot BEFORE every vault write so the saved blob is2767// what is about to be lost, not what was just written. Capped at 5. The2768// stored blobs are AES-256-GCM ciphertext under the master password, so2769// history adds zero security surface.2770// ---------------------------------------------------------------------------27712772const HISTORY_KEY = "peach_vault_history";2773const HISTORY_MAX = 5;27742775// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the2776// plaintext snapshot field cannot reveal exact vault size or single-entry change2777// deltas. Buckets widen as the vault grows so absolute leakage stays roughly2778// proportional rather than exact.2779function bucketEntryCount(n: number): number {2780  if (n <= 0) return 0;2781  if (n <= 10) return 10;2782  if (n <= 25) return 25;2783  if (n <= 50) return 50;2784  if (n <= 100) return 100;2785  if (n <= 250) return 250;2786  if (n <= 500) return 500;2787  return Math.ceil(n / 500) * 500;2788}27892790/**2791 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE2792 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost2793 * state. Identical-to-most-recent blobs are skipped to avoid filling history2794 * with no-op writes.2795 */2796export async function takeVaultSnapshot(2797  reason: VaultSnapshot["reason"],2798): Promise<void> {2799  try {2800    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2801    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2802    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];28032804    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,2805    // pre-createVault). createVault skips snapshots intentionally — there is2806    // no prior state to preserve.2807    if (!slotA) return;28082809    const vault_a = JSON.stringify(slotA);2810    const vault_b = JSON.stringify(slotB ?? slotA);28112812    // Best-effort entry count: only meaningful when the in-memory state is2813    // unlocked AND in real mode. Decoy mode would leak the decoy entry2814    // count; locked mode has no entries to count. Both fall through to null.2815    // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage2816    // .local sits next to the AES-GCM blobs in plaintext, so an exact count would2817    // leak vault size and per-write deltas (the metadata padding hides this).2818    let entryCount: number | null = null;2819    if (derivedKey && vaultMode === "real") {2820      try {2821        entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2822      } catch {2823        entryCount = null;2824      }2825    }28262827    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2828    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2829      ? existingRaw2830      : { snapshots: [] };28312832    // Deduplication: if the most recent snapshot has byte-identical blobs,2833    // skip — saves the same vault N times in a row would otherwise burn the2834    // entire history window with no recovery value.2835    const latest = history.snapshots[0];2836    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2837      return;2838    }28392840    const snapshot: VaultSnapshot = {2841      id: crypto.randomUUID(),2842      savedAt: Date.now(),2843      reason,2844      vault_a,2845      vault_b,2846      entryCount,2847    };28482849    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2850    await chrome.storage.local.set({ [HISTORY_KEY]: history });2851  } catch (err) {2852    // Non-fatal — history must never block a save. A failed snapshot just2853    // means the user loses one rung of the safety ladder for this write.2854    // M4/QUA-006: log so the failure is visible; if it looks like a quota2855    // problem, notify the popup so the user can prune history themselves.2856    console.warn('[Peach] takeVaultSnapshot failed:', err);2857    if (String(err).includes('QUOTA') || String(err).includes('quota')) {2858      try {2859        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2860      } catch {2861        // popup may be closed; surfaced via console.warn above2862      }2863    }2864  }2865}28662867/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */2868export async function clearVaultHistory(): Promise<void> {2869  try {2870    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2871  } catch {2872    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().2873  }2874}28752876/** Read all snapshots, newest first, for the Settings vault history view. */2877export async function getVaultHistory(): Promise<VaultSnapshot[]> {2878  const raw = await chrome.storage.local.get(HISTORY_KEY);2879  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2880  if (!history || !Array.isArray(history.snapshots)) return [];2881  return history.snapshots;2882}28832884/**2885 * Replace the on-disk vault with the chosen snapshot's blobs and lock the2886 * vault. The current state is itself snapshotted with reason 'restore' first2887 * so the restore is reversible. After this returns, the user must unlock2888 * with the password that matches the restored blob.2889 */2890export async function restoreVaultSnapshot(2891  snapshotId: string,2892): Promise<{ ok: true } | { ok: false; error: string }> {2893  const history = await getVaultHistory();2894  const snapshot = history.find((s) => s.id === snapshotId);2895  if (!snapshot) return { ok: false, error: "snapshot_not_found" };28962897  // Snapshot the about-to-be-replaced current state so the restore is2898  // reversible. takeVaultSnapshot is no-op when the most recent entry is2899  // byte-identical, so a restore-of-a-restore stays clean.2900  await takeVaultSnapshot("restore");29012902  let restoredA: StoredVault;2903  let restoredB: StoredVault;2904  try {2905    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2906    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2907  } catch {2908    return { ok: false, error: "snapshot_corrupt" };2909  }29102911  await writeSlot(SLOT_A, restoredA);2912  await writeSlot(SLOT_B, restoredB);2913  await stampLocalVaultMutation();29142915  // Lock so the user must prove they can decrypt the restored blob. If they2916  // restored a pre-password-change snapshot they discover it immediately at2917  // the lock screen rather than silently holding an inaccessible vault.2918  lock();29192920  return { ok: true };2921}29222923/**2924 * Rotate the vault's sync identity by generating a FRESH ROOT KEY and2925 * re-wrapping everything under it (MV-8). Because every sync credential —2926 * vault_id, the Noise static keypair, the Ed25519 manifest signing key,2927 * and the per-entry envelope keys — derives from the root key, rotation2928 * makes a lost/stolen device's retained key material useless. All paired2929 * devices must re-pair afterwards.2930 *2931 * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from2932 * the root key, so rotation INVALIDATES the old recovery phrase. The flow2933 * sets peach_recovery_pending so the popup re-presents the new words for2934 * confirmation, same as the initial recovery-word confirmation path.2935 */2936export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2937  if (!derivedKey || vaultMode !== "real") {2938    return { ok: false, error: "vault_locked" };2939  }2940  const slotA = await loadSlot(SLOT_A);2941  if (!slotA) return { ok: false, error: "missing_slot_a" };29422943  // 1. Fresh root key + recovery escrow (new words).2944  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2945    await generateAndWrapRootKey();29462947  // 2. Re-wrap the new root key under the current password-derived key.2948  const newRootKey = unhex(rawRootKey);2949  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);29502951  // 3. Snapshot, then single atomic slot write — both escrow wrappings of2952  //    the new root key land together (same transactional rule as2953  //    changePassword). Entries are untouched: they are sealed under the2954  //    password-derived key, not the root key.2955  await takeVaultSnapshot("local_save");2956  const nextSlotA: StoredVault = {2957    ...slotA,2958    recovery_salt: recoverySalt,2959    recovery_wrapped_root_key: wrappedRootKey,2960    recovery_nonce: wrappedNonce,2961    recovery_tag: wrappedTag,2962    ...pwRoot,2963  };2964  await writeSlot(SLOT_A, nextSlotA);29652966  // 4. Swap in memory only after disk committed.2967  if (rootKey) zeroKey(rootKey);2968  rootKey = newRootKey;29692970  // 5. Old peers' identities derive from the dead root key — drop them all.2971  await clearPairedDevices();29722973  // 6. Force re-presentation of the NEW recovery words on next popup view.2974  await setRecoveryConfirmPending(); // same flag the create-vault flow uses29752976  await stampLocalVaultMutation();2977  return { ok: true };2978}29792980/**2981 * Change the master password. Re-encrypts slot A with a fresh salt + new2982 * derived key. Slot B (duress or noise) is left untouched when duress is2983 * configured — it uses an independent key derived from the duress password —2984 * and regenerated as noise when duress is not configured.2985 */2986export async function changePassword(2987  currentPassword: string,2988  newPassword: string2989): Promise<boolean> {2990  if (!derivedKey || vaultMode !== "real") return false;29912992  // Verify current password before allowing any change2993  const valid = await verifyMainPassword(currentPassword);2994  if (!valid) return false;29952996  const slotA = await loadSlot(SLOT_A);2997  if (!slotA) return false;2998  if (!rootKey) return false;29993000  // Derive fresh key from new password + new random salt3001  const newSalt = generateSalt();3002  const newKey = await deriveKey(newPassword, newSalt);30033004  // Re-encrypt the in-memory entries with the new key3005  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3006  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");30073008  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey3009  // under recovery words and remain untouched — recovery phrase keeps working3010  // across password changes (H4/COR-002).3011  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);30123013  const finalSlotA: StoredVault = {3014    ...slotA,3015    salt: hex(newSalt),3016    nonce,3017    ciphertext,3018    tag,3019    ...pwRoot,3020  };30213022  // Snapshot the OLD-key blob before overwriting it. If the user later3023  // restores from this snapshot they will need the OLD password — the3024  // post-restore lock surfaces that immediately.3025  await takeVaultSnapshot("local_save");30263027  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in3028  // one storage transaction. An SW kill before this point leaves the old3029  // slot intact; after this point everything tied to the new password is3030  // already on disk together (M6/COR-004).3031  await writeSlot(SLOT_A, finalSlotA);30323033  // Swap the in-memory key synchronously before any further await. If we3034  // threw between the Slot A write and this swap, the next persistEntries()3035  // would overwrite Slot A with old-key ciphertext, locking the user out.3036  zeroKey(derivedKey);3037  derivedKey = newKey;30383039  // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,3040  // the next service-worker restore decrypts the new-key Slot A with the stale3041  // key, fails tryDecryptSlotResult, and silently drops the user to a lock3042  // screen. Re-escrow the new key so restore survives a password change.3043  void saveSessionKey(newKey, "real");30443045  // Regenerate noise for slot B when duress is not in use. Runs after the3046  // key swap so a failure here is a disposable noise regeneration, not a3047  // lockout.3048  if (!duressConfigured) {3049    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3050  }3051  await clearPinUnlockStorage();3052  await stampLocalVaultMutation();30533054  return true;3055}