vault.ts

124.7 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 {39  clearRecoveryConfirmPending,40  setRecoveryConfirmPending,41} from "./recovery-pending";42import {43  clearPairSecrets,44  dropSessionCache,45  reconcilePairSecrets,46  setSessionAllowed,47} from "./pair-secrets";4849type EntryDraft = Omit<50  VaultEntry,51  "id" | "createdAt" | "modifiedAt" | "deletedAt"52>;5354// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B55// either holds the decoy vault (when duress is configured) or random noise56// that looks identical at rest. No external indicator is stored — duress57// configuration is recorded only inside the encrypted payload of slot A.58const SLOT_A = "peach_vault_a";59const SLOT_B = "peach_vault_b";6061// Plaintext is padded to the next multiple of this size before encryption.62// This coarsens ciphertext-length leakage into 1 KiB buckets.63const PAD_BLOCK = 1024;6465// How many past passwords to retain per entry. Generous enough to be useful as66// a recovery aid while bounding the secret material we hold (and the ciphertext67// growth) for an entry whose password is rotated frequently.68const PASSWORD_HISTORY_MAX = 50;6970// When duress is configured, BOTH slots are padded to this fixed ceiling so71// their ciphertext lengths are identical and cannot reveal that a hidden decoy72// vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably73// holds a very large vault (thousands of entries incl. passkeys). The decoy74// slot can't be re-encrypted on every real-vault save (the real session has no75// decoy key), so a fixed ceiling — rather than size-matching on each write — is76// what keeps the two slots equal. A real vault whose plaintext exceeds this77// would grow past the ceiling and re-expose the bit; that is an extreme,78// documented edge well beyond normal use.79const DURESS_SLOT_SIZE = 262_144;8081let derivedKey: Uint8Array | null = null;82// 32-byte random root key, separate from the derivedKey used for vault encryption.83// The root key is wrapped (encrypted) under the recovery key derived from the84// 12-word phrase. When the user changes their password, only the wrapping changes;85// the root key and recovery words remain stable.86let rootKey: Uint8Array | null = null;87let entries: VaultEntry[] = [];88// Tombstones outlive their full entry records so a peer that was offline89// during a purge cannot reintroduce an old ciphertext envelope. This map is90// serialized only inside the encrypted vault payload.91let tombstones = new Map<string, number>();92let domainIndex: Map<string, VaultEntry[]> = new Map();93let vaultMode: "real" | "decoy" = "real";94// Cached post-unlock view of whether the real vault records that duress is95// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock96// this stays false because we never see slot A's plaintext.97let duressConfigured = false;9899// Set to a corruption reason during the last unlock() attempt when both slots100// decrypted but failed schema validation. Consumed and cleared by101// consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted102// distinctly from wrong-password (M7/COR-007).103//104// NOTE: module-level state — if vault.ts is ever moved to a worker context,105// this must be replaced with a structured return value from unlock() or a106// postMessage channel. The signal is invisible across module-instance107// boundaries.108let lastUnlockFailureReason: string | null = null;109110export function consumeLastUnlockFailureReason(): string | null {111  const r = lastUnlockFailureReason;112  lastUnlockFailureReason = null;113  return r;114}115116// ---------------------------------------------------------------------------117// Settings (persisted inside the encrypted vault store as a sidecar key)118// ---------------------------------------------------------------------------119120export type TextSize = "small" | "medium" | "large";121122export interface VaultSettings {123  accentColor?: AccentColorName;124  theme?: "dark" | "light";125  autoLockMinutes?: number;126  clipboardClearSeconds?: number;127  lockOnClose?: boolean;128  breachMonitoring?: boolean;129  passkeyProviderEnabled?: boolean;130  trashRetentionDays?: number;131  manualSummonDomains?: Record<string, ManualSummonRecord>;132  manualSummonSalt?: string;133  declinedCardFingerprints?: string[];134135  /** Metadata is bounded because it remains outside the encrypted vault blob. */136  metadataVersion?: 1;137  /**138   * Text size scale for body copy and entry titles. Stops at "large" by139   * design — XL clips dense screens (Vault, Health) inside the fixed140   * 330×480 popup surface.141   */142  textSize?: TextSize;143  /**144   * When true, all entrance/exit transitions and ambient animations are145   * collapsed to instant. Honoured alongside the user's OS-level146   * `prefers-reduced-motion`.147   */148  reduceMotion?: boolean;149  /** Whether this vault has a local PIN unlock wrapper configured. */150  pinEnabled?: boolean;151  /** Whether the lock screen should open on PIN instead of the password. */152  pinPreferred?: boolean;153  /**154   * User overrides for keyboard shortcuts. Keys are ShortcutAction names155   * (see src/shared/shortcuts.ts). Missing actions fall through to156   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the157   * canonical lookup. Storing only the deltas keeps the vault payload158   * small and lets default updates land for users who never rebind.159   */160  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;161}162163interface StoredPinWrap {164  version: 1;165  salt: string;166  nonce: string;167  ciphertext: string;168  tag: string;169}170171const PIN_WRAP_STORAGE = "peach_pin_wrap";172const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";173const PIN_MIN_LENGTH = 4;174const PIN_MAX_LENGTH = 12;175const PIN_MAX_ATTEMPTS = 3;176177let settings: VaultSettings = {};178const CARD_DECLINE_MAX = 256;179/** Hex length of a keyed card-decline digest. Legacy records are 16 hex. */180const CARD_DECLINE_DIGEST_HEX = 32;181182async function metadataDigest(value: string, key: string): Promise<string> {183  const bytes = new TextEncoder().encode(`${key}:${value}`);184  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));185  return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");186}187188let inMemoryPinWrap: StoredPinWrap | null = null;189let inMemoryPinFailedAttempts = 0;190let sessionPinStorageFailed = false;191192export function getSettings(): VaultSettings {193  return settings;194}195196export async function ensureSettingsLoaded(): Promise<VaultSettings> {197  await loadSettings();198  return settings;199}200201export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {202  await loadSettings();203  // LV-7: cheap insurance against prototype pollution if a future caller ever204  // feeds parsed external JSON here (native spread keeps __proto__ an own-205  // property today, but Object.assign-style consumers and JSON revivers have206  // been burned before).207  const safe = Object.fromEntries(208    Object.entries(patch).filter(209      ([k]) => !["__proto__", "constructor", "prototype"].includes(k),210    ),211  ) as Partial<VaultSettings>;212  settings = { ...settings, ...safe };213  await persistSettings();214}215216function isValidPin(pin: string): boolean {217  return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);218}219220/** Set up a local PIN wrapper after proving the current master password. */221export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {222  if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;223  if (!(await verifyMainPassword(currentPassword))) return false;224225  const salt = generateSalt();226  const pinKey = await deriveKey(pin, salt);227  try {228    const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");229    await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });230    await updateSettings({ pinEnabled: true });231    return true;232  } finally {233    zeroKey(pinKey);234  }235}236237async function clearPinUnlockStorage(): Promise<void> {238  inMemoryPinWrap = null;239  inMemoryPinFailedAttempts = 0;240  sessionPinStorageFailed = false;241  if (chrome.storage.session) {242    await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);243  }244  await updateSettings({ pinEnabled: false });245}246247/** Remove the PIN wrapper after proving the current master password. */248export async function disablePinUnlock(currentPassword: string): Promise<boolean> {249  if (!derivedKey || vaultMode !== "real") return false;250  if (!(await verifyMainPassword(currentPassword))) return false;251  await clearPinUnlockStorage();252  return true;253}254255/** Unlock the real vault from its locally stored PIN-wrapped derived key. */256export async function unlockWithPin(pin: string): Promise<boolean> {257  if (!isValidPin(pin)) return false;258  await loadSettings();259  if (!settings.pinEnabled) return false;260261  const { stored, failedAttempts } = await loadPinState();262  if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;263  if (264    !stored ||265    stored.version !== 1 ||266    !stored.salt ||267    !stored.nonce ||268    !stored.ciphertext ||269    !stored.tag270  ) return false;271272  const pinKey = await deriveKey(pin, unhex(stored.salt));273  let unwrappedKey: Uint8Array | null = null;274  try {275    const masterKeyHex = await decrypt(276      pinKey,277      stored.nonce,278      stored.ciphertext,279      stored.tag,280      "pin_wrap_v1",281    );282    if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;283    unwrappedKey = unhex(masterKeyHex);284    const result = await unlockWithKey(masterKeyHex);285    if (!result.ok) return false;286    await resetPinFailedAttempts();287    return true;288  } catch {289    await recordPinFailure(failedAttempts + 1);290    return false;291  } finally {292    zeroKey(pinKey);293    if (unwrappedKey) zeroKey(unwrappedKey);294  }295}296297async function savePinWrap(stored: StoredPinWrap): Promise<void> {298  inMemoryPinWrap = stored;299  inMemoryPinFailedAttempts = 0;300  sessionPinStorageFailed = !chrome.storage.session;301  if (!chrome.storage.session) return;302  try {303    await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });304    sessionPinStorageFailed = false;305  } catch {306    // Firefox fallback: keep the wrapper only in this background context.307    sessionPinStorageFailed = true;308  }309}310311async function loadPinState(): Promise<{312  stored: StoredPinWrap | undefined;313  failedAttempts: number;314}> {315  if (!chrome.storage.session) {316    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };317  }318  try {319    const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);320    return {321      stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??322        (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),323      failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"324        ? result[PIN_ATTEMPTS_STORAGE]325        : 0,326    };327  } catch {328    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };329  }330}331332async function recordPinFailure(failedAttempts: number): Promise<void> {333  inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);334  if (!chrome.storage.session) return;335  try {336    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });337  } catch { /* in-memory counter remains the fallback */ }338}339340async function resetPinFailedAttempts(): Promise<void> {341  inMemoryPinFailedAttempts = 0;342  if (!chrome.storage.session) return;343  try {344    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });345  } catch { /* in-memory counter remains the fallback */ }346}347348export async function getPinUnlockStatus(): Promise<{349  configured: boolean;350  available: boolean;351  attemptsRemaining: number;352}> {353  await loadSettings();354  if (!settings.pinEnabled) {355    return { configured: false, available: false, attemptsRemaining: 0 };356  }357  const { stored, failedAttempts } = await loadPinState();358  const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;359  return {360    configured: true,361    available,362    attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,363  };364}365366async function persistSettings(): Promise<void> {367  try {368    await chrome.storage.local.set({369      peach_settings: {370        ...settings,371        manualSummonDomains: undefined,372        manualSummonSalt: undefined,373        cardFingerprintSalt: undefined,374        declinedCardFingerprints: settings.declinedCardFingerprints ?? [],375        metadataVersion: 1,376      },377    });378  } catch (err) {379    throw mapStorageError(err);380  }381}382383function mapStorageError(err: unknown): Error {384  const message = err instanceof Error ? err.message : String(err);385  if (/QUOTA_BYTES|quota/i.test(message)) {386    return new Error("STORAGE_QUOTA_EXCEEDED");387  }388  return err instanceof Error ? err : new Error(message);389}390391async function loadSettings(): Promise<void> {392  const result = await chrome.storage.local.get("peach_settings");393  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};394}395396// Fire-and-forget sync trigger after a committed vault write. A failure here397// must never fail the write that caused it.398function fireSyncTrigger(reason: "vault_change"): void {399  void Promise.resolve()400    .then(() => triggerSyncIfReachable({ reason }))401    .catch(() => {402      /* no sync runner registered, or nothing reachable — non-fatal */403    });404}405406let lockedSyncRefreshHook: (() => Promise<void>) | null = null;407let lockedSyncUnlockHook: (() => Promise<void>) | null = null;408let decoySyncTeardownHook: (() => void) | null = null;409410export function setLockedSyncHooks(hooks: {411  refresh: () => Promise<void>;412  afterUnlock: () => Promise<void>;413  onDecoy?: () => void;414}): void {415  lockedSyncRefreshHook = hooks.refresh;416  lockedSyncUnlockHook = hooks.afterUnlock;417  decoySyncTeardownHook = hooks.onDecoy ?? null;418}419420async function refreshLockedSyncState(): Promise<void> {421  if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;422  await lockedSyncRefreshHook().catch((error) => {423    console.warn("[Peach sync] locked spool refresh failed:", error);424  });425}426427// ---------------------------------------------------------------------------428// HIBP breach check (k-anonymity model)429// ---------------------------------------------------------------------------430431export async function checkBreach(password: string): Promise<number> {432  // HIBP k-anonymity API is keyed on SHA-1 prefixes — this is dictated by433  // the public API, not a security choice. Do not migrate to SHA-256 here.434  const encoder = new TextEncoder();435  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));436  const hashHex = Array.from(new Uint8Array(hashBuffer))437    .map((b) => b.toString(16).padStart(2, "0"))438    .join("")439    .toUpperCase();440441  const prefix = hashHex.slice(0, 5);442  const suffix = hashHex.slice(5);443444  try {445    // PCH-02-007: request HIBP response padding so response length does not446    // reveal how many suffixes a queried prefix returned.447    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {448      headers: { "Add-Padding": "true" },449    });450    if (!res.ok) return 0;451    const text = await res.text();452    const match = text.split("\r\n").find((l) => l.startsWith(suffix));453    return match ? parseInt(match.split(":")[1], 10) : 0;454  } catch {455    return 0; // Network unavailable — don't block456  }457}458459export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {460  // Unreachable from the message layer, which gates on isUnlocked first.461  const key = await cardDeclineKey();462  if (!key) return;463  const fingerprint = await cardFingerprint(last4, expMonth, expYear);464  await loadSettings();465  settings = {466    ...settings,467    declinedCardFingerprints: [468      // Legacy records predate the keyed digest and are silently dropped here:469      // they are 16 hex characters rather than 32, and are no longer accepted.470      ...(settings.declinedCardFingerprints ?? []).filter(471        (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,472      ),473      await metadataDigest(fingerprint, key),474    ].slice(-CARD_DECLINE_MAX),475  };476  await persistSettings();477}478479export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {480  await loadSettings();481  const key = await cardDeclineKey();482  if (!key) return false;483  const fingerprint = await cardFingerprint(last4, expMonth, expYear);484  const candidate = await metadataDigest(fingerprint, key);485  return (settings.declinedCardFingerprints ?? []).some(486    (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,487  );488}489490/**491 * Keying material for the declined-card digest, derived from the vault root492 * key so it is available only while unlocked and never written to storage.493 *494 * PB-26-108: the previous scheme stored a random salt beside the records. A495 * salt defeats precomputed tables, not an attacker holding the file — the496 * candidate space here is ~10^4 last-four values x 12 months x a few years, so497 * a stored salt left the card's last four and expiry recoverable offline in498 * seconds. `peach_settings` is unencrypted and directly readable by a content499 * script on Firefox, so the key has to live outside storage entirely.500 */501async function cardDeclineKey(): Promise<string | null> {502  const root = getRootKeyHex();503  if (!root) return null;504  const digest = new Uint8Array(505    await crypto.subtle.digest(506      "SHA-256",507      new TextEncoder().encode(`peach-card-decline-v1:${root}`),508    ),509  );510  return hex(digest);511}512513async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {514  const encoder = new TextEncoder();515  const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));516  return Array.from(new Uint8Array(buf).slice(0, 8))517    .map((b) => b.toString(16).padStart(2, "0"))518    .join("");519}520521function hex(buf: Uint8Array): string {522  return Array.from(buf)523    .map((b) => b.toString(16).padStart(2, "0"))524    .join("");525}526527function unhex(s: string): Uint8Array {528  const out = new Uint8Array(s.length / 2);529  for (let i = 0; i < s.length; i += 2) {530    out[i / 2] = parseInt(s.slice(i, i + 2), 16);531  }532  return out;533}534535// ---------------------------------------------------------------------------536// Root key generation and recovery word wrapping537// ---------------------------------------------------------------------------538539/**540 * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key541 * derived from the recovery word material via HKDF-SHA256. The wrapped blob542 * fields are stored in slot A alongside the password-wrapped ciphertext. The543 * root key itself is kept in memory for the session so getRecoveryWords() can544 * encode it without re-deriving.545 *546 * SECURITY MODEL (PCH-01-005): the recovery KEK is derived from the 12-word547 * material, which encodes only rootKey[0..16] (recovery-words.ts). The KEK is thus548 * derived from a PREFIX of the very plaintext it wraps, and effective recovery549 * security is 128-bit (standard for a 12-word phrase), NOT 256-bit. This is safe —550 * unwrapping requires the words, and the GCM ciphertext leaks nothing without them —551 * but it is intentional, not an oversight. A future escrow version wanting 256-bit552 * recovery must derive the words from INDEPENDENT entropy, not from the root key.553 */554async function generateAndWrapRootKey(): Promise<{555  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey556  recoverySalt: string;557  wrappedRootKey: string;558  wrappedNonce: string;559  wrappedTag: string;560}> {561  const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));562563  // Derive the recovery key from the BIP39 words encoding of the root key.564  const words = await rootKeyToWords(rootKeyBytes);565  const wordMaterial = await wordsToRootKeyMaterial(words);566  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));567  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);568569  // Wrap the root key with the recovery key using AES-256-GCM directly.570  // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.571  const nonce = crypto.getRandomValues(new Uint8Array(12));572  const encoded = new TextEncoder().encode(hex(rootKeyBytes));573  const raw = await crypto.subtle.encrypt(574    {575      name: "AES-GCM",576      iv: nonce,577      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,578      tagLength: 128,579    },580    recoveryKey,581    encoded582  );583  // WebCrypto returns ciphertext || auth tag concatenated; split them.584  const rawU8 = new Uint8Array(raw);585  const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));586  const tag = hex(rawU8.slice(rawU8.length - 16));587588  return {589    rawRootKey: hex(rootKeyBytes),590    recoverySalt: hex(recoverySalt),591    wrappedRootKey: ciphertext,592    wrappedNonce: hex(nonce),593    wrappedTag: tag,594  };595}596597async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{598  recoverySalt: string;599  wrappedRootKey: string;600  wrappedNonce: string;601  wrappedTag: string;602}> {603  if (rootKeyBytes.length !== 32) {604    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");605  }606607  const words = await rootKeyToWords(rootKeyBytes);608  const wordMaterial = await wordsToRootKeyMaterial(words);609  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));610  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);611  const nonce = crypto.getRandomValues(new Uint8Array(12));612  const encoded = new TextEncoder().encode(hex(rootKeyBytes));613  const raw = await crypto.subtle.encrypt(614    {615      name: "AES-GCM",616      iv: nonce,617      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,618      tagLength: 128,619    },620    recoveryKey,621    encoded,622  );623  const rawU8 = new Uint8Array(raw);624  return {625    recoverySalt: hex(recoverySalt),626    wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),627    wrappedNonce: hex(nonce),628    wrappedTag: hex(rawU8.slice(rawU8.length - 16)),629  };630}631632/**633 * Unwrap the root key using recovery words. Called during recovery flow when634 * the user provides their 12-word phrase and a new password.635 * Exported for use by the RECOVER_WITH_WORDS message handler.636 */637export async function unwrapRootKeyForRecovery(638  wrappedRootKey: string,639  wrappedNonce: string,640  wrappedTag: string,641  recoverySaltHex: string,642  words: string[]643): Promise<void> {644  const wordMaterial = await wordsToRootKeyMaterial(words);645  const recoverySalt = unhex(recoverySaltHex);646  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);647648  // Decrypt and cache the root key. The root key bytes were hex-encoded before649  // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt650  // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.651  // Each component is stored separately in the vault slot; decode them individually652  // so the nonce is always exactly the 12 bytes from wrappedNonce.653  const nonceRaw = unhex(wrappedNonce);654  const ciphertextRaw = unhex(wrappedRootKey);655  const tagRaw = unhex(wrappedTag);656  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).657  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.658  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;659  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);660  combined.set(ciphertextRaw, 0);661  combined.set(tagRaw, ciphertextRaw.length);662  const raw = await crypto.subtle.decrypt(663    { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },664    recoveryKey,665    combined666  );667  const decryptedHex = new TextDecoder().decode(raw);668  rootKey = unhex(decryptedHex);669}670671/**672 * Re-wrap the cached root key under a new derived key (from the user's new673 * password). Called after password change and after recovery re-key so the674 * same recovery words continue to work with the updated password — and so675 * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.676 *677 * recovery_* fields are intentionally not touched: those wrap rootKey under678 * the recovery words, which do not change when the password changes.679 */680export async function rewrapRootKeyForPassword(681  newDerivedKey: Uint8Array,682): Promise<void> {683  if (!rootKey) throw new Error("root key not set");684  const slotA = await loadSlot(SLOT_A);685  if (!slotA) throw new Error("missing slot A");686  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);687  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });688}689690/**691 * Change the vault encryption to a new derived key (already derived from692 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.693 * Caller is responsible for key swap and snapshot.694 */695async function reencryptVaultForNewKey(696  newKey: Uint8Array,697  slotA: StoredVault698): Promise<void> {699  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);700  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");701  await writeSlot(SLOT_A, {702    ...slotA,703    nonce,704    ciphertext,705    tag,706  });707}708709/**710 * Full password change with an already-derived key (used after recovery).711 * Unlike changePassword(), this skips re-verification of current password712 * since recovery flow has already validated the user's identity via words.713 */714export async function changePasswordWithNewKey(715  newSalt: Uint8Array,716  newKey: Uint8Array717): Promise<void> {718  const epoch = vaultEpoch;719  const throwIfStale = () => {720    if (epoch === vaultEpoch) return;721    zeroKey(newKey);722    throw new Error("vault_locked");723  };724  const slotA = await loadSlot(SLOT_A);725  throwIfStale();726  if (!slotA) throw new Error("missing slot A");727  // reencryptVaultForNewKey must receive the updated salt so that subsequent728  // UNLOCK calls with the new password can re-derive the correct key.729  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });730  throwIfStale();731  if (derivedKey) zeroKey(derivedKey);732  derivedKey = newKey;733  // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow734  // reaches this path, so a post-recovery SW restart would otherwise lock out735  // a user who just reset their password.736  await saveSessionKey(newKey, "real", epoch);737  throwIfStale();738  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works739  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow740  // should have populated it — fall back silently rather than blowing up the741  // recovery path; the escrow is already present on every supported vault.742  if (rootKey) {743    const slotAfter = await loadSlot(SLOT_A);744    throwIfStale();745    if (slotAfter) {746      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);747      throwIfStale();748      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });749      throwIfStale();750    }751  }752  if (!duressConfigured) {753    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));754    throwIfStale();755  }756  await clearPinUnlockStorage();757  throwIfStale();758  await stampLocalVaultMutation();759  throwIfStale();760}761762// ---------------------------------------------------------------------------763// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive764// a new key after password reset without re-importing the crypto module.765// ---------------------------------------------------------------------------766767export { deriveKey, generateSalt };768769export async function loadSlotA(): Promise<StoredVault | null> {770  return loadSlot(SLOT_A);771}772773/**774 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields775 * (a second wrapping of the root key under the password-derived key). This776 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without777 * the user re-entering their recovery phrase: the same root key bytes —778 * and therefore the same 12 words — survive every unlock.779 *780 * Returns null when the escrow is absent or decryption fails.781 */782async function deriveRootKeyFromDerivedKey(783  derivedKeyBytes: Uint8Array,784  slotA: StoredVault,785): Promise<Uint8Array | null> {786  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {787    return null;788  }789  try {790    const rootHex = await decrypt(791      derivedKeyBytes,792      slotA.pw_root_nonce,793      slotA.pw_root_ciphertext,794      slotA.pw_root_tag,795      "pw_root_v1",796    );797    return unhex(rootHex);798  } catch {799    return null;800  }801}802803// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the804// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.805async function wrapRootKeyForDerivedKey(806  derivedKeyBytes: Uint8Array,807  rootKeyBytes: Uint8Array,808): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {809  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");810  return {811    pw_root_ciphertext: ciphertext,812    pw_root_nonce: nonce,813    pw_root_tag: tag,814  };815}816817/** Returns the 12 recovery words for the current session's root key. */818export async function getRecoveryWords(): Promise<string[]> {819  if (!rootKey) {820    throw new Error("recovery not set up");821  }822  return rootKeyToWords(rootKey);823}824825async function loadSlot(key: string): Promise<StoredVault | null> {826  const result = await chrome.storage.local.get(key);827  return (result as Record<string, StoredVault | undefined>)[key] ?? null;828}829830async function writeSlot(key: string, vault: StoredVault): Promise<void> {831  try {832    await chrome.storage.local.set({ [key]: vault });833  } catch (err) {834    throw mapStorageError(err);835  }836}837838/**839 * Roll back a vault that was created by [createVaultFromRootKey] but840 * couldn't be paired (e.g. the subsequent license activation, identity841 * derivation, or paired-device upsert failed). Without this, a failed842 * receiver session leaves behind an empty vault — the user has to843 * re-pair to get a working setup, but the empty vault sits in the way.844 *845 * The call is idempotent: removing keys that aren't there is a no-op.846 */847export async function rollbackAdoptedVault(): Promise<void> {848  await lock();849  try {850    await chrome.storage.local.remove([851      SLOT_A,852      SLOT_B,853      "peach_last_modified_at",854    ]);855  } catch (err) {856    throw mapStorageError(err);857  }858}859860async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {861  const raw = await chrome.storage.local.get("peach_last_modified_at");862  const previous = Number(863    (raw as Record<string, unknown>).peach_last_modified_at,864  );865  const next = Math.max(866    Date.now(),867    Number.isFinite(previous) ? previous + 1 : 0,868  );869  try {870    await chrome.storage.local.set({871      [SLOT_A]: slotA,872      [SLOT_B]: slotB,873      peach_last_modified_at: String(next),874    });875  } catch (err) {876    throw mapStorageError(err);877  }878}879880// Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the881// sync layer's last-write-wins merge against wall-clock regressions (NTP step,882// manual clock change, DST glitch): a backwards Date.now() must never produce883// an entry timestamp that's <= the entry's existing modifiedAt, or a genuine884// local edit would lose to a stale remote copy. The returned value is always885// strictly greater than both the entry's current modifiedAt and the previous886// call's result (SEV-2-E).887let lastEntryTimestamp = 0;888function monotonicEntryTimestamp(currentModifiedAt?: number): number {889  const base = Math.max(890    Date.now(),891    (currentModifiedAt ?? 0) + 1,892    lastEntryTimestamp + 1,893  );894  lastEntryTimestamp = base;895  return base;896}897898async function stampLocalVaultMutation(): Promise<void> {899  const raw = await chrome.storage.local.get("peach_last_modified_at");900  const previous = Number(901    (raw as Record<string, unknown>).peach_last_modified_at,902  );903  const next = Math.max(904    Date.now(),905    Number.isFinite(previous) ? previous + 1 : 0,906  );907  await chrome.storage.local.set({908    peach_last_modified_at: String(next),909  });910}911912// ---------------------------------------------------------------------------913// Padding + noise helpers914// ---------------------------------------------------------------------------915916/**917 * Pads the plaintext string with trailing spaces so that the encoded byte918 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing919 * whitespace after a complete document, so the padding is transparent on920 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.921 */922function padPlaintext(json: string, minBytes = 0): string {923  const encoded = new TextEncoder().encode(json);924  const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);925  // minBytes lets the duress path force a fixed ceiling so both slots match.926  const target = Math.max(bucket, minBytes);927  const padLen = target - encoded.length;928  return json + " ".repeat(padLen);929}930931/**932 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The933 * ciphertext length matches the given byte count. Because GCM ciphertext is934 * pseudo-random, this blob is indistinguishable at rest from a real935 * encrypted vault without the key.936 *937 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto938 * spec hard limit). Chunk the fill so vaults larger than that — common once939 * the user has dozens of entries or any passkey — don't throw.940 */941const CSPRNG_CHUNK = 65_536;942943function fillRandom(buf: Uint8Array): Uint8Array {944  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {945    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);946    const chunk = new Uint8Array(end - offset);947    crypto.getRandomValues(chunk);948    buf.set(chunk, offset);949  }950  return buf;951}952953function generateNoiseBlob(ciphertextBytes: number): StoredVault {954  const salt = crypto.getRandomValues(new Uint8Array(16));955  const nonce = crypto.getRandomValues(new Uint8Array(12));956  const cipher = fillRandom(new Uint8Array(ciphertextBytes));957  const tag = crypto.getRandomValues(new Uint8Array(16));958  return {959    version: 1,960    salt: hex(salt),961    nonce: hex(nonce),962    ciphertext: hex(cipher),963    tag: hex(tag),964  };965}966967// ---------------------------------------------------------------------------968// Encrypted plaintext envelope969// ---------------------------------------------------------------------------970971type VaultPayload = {972  entries: VaultEntry[];973  tombstones?: Record<string, number>;974  duressConfigured?: boolean;975};976977type DecryptedPayload = {978  entries: VaultEntry[];979  tombstones: Map<string, number>;980  duressConfigured: boolean;981};982983function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {984  const envelope: VaultPayload = {985    entries,986    tombstones: Object.fromEntries(tombstones),987    duressConfigured: duress,988  };989  return padPlaintext(JSON.stringify(envelope), minBytes);990}991992function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {993  const result = new Map<string, number>();994  if (raw && typeof raw === "object" && !Array.isArray(raw)) {995    for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {996      if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {997        result.set(id, timestamp);998      }999    }1000  }1001  // Older payloads had no ledger. Rebuild the durable portion from any1002  // soft-deleted entries before they are eligible for a later purge.1003  for (const entry of allEntries) {1004    if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1005      continue;1006    }1007    if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1008      result.set(entry.id, entry.deletedAt);1009    }1010  }1011  return result;1012}10131014function reconcileTombstones(): void {1015  const byId = new Map(entries.map((entry) => [entry.id, entry]));1016  for (const [id] of tombstones) {1017    if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1018  }1019  for (const entry of entries) {1020    if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1021      if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1022        tombstones.set(entry.id, entry.deletedAt);1023      }1024    }1025  }1026}10271028function decodePayload(plain: string): DecryptedPayload {1029  const parsed = JSON.parse(plain) as VaultPayload;1030  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1031    throw new Error("invalid vault payload");1032  }1033  const normalized = parsed.entries.map(normalizeEntry);1034  return {1035    entries: normalized,1036    tombstones: decodeTombstones(parsed.tombstones, normalized),1037    duressConfigured: parsed.duressConfigured === true,1038  };1039}10401041/**1042 * Consolidate duplicate passkey records after registration.1043 *1044 * Multiple registrations for one RP can leave duplicate passkey records. This1045 * pass collapses the duplicates:1046 *1047 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.1048 *   2. Within each group, the most recently-active credential wins (prefer1049 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).1050 *   3. If a NON-passkey login entry exists for that RP (matched by url or1051 *      additionalUrls hostname), attach the winning passkey to it and1052 *      soft-delete every passkey-bearing entry in the group.1053 *   4. Otherwise, soft-delete every loser in the group, keeping the winner1054 *      as a passkey-only entry.1055 *1056 * Soft delete (deletedAt) rather than splice — the trash flow keeps the1057 * record around for retention and the user can restore if the heuristic1058 * picked wrong. The `merged` flag goes back to decodePayload so the1059 * unlock path persists the consolidated shape immediately.1060 */1061function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1062  let merged = false;1063  const now = Date.now();10641065  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1066  const byRpId = new Map<string, VaultEntry[]>();1067  for (const e of candidates) {1068    const rpId = e.passkey!.rpId;1069    const list = byRpId.get(rpId);1070    if (list) list.push(e);1071    else byRpId.set(rpId, [e]);1072  }10731074  for (const [rpId, group] of byRpId) {1075    if (group.length === 0) continue;1076    group.sort((a, b) => {1077      const aT =1078        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1079      const bT =1080        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1081      return bT - aT;1082    });1083    const winner = group[0]!;10841085    // Look for a NON-passkey login entry matching this RP. Use the URL1086    // hostname rather than a substring search so a "login.foo.com" entry1087    // doesn't accidentally absorb a passkey for "foo.com".1088    const loginTarget = entries.find((e) => {1089      if (e.deletedAt || e.passkey || e.type !== "login") return false;1090      if (e.url && extractDomain(e.url) === rpId) return true;1091      for (const extra of e.additionalUrls ?? []) {1092        if (extractDomain(extra) === rpId) return true;1093      }1094      return false;1095    });10961097    if (loginTarget) {1098      loginTarget.passkey = winner.passkey;1099      // A passkey entry may also carry the site's TOTP seed. Preserve it when1100      // the passkey is consolidated onto an existing login instead of1101      // soft-deleting the only record that held the 2FA configuration.1102      if (!loginTarget.totp && winner.totp) {1103        loginTarget.totp = winner.totp;1104        loginTarget.totpAlgorithm = winner.totpAlgorithm;1105        loginTarget.totpDigits = winner.totpDigits;1106        loginTarget.totpPeriod = winner.totpPeriod;1107      }1108      loginTarget.modifiedAt = now;1109      for (const dup of group) {1110        if (!dup.deletedAt) {1111          dup.deletedAt = now;1112          dup.modifiedAt = now;1113        }1114      }1115      merged = true;1116    } else if (group.length > 1) {1117      for (let i = 1; i < group.length; i += 1) {1118        const dup = group[i]!;1119        if (!dup.deletedAt) {1120          dup.deletedAt = now;1121          dup.modifiedAt = now;1122        }1123      }1124      merged = true;1125    }1126  }11271128  return { merged };1129}11301131// Validates that a `StoredVault` record on disk has the field shape we expect1132// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only1133// authenticates the static VERSION_BYTE AAD, not the `version` field on the1134// outer record — a corrupted `version: 42` slot still decrypts cleanly. This1135// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a1136// history-restore path instead of falsely reporting "wrong password".1137function hasValidVaultSchema(v: unknown): v is StoredVault {1138  if (typeof v !== "object" || v === null) return false;1139  const r = v as Record<string, unknown>;1140  return (1141    r.version === 1 &&1142    typeof r.salt === "string" &&1143    typeof r.nonce === "string" &&1144    typeof r.ciphertext === "string" &&1145    typeof r.tag === "string"1146  );1147}11481149export type TryDecryptResult =1150  | { status: "ok"; payload: DecryptedPayload }1151  | { status: "wrong_key" }1152  | { status: "corrupted"; reason: string };11531154// Exported solely for `unlockFromCodex` below, which needs to verify a1155// supplied passphrase against a slotA blob *without* committing anything to1156// storage. Keep this internal otherwise — callers crossing the messages.ts1157// boundary should always go through `unlock` / `verifyMainPassword` so the1158// constant-time invariants in Projects.md §3.4 are not bypassed.1159export async function tryDecryptSlot(1160  vault: StoredVault,1161  key: Uint8Array,1162  aadTag?: string,1163): Promise<DecryptedPayload | null> {1164  const res = await tryDecryptSlotResult(vault, key, aadTag);1165  return res.status === "ok" ? res.payload : null;1166}11671168export async function tryDecryptSlotResult(1169  vault: StoredVault,1170  key: Uint8Array,1171  aadTag?: string,1172): Promise<TryDecryptResult> {1173  if (!hasValidVaultSchema(vault)) {1174    return { status: "corrupted", reason: "invalid_slot_schema" };1175  }1176  let plain: string;1177  try {1178    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1179  } catch {1180    return { status: "wrong_key" };1181  }1182  try {1183    const parsed = JSON.parse(plain) as unknown;1184    if (typeof parsed !== "object" || parsed === null) {1185      return { status: "corrupted", reason: "invalid_payload_shape" };1186    }1187    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1188      return { status: "corrupted", reason: "invalid_payload_shape" };1189    }1190    return { status: "ok", payload: decodePayload(plain) };1191  } catch {1192    return { status: "corrupted", reason: "invalid_payload_json" };1193  }1194}11951196// ---------------------------------------------------------------------------1197// Core operations1198// ---------------------------------------------------------------------------11991200function buildDomainIndexSync(all: VaultEntry[]): void {1201  domainIndex = new Map();12021203  for (const entry of all) {1204    if (entry.deletedAt) continue;1205    if (entry.type === "note" || entry.type === "card") continue;12061207    // An entry can match multiple sites via `additionalUrls`. Index each1208    // hostname so `queryDomain` returns the entry regardless of which alias1209    // the user is currently visiting. Dedupe per-entry so a single entry1210    // never appears twice in one bucket.1211    const domains = new Set<string>();1212    const primary = extractDomain(entry.url ?? "");1213    if (primary) domains.add(primary);1214    for (const extra of entry.additionalUrls ?? []) {1215      const d = extractDomain(extra);1216      if (d) domains.add(d);1217    }1218    if (domains.size === 0) continue;12191220    for (const domain of domains) {1221      const bucket = domainIndex.get(domain) ?? [];1222      bucket.push(entry);1223      domainIndex.set(domain, bucket);1224    }1225  }1226}12271228export async function createVault(password: string): Promise<void> {1229  const salt = generateSalt();1230  derivedKey = await deriveKey(password, salt);1231  entries = [];1232  tombstones = new Map();1233  vaultMode = "real";1234  duressConfigured = false;1235  buildDomainIndexSync(entries);12361237  // Generate and wrap the root key for recovery word escrow.1238  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1239    await generateAndWrapRootKey();1240  rootKey = unhex(rawRootKey);12411242  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1243  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");12441245  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate1246  // it without recovery words (H2/COR-006).1247  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12481249  const slotA: StoredVault = {1250    version: 1,1251    salt: hex(salt),1252    nonce,1253    ciphertext,1254    tag,1255    recovery_salt: recoverySalt,1256    recovery_wrapped_root_key: wrappedRootKey,1257    recovery_nonce: wrappedNonce,1258    recovery_tag: wrappedTag,1259    ...pwRoot,1260  };1261  await writeSlot(SLOT_A, slotA);12621263  // Matching-size noise blob for slot B, so a fresh install shows two1264  // similarly-shaped records regardless of whether duress is ever set up.1265  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1266  await stampLocalVaultMutation();12671268  touchAutoLock();1269}12701271/**1272 * Creates a local vault from an existing vault root key received through the1273 * SAS-gated adoption protocol. The password is local to this device; it wraps1274 * the adopted root key at rest and does not need to match the source device.1275 */1276export async function createVaultFromRootKey(1277  password: string,1278  rootKeyHex: string,1279): Promise<{ vaultId: string }> {1280  if (typeof password !== "string" || password.length === 0) {1281    throw new Error("ADOPT_INVALID_PASSWORD");1282  }1283  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1284    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1285  }12861287  const rootKeyBytes = unhex(rootKeyHex);1288  if (rootKeyBytes.length !== 32) {1289    rootKeyBytes.fill(0);1290    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1291  }12921293  let nextDerivedKey: Uint8Array | null = null;1294  let sessionRootKey: Uint8Array | null = null;1295  try {1296    if ((await loadSlot(SLOT_A)) !== null) {1297      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1298    }12991300    const nextEntries: VaultEntry[] = [];1301    const nextDuressConfigured = false;1302    const vaultId = await computeVaultId(rootKeyBytes);1303    const salt = generateSalt();1304    nextDerivedKey = await deriveKey(password, salt);1305    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);13061307    sessionRootKey = new Uint8Array(rootKeyBytes);1308    const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1309    const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1310    const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1311    const slotA: StoredVault = {1312      version: 1,1313      salt: hex(salt),1314      nonce,1315      ciphertext,1316      tag,1317      recovery_salt: recovery.recoverySalt,1318      recovery_wrapped_root_key: recovery.wrappedRootKey,1319      recovery_nonce: recovery.wrappedNonce,1320      recovery_tag: recovery.wrappedTag,1321      ...pwRoot,1322    };1323    const slotB = generateNoiseBlob(ciphertext.length / 2);1324    await commitCreatedVaultSlots(slotA, slotB);13251326    const previousDerivedKey = derivedKey;1327    const previousRootKey = rootKey;1328    derivedKey = nextDerivedKey;1329    rootKey = sessionRootKey;1330    nextDerivedKey = null;1331    sessionRootKey = null;1332    entries = nextEntries;1333    tombstones = new Map();1334    vaultMode = "real";1335    duressConfigured = nextDuressConfigured;1336    buildDomainIndexSync(entries);1337    if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1338    if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1339    touchAutoLock();13401341    return { vaultId };1342  } finally {1343    rootKeyBytes.fill(0);1344    if (nextDerivedKey) zeroKey(nextDerivedKey);1345    if (sessionRootKey) sessionRootKey.fill(0);1346  }1347}13481349export async function hasVault(): Promise<boolean> {1350  return (await loadSlot(SLOT_A)) !== null;1351}13521353/**1354 * Returns whether the real vault has recorded that a duress passphrase is1355 * configured. Reliable only after a real-mode unlock — before unlock or in1356 * decoy mode, returns false because we cannot read slot A's plaintext.1357 */1358export async function isDuressEnabled(): Promise<boolean> {1359  return vaultMode === "real" && duressConfigured;1360}13611362export async function setupDuress(duressPassword: string): Promise<void> {1363  if (!derivedKey || vaultMode !== "real") {1364    throw new Error("vault must be unlocked in real mode");1365  }1366  // Encrypt the decoy payload (empty entries) under the duress key. Pad to1367  // at least the current slot A ciphertext size so the two slots look the1368  // same at rest at setup time.1369  const slotA = await loadSlot(SLOT_A);1370  if (!slotA) throw new Error("missing slot A");13711372  const decoySalt = generateSalt();1373  const decoyKey = await deriveKey(duressPassword, decoySalt);1374  try {1375    // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1376    // same ceiling by the persistSlotA() call below (duressConfigured is now1377    // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1378    // lengths cannot reveal that duress is configured (audit MEDIUM).1379    const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1380    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1381    await writeSlot(SLOT_B, {1382      version: 1,1383      salt: hex(decoySalt),1384      nonce,1385      ciphertext,1386      tag,1387    });1388  } finally {1389    zeroKey(decoyKey);1390  }13911392  // Re-encrypt slot A with the duressConfigured flag set to true. No external1393  // flag is written — the bit lives inside the encrypted envelope.1394  duressConfigured = true;1395  await persistSlotA();1396}13971398export async function disableDuress(): Promise<void> {1399  if (!derivedKey || vaultMode !== "real") {1400    throw new Error("vault must be unlocked in real mode");1401  }1402  // Overwrite slot B with fresh noise that matches slot A's size. The slot1403  // remains occupied so its mere existence does not signal that duress was1404  // ever configured.1405  const slotA = await loadSlot(SLOT_A);1406  if (slotA) {1407    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1408  }14091410  duressConfigured = false;1411  await persistSlotA();1412}14131414// L14/COR-015: this function intentionally checks slot A (real vault) only.1415// A duress-mode user attempting changePassword is rejected earlier by the1416// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1417// distinction here is by design — duress users cannot rotate the real1418// password by guessing it, and the asymmetry in error timing is acceptable1419// because duress mode is opt-in and the user knows their own decoy key.1420export async function verifyMainPassword(password: string): Promise<boolean> {1421  const slotA = await loadSlot(SLOT_A);1422  if (!slotA) return false;1423  const key = await deriveKey(password, unhex(slotA.salt));1424  try {1425    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1426  } finally {1427    zeroKey(key);1428  }1429}14301431export async function unlock(password: string): Promise<boolean> {1432  return attemptUnlockWithDerive(password, deriveKey);1433}14341435async function attemptUnlockWithDerive(1436  password: string,1437  derive: typeof deriveKey,1438): Promise<boolean> {1439  const epoch = vaultEpoch;1440  const stale = () => epoch !== vaultEpoch;1441  // Load both current-format slots in parallel. Both exist on a supported1442  // install; the1443  // parallel load and the parallel derivation below keep wall-clock time1444  // constant regardless of which slot's passphrase (real or decoy) the1445  // caller supplied, so a timing side channel cannot distinguish the1446  // duress path from the real-unlock path (DUR-002).1447  const [slotA, slotB] = await Promise.all([1448    loadSlot(SLOT_A),1449    loadSlot(SLOT_B),1450  ]);14511452  // Derive both keys in parallel, unconditionally.1453  const [keyA, keyB] = await Promise.all([1454    slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1455    slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1456  ]);14571458  // Attempt both decrypts in parallel. The Result variant lets us distinguish1459  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1460  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1461  const [resultA, resultB] = await Promise.all([1462    keyA && slotA1463      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1464      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1465    keyB && slotB1466      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1467      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1468  ]);1469  const payloadA = resultA.status === "ok" ? resultA.payload : null;1470  const payloadB = resultB.status === "ok" ? resultB.payload : null;14711472  if (stale()) {1473    if (keyA) zeroKey(keyA);1474    if (keyB) zeroKey(keyB);1475    return false;1476  }14771478  if (payloadA && keyA) {1479    if (stale()) {1480      zeroKey(keyA);1481      if (keyB) zeroKey(keyB);1482      return false;1483    }1484    derivedKey = keyA;1485    vaultMode = "real";1486    entries = payloadA.entries;1487    tombstones = payloadA.tombstones;1488    duressConfigured = payloadA.duressConfigured;1489    buildDomainIndexSync(entries);1490    await loadSettings();1491    if (stale()) {1492      await lock();1493      return false;1494    }1495    touchAutoLock();1496    if (keyB) zeroKey(keyB);14971498    // Current vaults always carry a password-wrapped root key. Older vaults1499    // without this escrow are outside the supported storage format.1500    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1501    if (!rootKey) {1502      await lock();1503      return false;1504    }15051506    // PB-26-213: adopt legacy plaintext pair secrets into the encrypted1507    // record and refresh the trusted-session operational copy.1508    await reconcilePairSecrets(rootKey);1509    if (stale()) {1510      await lock();1511      return false;1512    }1513    await lockedSyncUnlockHook?.().catch((error) => {1514      console.warn("[Peach sync] locked inbox drain failed:", error);1515    });1516    if (stale()) {1517      await lock();1518      return false;1519    }1520    await resetPinFailedAttempts();1521    if (stale()) {1522      await lock();1523      return false;1524    }1525    await saveSessionKey(keyA, "real", epoch);1526    if (stale()) return false;1527    return true;1528  }15291530  if (payloadB && keyB) {1531    if (stale()) {1532      zeroKey(keyB);1533      if (keyA) zeroKey(keyA);1534      return false;1535    }1536    derivedKey = keyB;1537    vaultMode = "decoy";1538    decoySyncTeardownHook?.();1539    entries = payloadB.entries;1540    tombstones = payloadB.tombstones;1541    duressConfigured = false;1542    buildDomainIndexSync(entries);1543    await loadSettings();1544    touchAutoLock();1545    if (keyA) zeroKey(keyA);1546    // PB-26-213: a duress session must never serve the real pair-secret map.1547    await dropSessionCache();1548    if (stale()) {1549      await lock();1550      return false;1551    }1552    await saveSessionKey(keyB, "decoy", epoch);1553    if (stale()) return false;1554    return true;1555  }15561557  if (keyA) zeroKey(keyA);1558  if (keyB) zeroKey(keyB);1559  // Surface corruption (good AES tag, broken schema) distinctly from1560  // wrong-password so the popup can offer history-restore (M7/COR-007).1561  if (resultA.status === "corrupted") {1562    lastUnlockFailureReason = resultA.reason;1563  } else if (resultB.status === "corrupted") {1564    lastUnlockFailureReason = resultB.reason;1565  }1566  return false;1567}15681569// ---------------------------------------------------------------------------1570// MV3 service worker session key — survives SW restarts within a browser session1571// ---------------------------------------------------------------------------15721573// chrome.storage.session is cleared on browser close; survives SW restart.1574// Firefox does not implement it (persistent background script, no restart risk).1575const SESSION_KEY_STORAGE = "peach_session_dk";15761577// Order writes and removals so lock's awaited removal is a barrier after1578// every escrow write that started before it.1579let sessionKeyMutation = Promise.resolve();15801581function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1582  const task = sessionKeyMutation.then(mutation, mutation);1583  sessionKeyMutation = task.catch(() => undefined);1584  return task;1585}15861587// PB-26-203: the session-key escrow may only operate where startup positively1588// established trusted-only session storage. Defaults to available so direct1589// vault imports (tests, non-entry bootstraps) keep working; entry.ts applies1590// the real hardening result before the first restore.1591let sessionEscrowAvailable = true;15921593/**1594 * Apply the storage-hardening result at startup. When the trusted-only1595 * session state cannot be established, any stored session key is removed and1596 * the escrow stays off: the user unlocks again after a worker restart1597 * instead of trusting an unproven boundary.1598 */1599export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1600  setSessionAllowed(sessionEscrow);1601  if (sessionEscrow) return;1602  sessionEscrowAvailable = false;1603  await clearSessionKey();1604}16051606async function saveSessionKey(1607  key: Uint8Array,1608  mode: "real" | "decoy",1609  expectedEpoch = vaultEpoch,1610): Promise<void> {1611  const session = chrome.storage.session;1612  if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1613  const stored = { keyHex: hex(key), mode };1614  await mutateSessionKey(async () => {1615    if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1616    await session.set({ [SESSION_KEY_STORAGE]: stored });1617  });1618}16191620async function clearSessionKey(): Promise<void> {1621  const session = chrome.storage.session;1622  if (!session) return;1623  await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1624}16251626// A lock that only holds while the service worker stays alive is no lock at1627// all: a cold worker's restoreFromSession() would find the surviving session1628// key and silently re-unlock ~a minute after the user locked. So lock()1629// bumps this epoch synchronously at entry and zeroes keys/indexes before any1630// await; a restore that started earlier sees the mismatch after its next1631// await and aborts instead of publishing.1632let vaultEpoch = 0;16331634// One in-flight restore shared by concurrent callers: storage is read once1635// and the locked-sync unlock hook runs once.1636let restoreInFlight: Promise<boolean> | null = null;16371638/**1639 * On SW restart, re-derive all in-memory vault state from the session key1640 * stored in chrome.storage.session (set at unlock time, cleared by lock();1641 * also cleared on browser close or when restoration fails).1642 * Returns true if the vault was restored, false if no session key was found.1643 * No-ops if the vault is already unlocked.1644 */1645export async function restoreFromSession(): Promise<boolean> {1646  if (derivedKey !== null) return true;1647  if (restoreInFlight) return restoreInFlight;1648  const task = restoreFromSessionInner();1649  restoreInFlight = task;1650  try {1651    return await task;1652  } finally {1653    restoreInFlight = null;1654  }1655}16561657async function restoreFromSessionInner(): Promise<boolean> {1658  const epoch = vaultEpoch;1659  const stale = () => epoch !== vaultEpoch;1660  if (derivedKey !== null) return true;1661  if (!sessionEscrowAvailable) return false;1662  if (!chrome.storage.session) return false;1663  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1664  if (stale()) return false;1665  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1666  if (!entry?.keyHex) return false;16671668  const key = unhex(entry.keyHex);1669  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1670  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1671  const slot = await loadSlot(slotKey);1672  if (stale()) { zeroKey(key); return false; }1673  if (!slot) { await clearSessionKey(); return false; }16741675  const result = await tryDecryptSlotResult(slot, key, aadTag);1676  if (stale()) { zeroKey(key); return false; }1677  if (result.status !== "ok") { await clearSessionKey(); return false; }16781679  // Decrypted payload stays in locals until the final epoch-checked commit1680  // below; nothing observable is published before then.1681  const payload = result.payload;1682  await loadSettings();1683  if (stale()) { zeroKey(key); return false; }16841685  // Rehydrate the root key used by sync identity derivation from the password1686  // escrow stored on the current vault format.1687  let freshRoot: Uint8Array | null = null;1688  if (entry.mode === "real") {1689    freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1690    if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1691    if (!freshRoot) {1692      await lock();1693      return false;1694    }1695  }16961697  if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1698  if (freshRoot) {1699    // PB-26-213: encrypted pair-secret record + session copy, before publish.1700    await reconcilePairSecrets(freshRoot);1701    if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1702  } else {1703    // PB-26-213: a duress restore must never serve the real pair-secret map.1704    await dropSessionCache();1705    if (stale()) { zeroKey(key); return false; }1706  }1707  derivedKey = key;1708  vaultMode = entry.mode;1709  if (entry.mode === "decoy") decoySyncTeardownHook?.();1710  entries = payload.entries;1711  tombstones = payload.tombstones;1712  duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1713  if (freshRoot) rootKey = freshRoot;1714  buildDomainIndexSync(entries);1715  touchAutoLock();17161717  await lockedSyncUnlockHook?.().catch((error) => {1718    console.warn("[Peach sync] locked inbox drain failed:", error);1719  });1720  if (stale()) return false;17211722  return true;1723}17241725export async function lock(): Promise<void> {1726  // A user-initiated lock must be durable across MV3 service-worker restarts.1727  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1728  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1729  // If the session key survived lock(), that restore would silently re-unlock1730  // the vault ~a minute after the user locked it — a lock that only held1731  // while the SW happened to stay alive. The background-sync convenience the1732  // session key once bought (sync runner re-waking to a restored vault) is1733  // not worth an unlocked vault the user never asked to unlock; when locked,1734  // sync-runner correctly reports VAULT_LOCKED instead.1735  vaultEpoch++;1736  if (derivedKey) {1737    zeroKey(derivedKey);1738    derivedKey = null;1739  }1740  if (rootKey) {1741    rootKey.fill(0);1742    rootKey = null;1743  }17441745  entries = [];1746  tombstones = new Map();1747  domainIndex = new Map();1748  vaultMode = "real";1749  duressConfigured = false;1750  clearAutoLock();1751  await clearSessionKey();1752  await clearRecoveryConfirmPending();1753}17541755export function isUnlocked(): boolean {1756  return derivedKey !== null;1757}1758/** True only while the active session is unlocked through the decoy slot. */1759export function isDecoyMode(): boolean {1760  return derivedKey !== null && vaultMode === "decoy";1761}17621763// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1764// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1765// always derives from scratch via Argon2id.1766export async function unlockWithKey(1767  masterKeyHex: string,1768): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1769  const epoch = vaultEpoch;1770  const stale = () => epoch !== vaultEpoch;1771  let masterKeyBytes: Uint8Array | null = null;1772  try {1773    masterKeyBytes = unhex(masterKeyHex);1774    const stored = await loadSlot(SLOT_A);1775    if (!stored) return { ok: false, error: "invalid_key" };1776    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1777    if (!payload || stale()) {1778      zeroKey(masterKeyBytes);1779      return { ok: false, error: "invalid_key" };1780    }17811782    derivedKey = masterKeyBytes;1783    vaultMode = "real";1784    entries = payload.entries;1785    tombstones = payload.tombstones;1786    duressConfigured = payload.duressConfigured;1787    buildDomainIndexSync(entries);1788    await loadSettings();1789    touchAutoLock();17901791    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1792    if (!rootKey) {1793      await lock();1794      return { ok: false, error: "invalid_key" };1795    }1796    await reconcilePairSecrets(rootKey);1797    if (stale()) {1798      await lock();1799      return { ok: false, error: "invalid_key" };1800    }1801    await saveSessionKey(masterKeyBytes, "real", epoch);1802    if (stale()) return { ok: false, error: "invalid_key" };1803    return { ok: true, entries };1804  } catch {1805    if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1806    return { ok: false, error: "invalid_key" };1807  }1808}18091810// --- Adoption duress-slot helpers (passthrough, no decrypt) ---18111812/**1813 * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1814 * duress is configured and slot B exists, so the inline snapshot can ship it1815 * to the receiver. Returns null otherwise. Never decrypts.1816 */1817export async function exportDuressSlotForAdoption(): Promise<string | null> {1818  if (!(await isDuressEnabled())) return null;1819  const slotB = await loadSlot(SLOT_B);1820  if (!slotB) return null;1821  return JSON.stringify(slotB);1822}18231824/**1825 * Adoption RECEIVER helper: installs a duress slot received from a source1826 * device. Validates the StoredVault shape (same guard as unlockFromCodex)1827 * and writes it to slot B. Throws on invalid shape so the caller can treat1828 * it as a non-fatal snapshot failure. Never decrypts.1829 */1830export async function installAdoptedDuressSlot(json: string): Promise<void> {1831  let parsed: StoredVault;1832  try {1833    const raw = JSON.parse(json) as unknown;1834    if (!hasValidVaultSchema(raw)) {1835      throw new Error("invalid_duress_slot_shape");1836    }1837    parsed = raw;1838  } catch (err) {1839    if (err instanceof SyntaxError) {1840      throw new Error("invalid_duress_slot_shape");1841    }1842    throw err;1843  }1844  await writeSlot(SLOT_B, parsed);1845}18461847/**1848 * Restore the vault from a parsed Peach Codex payload.1849 *1850 * The supplied passphrase is verified against the Codex's slotA *before* any1851 * storage mutation — wrong passphrases return without touching disk. On1852 * success, the current vault state is snapshotted (reason: "restore") so the1853 * user can revert via Settings -> Vault History, then slotA/slotB are written1854 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A1855 * regular `unlock()` follows so the rest of the SW sees the standard1856 * post-unlock state.1857 *1858 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex1859 * printed by a Pro user must always be restorable, even on a free device.1860 *1861 * Timing is not held constant here (in contrast to `unlock`): the Codex flow1862 * is an explicit user action with a known passphrase, not a passive lock1863 * screen, so the duress-vs-real timing side channel does not apply.1864 */1865export async function unlockFromCodex(params: {1866  slotA: string; // JSON string of StoredVault for slot A1867  slotB: string | null; // JSON string of StoredVault for slot B, or null1868  timestamp: number;1869  masterPassphrase: string;1870}): Promise<1871  | { ok: true; entryCount: number }1872  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1873> {1874  let parsedSlotA: StoredVault;1875  let parsedSlotB: StoredVault | null = null;1876  try {1877    const rawA = JSON.parse(params.slotA) as unknown;1878    if (!hasValidVaultSchema(rawA)) {1879      return { ok: false, reason: "invalid_slot_format" };1880    }1881    parsedSlotA = rawA;1882    if (params.slotB !== null) {1883      const rawB = JSON.parse(params.slotB) as unknown;1884      if (!hasValidVaultSchema(rawB)) {1885        return { ok: false, reason: "invalid_slot_format" };1886      }1887      parsedSlotB = rawB;1888    }1889  } catch {1890    return { ok: false, reason: "invalid_slot_format" };1891  }18921893  // Verify the passphrase against the supplied slotA WITHOUT writing anything.1894  // If this fails the on-disk vault is left completely untouched.1895  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1896  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1897  if (!trialPayload) {1898    zeroKey(trialKey);1899    return { ok: false, reason: "wrong_passphrase" };1900  }1901  // Drop the trial key; `unlock()` below derives its own copy. Holding two1902  // copies of the master key in memory at once would be a needless surface.1903  zeroKey(trialKey);19041905  // Snapshot the about-to-be-overwritten on-disk state so the user can revert1906  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.1907  await takeVaultSnapshot("restore");19081909  await writeSlot(SLOT_A, parsedSlotA);1910  if (parsedSlotB) {1911    await writeSlot(SLOT_B, parsedSlotB);1912  } else {1913    // Slot B must always exist with ciphertext length matching slot A1914    // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't1915    // configured); regenerate noise to satisfy the invariant.1916    await writeSlot(1917      SLOT_B,1918      generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1919    );1920  }1921  await chrome.storage.local.set({1922    peach_last_modified_at: String(params.timestamp),1923  });19241925  // Standard unlock path so domain index, settings, auto-lock, and1926  // recovery-key state all populate exactly as a normal unlock would.1927  const ok = await unlock(params.masterPassphrase);1928  if (!ok) {1929    // Should be unreachable — we just verified the passphrase against the1930    // same slotA we wrote. Treat as wrong_passphrase to keep the contract1931    // simple if some race or storage anomaly intrudes.1932    return { ok: false, reason: "wrong_passphrase" };1933  }1934  return { ok: true, entryCount: getEntries().length };1935}19361937// Returns the current in-memory derived key as a hex string. Only the PIN1938// unlock path needs this value, and it reads module state directly; this1939// accessor exists so unlockWithKey's root-key rehydration is testable.1940export /* @testable */ function getDerivedKeyHex(): string {1941  if (!derivedKey) throw new Error("vault_not_unlocked");1942  return hex(derivedKey);1943}19441945/**1946 * Returns the vault's stable root key as hex, or null when locked / not yet1947 * populated. The root key is random per-vault, generated at creation, escrowed1948 * under the recovery words, and re-wrapped (not regenerated) on every password1949 * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.1950 *1951 * This is the correct input for sync identity derivation (vault_id, the Noise1952 * NK static key, the Ed25519 manifest signing key, and per-entry envelope1953 * keys): paired devices keep the same identity and can still decrypt each1954 * other's entries after either side rotates its master password (SEV-2-B).1955 *1956 * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED1957 * instead of crashing; rootKey is populated on every supported real-mode1958 * unlock via deriveRootKeyFromDerivedKey.1959 */1960export function getRootKeyHex(): string | null {1961  return rootKey ? hex(rootKey) : null;1962}19631964export function getEntries(): VaultEntry[] {1965  if (!derivedKey) throw new Error("vault_locked");1966  return entries.filter((entry) => !entry.deletedAt);1967}19681969const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;19701971export function getIdentities(): SavedIdentity[] {1972  if (!derivedKey) throw new Error("vault_locked");1973  const active = entries.filter((e) => !e.deletedAt && e.type === "login");19741975  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1976  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();19771978  for (const entry of active) {1979    const domain = extractDomain(entry.url ?? "") ?? "";1980    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;19811982    function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1983      const existing = map.get(v);1984      if (!existing || modifiedAt > existing.lastUsed) {1985        map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1986      } else {1987        map.set(v, { ...existing, count: existing.count + 1 });1988      }1989    }19901991    const uVal = entry.username?.trim();1992    if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);19931994    // Also count the dedicated email field — entries created via the inline1995    // popup new-entry form store email here rather than in username.1996    const eVal = entry.email?.trim();1997    if (eVal) tally(emailMap, eVal);1998  }19992000  const toIdentity = (type: "email" | "username") =>2001    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({2002      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,2003    });20042005  return [2006    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2007    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2008  ];2009}20102011export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2012  if (!derivedKey) throw new Error("vault_locked");20132014  const now = Date.now();2015  const added: VaultEntry[] = [];2016  for (const draft of drafts) {2017    try {2018      const sanitized = sanitizeEntryDraft(draft);2019      const nextEntry: VaultEntry = {2020        ...sanitized,2021        id: crypto.randomUUID(),2022        createdAt: now,2023        modifiedAt: monotonicEntryTimestamp(),2024      };2025      entries.push(nextEntry);2026      added.push(nextEntry);2027    } catch {2028      // Skip invalid drafts silently — caller can see diff between input and output length2029    }2030  }20312032  if (added.length > 0) {2033    await persistEntries();2034  }20352036  return added;2037}20382039/**2040 * Restore a set of entries in full fidelity — preserves id, createdAt,2041 * modifiedAt, and every other field unlike `batchAddEntries` which2042 * assigns fresh identity. Used by restore paths where the backup is the2043 * authoritative source of truth. `mode` controls how entries already in2044 * the vault are treated.2045 */2046export async function restoreEntries(2047  incoming: VaultEntry[],2048  mode: "replace" | "merge"2049): Promise<{ added: number; updated: number; skipped: number }> {2050  if (!derivedKey) throw new Error("vault_locked");20512052  // A replace with zero incoming entries is almost certainly a caller bug2053  // (e.g. an import that silently dropped everything). Refuse rather than2054  // wipe the vault.2055  if (mode === "replace" && incoming.length === 0) {2056    throw new Error("refuse to replace with empty entry set");2057  }20582059  let added = 0;2060  let updated = 0;2061  let skipped = 0;20622063  if (mode === "replace") {2064    entries = [];2065    for (const entry of incoming) {2066      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2067      if (!normalized) {2068        skipped += 1;2069        continue;2070      }2071      entries.push(normalized);2072      if (normalized.deletedAt) {2073        tombstones.set(2074          normalized.id,2075          Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2076        );2077      } else {2078        tombstones.delete(normalized.id);2079      }2080      added += 1;2081    }2082  } else {2083    const byId = new Map<string, VaultEntry>();2084    for (const e of entries) byId.set(e.id, e);20852086    for (const entry of incoming) {2087      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2088      if (!normalized) {2089        skipped += 1;2090        continue;2091      }2092      const existing = byId.get(normalized.id);2093      if (!existing) {2094        entries.push(normalized);2095        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2096        else tombstones.delete(normalized.id);2097        added += 1;2098      } else if (normalized.modifiedAt > existing.modifiedAt) {2099        const idx = entries.findIndex((e) => e.id === existing.id);2100        if (idx !== -1) {2101          entries[idx] = normalized;2102          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2103          else tombstones.delete(normalized.id);2104          updated += 1;2105        }2106      } else {2107        skipped += 1;2108      }2109    }2110  }21112112  await persistEntries("import");2113  return { added, updated, skipped };2114}21152116/**2117 * A Peach entry id is a UUID. A restored or imported file carries whatever id2118 * its author used, and no reader bounds it, so one oversized id would ride2119 * into every future sync frame and be rejected there — a permanent, opaque2120 * failure on both platforms, caused by data that was already local.2121 *2122 * The action is mint, not reject: this is the last point where an id is still2123 * ours to choose, so the worst outcome is one duplicate if the same vault is2124 * later restored beside a device still holding the old id. Rejecting would2125 * lose the user's import.2126 *2127 * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries2128 * also calls. Sync has to adopt a peer's id verbatim — that is the only thing2129 * keeping two devices agreed on which entry they are discussing — so reminting2130 * there would fork identity on every device, on every round.2131 */2132const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;21332134function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2135  if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2136  return { ...entry, id: crypto.randomUUID() };2137}21382139/**2140 * Validate + normalise a VaultEntry coming from a backup, import, or sync2141 * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies2142 * only known VaultEntry fields (unknown/attacker keys are dropped), and2143 * nested `passkey`/`canary` shapes are rebuilt field-by-field, never2144 * spread. Returns null for values the live vault shouldn't accept.2145 */2146function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2147  if (!entry || typeof entry !== "object") return null;2148  const title = typeof entry.title === "string" ? entry.title.trim() : "";2149  if (!title) return null;2150  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2151  const now = Date.now();2152  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2153  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;21542155  // normalizeEntry whitelists every known VaultEntry field before the entry2156  // reaches the live vault.2157  const normalized = normalizeEntry({2158    ...entry,2159    id,2160    title,2161    type: entry.type ?? "login",2162    createdAt,2163    modifiedAt,2164  });21652166  if (normalized.passkey) {2167    normalized.passkey = sanitizePasskey(normalized.passkey);2168  }2169  if (normalized.canary) {2170    normalized.canary = sanitizeCanary(normalized.canary);2171  }2172  // PCH-01-016: bound tags on the restore path too, so both import funnels agree.2173  normalized.tags = sanitizeTags(normalized.tags);21742175  // Login entries from backup must carry a password (or be passkey-only,2176  // in which case the passkey sub-object replaces the password requirement).2177  if (2178    normalized.type === "login" &&2179    !normalized.passkey &&2180    typeof normalized.password !== "string"2181  ) {2182    return null;2183  }2184  if (entry.passkey && !normalized.passkey) {2185    // Entry claimed a passkey but the shape didn't validate — refuse the2186    // whole entry rather than silently importing a credential-less husk.2187    return null;2188  }21892190  return normalized;2191}21922193/**2194 * Rebuild a PasskeyData object from untrusted input, field by field.2195 * Returns undefined when required fields are missing/mistyped — a rogue2196 * passkey credential must not activate via import (MV-6 attack scenario).2197 */2198function sanitizePasskey(pk: unknown): PasskeyData | undefined {2199  if (!pk || typeof pk !== "object") return undefined;2200  const r = pk as Record<string, unknown>;2201  if (2202    typeof r.rpId !== "string" || !r.rpId ||2203    typeof r.credentialId !== "string" || !r.credentialId ||2204    typeof r.publicKeyCose !== "string" ||2205    typeof r.userHandle !== "string" ||2206    typeof r.userName !== "string"2207  ) {2208    return undefined;2209  }2210  return {2211    rpId: r.rpId,2212    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2213    credentialId: r.credentialId,2214    publicKeyCose: r.publicKeyCose,2215    privateKeyJwk:2216      r.privateKeyJwk && typeof r.privateKeyJwk === "object"2217        ? (r.privateKeyJwk as JsonWebKey)2218        : undefined,2219    userHandle: r.userHandle,2220    userName: r.userName,2221    userDisplayName:2222      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2223    algorithm: r.algorithm === -8 ? -8 : -7,2224    signCount: typeof r.signCount === "number" ? r.signCount : 0,2225    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2226    backupEligible: r.backupEligible === true,2227    backupState: r.backupState === true,2228    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2229    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2230    transports: Array.isArray(r.transports)2231      ? r.transports.filter((t): t is string => typeof t === "string")2232      : undefined,2233    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2234  };2235}22362237/**2238 * Rebuild a Canary object from untrusted input. The webhook must be a2239 * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing2240 * exfiltration endpoint that the canary subsystem will POST entry titles2241 * to (MV-6 attack scenario). Invalid → the canary is dropped, not the2242 * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire2243 * time (isAllowedCanaryWebhookUrl), so this import-time check is the2244 * outer of two gates.2245 */2246function sanitizeCanary(c: unknown): Canary | undefined {2247  if (!c || typeof c !== "object") return undefined;2248  const r = c as Record<string, unknown>;2249  if (typeof r.webhookUrl !== "string") return undefined;2250  let url: URL;2251  try {2252    url = new URL(r.webhookUrl);2253  } catch {2254    return undefined;2255  }2256  if (url.protocol !== "https:") return undefined;2257  return {2258    webhookUrl: url.toString(),2259    label: typeof r.label === "string" ? r.label : undefined,2260    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2261    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2262  };2263}22642265/**2266 * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted.2267 * Keep only non-empty strings, cap each at 256 chars, and cap the count at 2552268 * (the same per-entry ceiling the export codec enforces, peachPayload.ts).2269 * Non-array or all-invalid input collapses to undefined.2270 */2271function sanitizeTags(tags: unknown): string[] | undefined {2272  if (!Array.isArray(tags)) return undefined;2273  const out: string[] = [];2274  for (const t of tags) {2275    if (typeof t !== "string") continue;2276    const trimmed = t.trim();2277    if (!trimmed) continue;2278    out.push(trimmed.slice(0, 256));2279    if (out.length >= 255) break;2280  }2281  return out.length > 0 ? out : undefined;2282}22832284// Soft-deletes every non-deleted entry in one call. The literal string2285// argument is a tripwire against accidental calls — any caller that would2286// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this2287// to a boolean.2288export async function clearVaultEntries(2289  confirmation: "REPLACE_ALL"2290): Promise<void> {2291  if (confirmation !== "REPLACE_ALL") {2292    throw new Error("clearVaultEntries requires explicit confirmation");2293  }2294  if (!derivedKey) throw new Error("vault_locked");2295  for (const entry of entries) {2296    if (!entry.deletedAt) {2297      const stamp = monotonicEntryTimestamp(Math.max(2298        entry.modifiedAt,2299        tombstones.get(entry.id) ?? 0,2300      ));2301      entry.deletedAt = stamp;2302      entry.modifiedAt = stamp;2303      tombstones.set(entry.id, stamp);2304    }2305  }2306  await persistEntries();2307}23082309export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2310  if (!derivedKey) throw new Error("vault_locked");23112312  const sanitized = sanitizeEntryDraft(entry);2313  const now = Date.now();2314  const stamp = monotonicEntryTimestamp();2315  const nextEntry: VaultEntry = {2316    ...sanitized,2317    id: crypto.randomUUID(),2318    createdAt: now,2319    modifiedAt: stamp,2320    // Stamp passwordChangedAt at creation so age tracking is accurate from2321    // day one. Non-login entries carry the same stamp harmlessly.2322    passwordChangedAt: sanitized.password ? stamp : undefined,2323  };23242325  // Clone-and-replace so a failed persist rolls back the in-memory state2326  // (COR-008). Without this, a quota-full or storage error leaves the2327  // module-level `entries` ahead of what's actually on disk.2328  const rollback = entries;2329  entries = [...entries, nextEntry];2330  try {2331    await persistEntries();2332  } catch (err) {2333    entries = rollback;2334    throw err;2335  }23362337  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)2338  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2339    checkBreach(nextEntry.password).then(async (count) => {2340      if (count > 0) {2341        const idx = entries.findIndex((e) => e.id === nextEntry.id);2342        if (idx !== -1) {2343          entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2344          await persistEntries();2345        }2346      }2347    });2348  }23492350  fireSyncTrigger("vault_change");23512352  return nextEntry;2353}23542355/**2356 * Run the passkey-merge pass on the live in-memory entries and persist if2357 * anything changed. Called immediately after any passkey registration so that2358 * a same-domain TOTP-only entry already in the vault gets consolidated right2359 * away instead of waiting until the next lock/unlock cycle.2360 */2361export async function consolidatePasskeyEntries(): Promise<void> {2362  if (!derivedKey) return;2363  const { merged } = mergePasskeyOnlyIntoLogins(entries);2364  if (merged) {2365    await persistEntries();2366  }2367}23682369export async function updateEntry(2370  id: string,2371  patch: Partial<VaultEntry>2372): Promise<void> {2373  if (!derivedKey) throw new Error("vault_locked");23742375  const idx = entries.findIndex((entry) => entry.id === id);2376  if (idx === -1 || entries[idx].deletedAt) {2377    throw new Error("entry_not_found");2378  }23792380  const current = entries[idx];2381  const sanitizedPatch = sanitizeEntryPatch(patch);2382  // Refresh passwordChangedAt only when the password value actually changes —2383  // a metadata-only update (title, tags, etc.) must not reset the age clock2384  // that drives the Pro health rotation recommendation.2385  const passwordChanged =2386    "password" in patch &&2387    typeof sanitizedPatch.password === "string" &&2388    sanitizedPatch.password !== current.password;2389  const stamp = monotonicEntryTimestamp(current.modifiedAt);2390  // When the password actually changes, push the outgoing one onto the history2391  // (newest first) so the user can reveal/copy/restore it later. We record the2392  // age it carried (passwordChangedAt, falling back to createdAt) rather than2393  // `now`, so the list reflects when each password was last in effect. Only2394  // capture a non-empty prior value — clearing a password leaves no secret to2395  // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)2396  // to avoid noise.2397  const priorPassword = current.password;2398  const shouldCapture =2399    passwordChanged &&2400    typeof priorPassword === "string" &&2401    priorPassword.length > 0 &&2402    current.passwordHistory?.[0]?.password !== priorPassword;2403  const nextHistory = shouldCapture2404    ? [2405        { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2406        ...(current.passwordHistory ?? []),2407      ].slice(0, PASSWORD_HISTORY_MAX)2408    : current.passwordHistory;2409  entries[idx] = {2410    ...current,2411    ...sanitizedPatch,2412    modifiedAt: stamp,2413    ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2414  };2415241624172418  // Card entries must retain their card number2419  if (entries[idx].type === "card" && !entries[idx].cardNumber) {2420    entries[idx] = current;2421    throw new Error("card number is required for card entries");2422  }24232424  // Roll the in-memory entry back to its pre-edit state if the disk write2425  // fails (e.g. storage quota). Without this the module-level `entries` array2426  // would hold an edit that never landed on disk, diverging from the vault2427  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.2428  try {2429    await persistEntries();2430  } catch (err) {2431    entries[idx] = current;2432    throw err;2433  }24342435  // Breach check when password changes: clear immediately so old breach doesn't linger,2436  // then async-check the new password and only re-set if it's also found.2437  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2438    entries[idx] = { ...entries[idx], breach: undefined };2439    await persistEntries();2440    const password = entries[idx].password;2441    if (password) {2442      checkBreach(password).then(async (count) => {2443        const i = entries.findIndex((e) => e.id === id);2444        if (i !== -1 && count > 0) {2445          entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2446          await persistEntries();2447        }2448      });2449    }2450  }24512452  fireSyncTrigger("vault_change");2453}24542455export async function deleteEntry(id: string): Promise<void> {2456  if (!derivedKey) throw new Error("vault_locked");24572458  const entry = entries.find((item) => item.id === id);2459  if (!entry || entry.deletedAt) {2460    throw new Error("entry_not_found");2461  }24622463  // Tombstone timestamp must be strictly greater than the entry's last2464  // modification so the deletion wins LWW against any in-flight edit, even2465  // under clock skew (SEV-2-E).2466  const previousModifiedAt = entry.modifiedAt;2467  const previousTombstone = tombstones.get(id);2468  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2469  entry.deletedAt = stamp;2470  entry.modifiedAt = stamp;2471  tombstones.set(id, stamp);2472  // Roll the tombstone back if the disk write fails, so a quota error doesn't2473  // leave the entry deleted in memory but alive on disk (SEV-3-A).2474  try {2475    await persistEntries();2476  } catch (err) {2477    delete entry.deletedAt;2478    entry.modifiedAt = previousModifiedAt;2479    if (previousTombstone === undefined) tombstones.delete(id);2480    else tombstones.set(id, previousTombstone);2481    throw err;2482  }2483  fireSyncTrigger("vault_change");2484}24852486export function getDeletedEntries(): VaultEntry[] {2487  if (!derivedKey) return [];2488  return entries.filter((e) => !!e.deletedAt);2489}24902491/** Return all durable deletion timestamps, including purged full entries. */2492export function getTombstoneManifest(): Record<string, number> {2493  if (!derivedKey) return {};2494  return Object.fromEntries(tombstones);2495}24962497/**2498 * Returns the vault's monotonic version — the `peach_last_modified_at` value2499 * stamped on every user-initiated write. Remote (sync) writes do NOT advance2500 * this version so a sync round never cascades into a second outbound sync.2501 */2502export async function getVaultVersion(): Promise<number> {2503  try {2504    const raw = await chrome.storage.local.get("peach_last_modified_at");2505    const v = (raw as Record<string, unknown>).peach_last_modified_at;2506    if (typeof v === "number") return v;2507    if (typeof v === "string" && v !== "") {2508      const n = Number.parseInt(v, 10);2509      return Number.isFinite(n) ? n : 0;2510    }2511    return 0;2512  } catch {2513    return 0;2514  }2515}25162517/**2518 * Apply a batch of remote entries + tombstones from a sync peer.2519 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when2520 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and2521 * persists with reason `sync_received` — does NOT fire the sync dispatcher, so2522 * applying a sync round cannot cascade into another outbound sync.2523 *2524 * `applied` counts entries that actually changed local state (new add,2525 * overwrite-by-newer, or new tombstone). `received` is the raw payload size2526 * the caller can surface in the sync result UI.2527 */2528export async function applyMergedRemoteEntries(2529  remoteEntries: VaultEntry[],2530  remoteTombstones: Record<string, number>,2531): Promise<{ received: number; applied: number; conflicts: number }> {2532  // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices2533  // concurrently edit different fields of the same entry, the one with the2534  // newer modifiedAt wins the whole entry and the other device's field edits2535  // are dropped silently. This is the deliberate trade-off for a simple,2536  // conflict-free merge — per-field CRDT merge is out of scope. Guidance for2537  // users: edit a given entry on one device at a time.2538  if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");25392540  const received = remoteEntries.length + Object.keys(remoteTombstones).length;2541  let applied = 0;2542  let conflicts = 0;25432544  const byId = new Map<string, VaultEntry>();2545  for (const e of entries) byId.set(e.id, e);25462547  const mergeNow = Date.now();2548  const remoteTombstoneTimes = new Map<string, number>();2549  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2550    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2551      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2552    }2553  }25542555  for (const remote of remoteEntries) {2556    const sanitized = sanitizeRestoredEntry(remote);2557    if (!sanitized) continue;2558    // HV-1a: a far-future remote modifiedAt must not permanently pin a2559    // malicious value against honest local edits. Clamp BEFORE any2560    // comparison against local timestamps.2561    sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2562    if (typeof sanitized.deletedAt === "number") {2563      sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2564    }2565    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2566    const localTombstoneTs = tombstones.get(sanitized.id);2567    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2568    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2569      continue;2570    }2571    const local = byId.get(sanitized.id);2572    if (!local) {2573      entries.push(sanitized);2574      byId.set(sanitized.id, sanitized);2575      tombstones.delete(sanitized.id);2576      applied += 1;2577    } else if (sanitized.modifiedAt > local.modifiedAt) {2578      const idx = entries.findIndex((e) => e.id === local.id);2579      if (idx !== -1) {2580        entries[idx] = sanitized;2581        byId.set(sanitized.id, sanitized);2582        tombstones.delete(sanitized.id);2583        applied += 1;2584        if (!local.deletedAt) conflicts += 1;2585      }2586    }2587  }25882589  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2590    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2591    // HV-1a/1c: clamp the tombstone timestamp before every comparison.2592    const deletedAt = remoteTombstoneTimes.get(id)!;2593    const knownTombstone = tombstones.get(id);2594    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2595    const local = byId.get(id);2596    if (!local) {2597      tombstones.set(id, deletedAt);2598      applied += 1;2599      continue;2600    }2601    if (local.deletedAt && local.deletedAt >= deletedAt) continue;2602    if (deletedAt < local.modifiedAt) continue;2603    const idx = entries.findIndex((e) => e.id === id);2604    if (idx !== -1) {2605      entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2606      tombstones.set(id, deletedAt);2607      applied += 1;2608    }2609  }26102611  if (applied > 0 && vaultMode === "real") {2612    await persistEntries("sync_received");2613    buildDomainIndexSync(entries);2614  }2615  return { received, applied, conflicts };2616}26172618export async function restoreEntry(id: string): Promise<void> {2619  if (!derivedKey) throw new Error("vault_locked");2620  const entry = entries.find((e) => e.id === id && e.deletedAt);2621  if (!entry) throw new Error("deleted entry not found");2622  const previousDeletedAt = entry.deletedAt;2623  const previousModifiedAt = entry.modifiedAt;2624  const previousTombstone = tombstones.get(id);2625  delete entry.deletedAt;2626  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2627    entry.modifiedAt,2628    previousTombstone ?? 0,2629  ));2630  tombstones.delete(id);2631  try {2632    await persistEntries();2633  } catch (err) {2634    entry.deletedAt = previousDeletedAt;2635    entry.modifiedAt = previousModifiedAt;2636    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2637    throw err;2638  }2639  buildDomainIndexSync(entries);2640}26412642export async function purgeEntry(id: string): Promise<void> {2643  if (!derivedKey) throw new Error("vault_locked");2644  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2645  if (idx === -1) throw new Error("deleted entry not found");2646  const entry = entries[idx]!;2647  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2648  entries.splice(idx, 1);2649  await persistEntries();2650}26512652/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */2653export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2654  if (!derivedKey || retentionMs === 0) return 0;2655  const cutoff = Date.now() - retentionMs;2656  const before = entries.length;2657  for (const entry of entries) {2658    if (entry.deletedAt && entry.deletedAt < cutoff) {2659      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2660    }2661  }2662  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2663  const purged = before - entries.length;2664  if (purged > 0) await persistEntries();2665  return purged;2666}26672668export function queryDomain(domain: string): VaultEntry[] {2669  return domainIndex.get(domain) ?? [];2670}26712672export function getEntryById(id: string): VaultEntry | undefined {2673  return entries.find((entry) => entry.id === id && !entry.deletedAt);2674}26752676async function persistSlotA(2677  reason: VaultSnapshot["reason"] = "local_save",2678): Promise<StoredVault> {2679  if (!derivedKey) throw new Error("vault_locked");2680  const epoch = vaultEpoch;2681  const throwIfStale = () => {2682    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2683  };26842685  const slotA = await loadSlot(SLOT_A);2686  throwIfStale();2687  if (!slotA) throw new Error("missing slot A");26882689  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures2690  // are swallowed by takeVaultSnapshot — history must never block a save.2691  await takeVaultSnapshot(reason);2692  throwIfStale();26932694  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2695  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2696  throwIfStale();26972698  // Preserve existing recovery escrow fields.2699  const nextSlotA: StoredVault = {2700    ...slotA,2701    nonce,2702    ciphertext,2703    tag,2704    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot2705  };27062707  // Slot A (the real vault) is now committed. Wrap every later write so a2708  // mid-persist failure (e.g. a storage error after slot A landed) restores the2709  // prior slot A before rethrowing. Without this, a "failed" save that already2710  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/2711  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces2712  // as a duplicate on the next unlock (observed under storage-quota pressure).2713  let slotACommitted = false;2714  try {2715    throwIfStale();2716    await writeSlot(SLOT_A, nextSlotA);2717    slotACommitted = true;2718    throwIfStale();27192720    // When duress is not configured, keep slot B size-matched with slot A by2721    // regenerating its noise. When duress IS configured, leave slot B alone —2722    // we don't have the decoy key to re-encrypt it. This is a known trade-off.2723    if (!duressConfigured) {2724      throwIfStale();2725      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2726      throwIfStale();2727    }2728    // Only advance the vault version for locally-originated writes. Entries2729    // applied from a sync peer ("sync_received") must NOT bump the counter —2730    // otherwise applying a remote round makes us look newer than the peer and2731    // triggers a redundant outbound sync, defeating the no-op optimisation2732    // and risking a ping-pong between two devices (SEV-2-C).2733    if (reason !== "sync_received") {2734      throwIfStale();2735      await stampLocalVaultMutation();2736      throwIfStale();2737    }2738  } catch (err) {2739    // Best-effort restore — the prior slot A is smaller-or-equal, so this write2740    // fits even when the failure was a quota overflow.2741    if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2742    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2743    throw err;2744  }27452746  return nextSlotA;2747}27482749async function persistSlotB(): Promise<void> {2750  if (!derivedKey) throw new Error("vault_locked");27512752  const slotB = await loadSlot(SLOT_B);2753  if (!slotB) throw new Error("missing slot B");27542755  // Decoy plaintext always reports duressConfigured=false — the decoy user2756  // sees a clean slate. persistSlotB only runs in decoy mode, which only2757  // exists once duress is configured, so pad to the fixed duress ceiling to2758  // keep slot B's length equal to slot A's (audit MEDIUM — deniability).2759  const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2760  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");27612762  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2763  // Symmetric with persistSlotA: if the version stamp fails after the decoy2764  // slot committed, restore the prior slot B so a failed decoy-mode save can't2765  // leave disk ahead of the in-memory rollback.2766  try {2767    await stampLocalVaultMutation();2768  } catch (err) {2769    await writeSlot(SLOT_B, slotB).catch(() => {});2770    throw err;2771  }2772}27732774async function persistEntries(2775  reason: VaultSnapshot["reason"] = "local_save",2776): Promise<void> {2777  if (!derivedKey) throw new Error("vault_locked");27782779  // Reconcile direct soft-delete callers while restoring the previous map if2780  // storage rejects the save.2781  const previousTombstones = tombstones;2782  tombstones = new Map(tombstones);2783  reconcileTombstones();2784  try {2785    if (vaultMode === "decoy") {2786      await persistSlotB();2787    } else {2788      await persistSlotA(reason);2789    }2790  } catch (err) {2791    tombstones = previousTombstones;2792    throw err;2793  }2794  buildDomainIndexSync(entries);2795  await refreshLockedSyncState();2796}27972798// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage2799// boundary. Schemeless values (e.g. "github.com") are inert and kept — they2800// pre-date this guard in existing data. Unparseable values are kept: the2801// reviewed render paths never execute them (favicons via _favicon/, domains2802// via tldts); this is defense-in-depth against future sinks.2803function allowHttpUrl(url: string | undefined): string {2804  const trimmed = url?.trim() ?? "";2805  if (!trimmed) return "";2806  try {2807    const parsed = new URL(trimmed);2808    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2809  } catch {2810    // No scheme to reject.2811  }2812  return trimmed;2813}28142815function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2816  if (!Array.isArray(values)) return undefined;2817  const seen = new Set<string>();2818  const cleaned: string[] = [];2819  for (const value of values) {2820    const trimmed = typeof value === "string" ? value.trim() : "";2821    if (!trimmed) continue;2822    if (!allowHttpUrl(trimmed)) continue;2823    const key = trimmed.toLowerCase();2824    if (seen.has(key)) continue;2825    seen.add(key);2826    cleaned.push(trimmed);2827  }2828  return cleaned.length > 0 ? cleaned : undefined;2829}28302831function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2832  const title = entry.title.trim();2833  const type: VaultEntry["type"] = entry.type ?? "login";28342835  if (!title) throw new Error("title is required");28362837  if (entry.passkey) {2838    const p = entry.passkey;2839    if (2840      !p.rpId ||2841      !p.credentialId ||2842      !p.publicKeyCose ||2843      !p.userHandle ||2844      !p.userName ||2845      (p.algorithm !== -7 && p.algorithm !== -8)2846    ) {2847      throw new Error("invalid passkey credential");2848    }2849  }2850  if (type === "card") {2851    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2852      throw new Error("cardholder name and card number are required");2853    }2854  }28552856  return {2857    type,2858    title,2859    username: type === "login" ? entry.username?.trim() ?? "" : undefined,2860    email: type === "login" ? entry.email?.trim() || undefined : undefined,2861    password: type === "login" ? entry.password : undefined,2862    url: type === "login" ? allowHttpUrl(entry.url) : undefined,2863    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2864    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2865    // PCH-01-019: preserve non-default TOTP params through batch-add/import so2866    // they are not silently stripped (they fall back to SHA-1/6/30 when absent).2867    totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2868    totpDigits: type === "login" ? entry.totpDigits : undefined,2869    totpPeriod: type === "login" ? entry.totpPeriod : undefined,2870    notes: entry.notes?.trim() || undefined,2871    breach: type === "login" ? entry.breach : undefined,2872    // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags2873    // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here.2874    canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2875    tags: sanitizeTags(entry.tags),2876    passkey: entry.passkey,2877    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2878    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2879    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2880    expYear: type === "card" ? entry.expYear?.trim() : undefined,2881    cvc: type === "card" ? entry.cvc?.trim() : undefined,2882    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2883    network: type === "card" ? entry.network : undefined,2884    last4: type === "card" ? entry.last4?.trim() : undefined,2885  };2886}28872888function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2889  const nextPatch: Partial<VaultEntry> = {};28902891  if ("type" in patch) {2892    nextPatch.type = patch.type;2893  }28942895  if ("title" in patch) {2896    const title = patch.title?.trim() ?? "";2897    if (!title) throw new Error("title is required");2898    nextPatch.title = title;2899  }29002901  if ("url" in patch) {2902    nextPatch.url = allowHttpUrl(patch.url);2903  }29042905  if ("additionalUrls" in patch) {2906    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2907    const seen = new Set<string>();2908    const cleaned: string[] = [];2909    for (const value of incoming) {2910      const trimmed = typeof value === "string" ? value.trim() : "";2911      if (!trimmed || !allowHttpUrl(trimmed)) continue;2912      const key = trimmed.toLowerCase();2913      if (seen.has(key)) continue;2914      seen.add(key);2915      cleaned.push(trimmed);2916    }2917    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2918  }29192920  if ("username" in patch) {2921    nextPatch.username = patch.username?.trim() ?? "";2922  }29232924  if ("email" in patch) {2925    nextPatch.email = patch.email?.trim() || undefined;2926  }29272928  if ("password" in patch) {2929    const password = patch.password ?? "";2930    // Allow clearing a password (e.g. on passkey-only entries)2931    nextPatch.password = password || undefined;2932  }29332934  if ("totp" in patch) {2935    nextPatch.totp = patch.totp?.trim() || undefined;2936  }29372938  if ("totpAlgorithm" in patch) {2939    nextPatch.totpAlgorithm = patch.totpAlgorithm;2940  }29412942  if ("totpDigits" in patch) {2943    nextPatch.totpDigits = patch.totpDigits;2944  }29452946  if ("totpPeriod" in patch) {2947    nextPatch.totpPeriod = patch.totpPeriod;2948  }29492950  if ("notes" in patch) {2951    nextPatch.notes = patch.notes?.trim() || undefined;2952  }29532954  if ("breach" in patch) {2955    nextPatch.breach = patch.breach;2956  }29572958  if ("canary" in patch) {2959    // PCH-02-001: route update patches through the same canary whitelist used2960    // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.2961    nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2962  }29632964  if ("passkey" in patch) {2965    // A patch with `passkey: undefined` removes the credential from the entry2966    // (used by "remove passkey" without deleting the whole entry).2967    if (patch.passkey === undefined || patch.passkey === null) {2968      nextPatch.passkey = undefined;2969    } else {2970      const sanitized = sanitizePasskey(patch.passkey);2971      if (!sanitized) throw new Error("invalid passkey credential");2972      nextPatch.passkey = sanitized;2973    }2974  }29752976  if ("tags" in patch) {2977    // PCH-02-001: bound tags exactly like both import funnels.2978    nextPatch.tags = sanitizeTags(patch.tags);2979  }29802981  if ("cardholderName" in patch) {2982    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2983  }2984  if ("cardNumber" in patch) {2985    const n = patch.cardNumber ?? "";2986    nextPatch.cardNumber = n || undefined;2987  }2988  if ("expMonth" in patch) {2989    nextPatch.expMonth = patch.expMonth?.trim() || undefined;2990  }2991  if ("expYear" in patch) {2992    nextPatch.expYear = patch.expYear?.trim() || undefined;2993  }2994  if ("cvc" in patch) {2995    nextPatch.cvc = patch.cvc?.trim() || undefined;2996  }2997  if ("cardNickname" in patch) {2998    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2999  }3000  if ("network" in patch) {3001    nextPatch.network = patch.network;3002  }3003  if ("last4" in patch) {3004    nextPatch.last4 = patch.last4?.trim() || undefined;3005  }30063007  return nextPatch;3008}30093010function normalizeEntry(entry: VaultEntry): VaultEntry {3011  const base: VaultEntry = {3012    id: entry.id,3013    type: entry.type,3014    title: entry.title,3015    username: entry.username,3016    email: entry.email,3017    password: entry.password,3018    url: entry.url,3019    additionalUrls:3020      "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3021        ? entry.additionalUrls3022        : undefined,3023    totp: entry.totp,3024    // PCH-01-019: carry TOTP params through the normalize/restore path too.3025    totpAlgorithm: entry.totpAlgorithm,3026    totpDigits: entry.totpDigits,3027    totpPeriod: entry.totpPeriod,3028    notes: entry.notes,3029    breach: entry.breach,3030    // Preserve the password-age clock across lock/unlock. Without this the3031    // field is dropped on every decode and the advanced-health "aged password"3032    // signal silently resets to createdAt on each unlock (SEV-2-A).3033    passwordChangedAt:3034      "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3035    // Preserve past-password history across lock/unlock. normalizeEntry is a3036    // field whitelist, so an unlisted field is silently dropped on every decode3037    // (same class as the passwordChangedAt SEV-2-A above).3038    passwordHistory:3039      "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3040        ? entry.passwordHistory3041        : undefined,3042    canary: entry.canary,3043    passkey: entry.passkey,3044    createdAt: entry.createdAt,3045    modifiedAt: entry.modifiedAt,3046    deletedAt: entry.deletedAt,3047    tags: entry.tags,3048    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3049    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3050    expMonth: "expMonth" in entry ? entry.expMonth : undefined,3051    expYear: "expYear" in entry ? entry.expYear : undefined,3052    cvc: "cvc" in entry ? entry.cvc : undefined,3053    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3054    network: "network" in entry ? entry.network : undefined,3055    last4: "last4" in entry ? entry.last4 : undefined,3056  };30573058  return base;3059}306030613062// ---------------------------------------------------------------------------3063// Deduplication3064// ---------------------------------------------------------------------------30653066function entryDupeKey(e: VaultEntry): string {3067  if (e.type === "note") return `note|${e.title.toLowerCase()}`;3068  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins3069  // on different sites that happen to share a username are still distinct;3070  // two passkey credentials for the same RP+user are duplicates regardless3071  // of any login fields they may also carry.3072  if (e.passkey) {3073    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3074  }3075  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3076}30773078/** Returns the number of duplicate entries (those that would be removed). */3079export function countDuplicates(): number {3080  const best = new Map<string, VaultEntry>();3081  let dupeCount = 0;3082  for (const e of entries.filter((e) => !e.deletedAt)) {3083    const key = entryDupeKey(e);3084    const existing = best.get(key);3085    if (!existing) {3086      best.set(key, e);3087    } else {3088      dupeCount++;3089      // Keep the more recently modified one in the map3090      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3091        best.set(key, e);3092      }3093    }3094  }3095  return dupeCount;3096}30973098/**3099 * Soft-deletes duplicate entries, keeping the most recently modified copy of3100 * each group. Returns the number of entries moved to trash.3101 */3102export async function deduplicateEntries(): Promise<number> {3103  if (!derivedKey) throw new Error("vault_locked");3104  // Dedup reshapes slot B permanently under the decoy key, which is3105  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.3106  if (vaultMode === "decoy") {3107    throw new Error("deduplicate disabled in decoy mode");3108  }31093110  const best = new Map<string, VaultEntry>();3111  for (const e of entries.filter((en) => !en.deletedAt)) {3112    const key = entryDupeKey(e);3113    const existing = best.get(key);3114    if (!existing) {3115      best.set(key, e);3116    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3117      best.set(key, e);3118    }3119  }31203121  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3122  let removed = 0;3123  entries = entries.map((e) => {3124    if (!e.deletedAt && !keepIds.has(e.id)) {3125      removed++;3126      const deletedAt = monotonicEntryTimestamp(Math.max(3127        e.modifiedAt,3128        tombstones.get(e.id) ?? 0,3129      ));3130      tombstones.set(e.id, deletedAt);3131      return { ...e, deletedAt };3132    }3133    return e;3134  });31353136  if (removed > 0) await persistEntries();3137  return removed;3138}31393140// ---------------------------------------------------------------------------3141// Vault history — snapshot BEFORE every vault write so the saved blob is3142// what is about to be lost, not what was just written. Capped at 5. The3143// stored blobs are AES-256-GCM ciphertext under the master password, so3144// history adds zero security surface.3145// ---------------------------------------------------------------------------31463147const HISTORY_KEY = "peach_vault_history";3148const HISTORY_MAX = 5;31493150// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the3151// plaintext snapshot field cannot reveal exact vault size or single-entry change3152// deltas. Buckets widen as the vault grows so absolute leakage stays roughly3153// proportional rather than exact.3154function bucketEntryCount(n: number): number {3155  if (n <= 0) return 0;3156  if (n <= 10) return 10;3157  if (n <= 25) return 25;3158  if (n <= 50) return 50;3159  if (n <= 100) return 100;3160  if (n <= 250) return 250;3161  if (n <= 500) return 500;3162  return Math.ceil(n / 500) * 500;3163}31643165/**3166 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE3167 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost3168 * state. Identical-to-most-recent blobs are skipped to avoid filling history3169 * with no-op writes.3170 */3171export async function takeVaultSnapshot(3172  reason: VaultSnapshot["reason"],3173): Promise<void> {3174  try {3175    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3176    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3177    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];31783179    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,3180    // pre-createVault). createVault skips snapshots intentionally — there is3181    // no prior state to preserve.3182    if (!slotA) return;31833184    const vault_a = JSON.stringify(slotA);3185    const vault_b = JSON.stringify(slotB ?? slotA);31863187    // Best-effort entry count: only meaningful when the in-memory state is3188    // unlocked AND in real mode. Decoy mode would leak the decoy entry3189    // count; locked mode has no entries to count. Both fall through to null.3190    // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage3191    // .local sits next to the AES-GCM blobs in plaintext, so an exact count would3192    // leak vault size and per-write deltas (the metadata padding hides this).3193    let entryCount: number | null = null;3194    if (derivedKey && vaultMode === "real") {3195      try {3196        entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3197      } catch {3198        entryCount = null;3199      }3200    }32013202    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3203    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3204      ? existingRaw3205      : { snapshots: [] };32063207    // Deduplication: if the most recent snapshot has byte-identical blobs,3208    // skip — saves the same vault N times in a row would otherwise burn the3209    // entire history window with no recovery value.3210    const latest = history.snapshots[0];3211    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3212      return;3213    }32143215    const snapshot: VaultSnapshot = {3216      id: crypto.randomUUID(),3217      savedAt: Date.now(),3218      reason,3219      vault_a,3220      vault_b,3221      entryCount,3222    };32233224    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3225    await chrome.storage.local.set({ [HISTORY_KEY]: history });3226  } catch (err) {3227    // Non-fatal — history must never block a save. A failed snapshot just3228    // means the user loses one rung of the safety ladder for this write.3229    // M4/QUA-006: log so the failure is visible; if it looks like a quota3230    // problem, notify the popup so the user can prune history themselves.3231    console.warn('[Peach] takeVaultSnapshot failed:', err);3232    if (String(err).includes('QUOTA') || String(err).includes('quota')) {3233      try {3234        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3235      } catch {3236        // popup may be closed; surfaced via console.warn above3237      }3238    }3239  }3240}32413242/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */3243export async function clearVaultHistory(): Promise<void> {3244  try {3245    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3246  } catch {3247    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().3248  }3249}32503251/** Read all snapshots, newest first, for the Settings vault history view. */3252export async function getVaultHistory(): Promise<VaultSnapshot[]> {3253  const raw = await chrome.storage.local.get(HISTORY_KEY);3254  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3255  if (!history || !Array.isArray(history.snapshots)) return [];3256  return history.snapshots;3257}32583259/**3260 * Replace the on-disk vault with the chosen snapshot's blobs and lock the3261 * vault. The current state is itself snapshotted with reason 'restore' first3262 * so the restore is reversible. After this returns, the user must unlock3263 * with the password that matches the restored blob.3264 */3265export async function restoreVaultSnapshot(3266  snapshotId: string,3267): Promise<{ ok: true } | { ok: false; error: string }> {3268  const history = await getVaultHistory();3269  const snapshot = history.find((s) => s.id === snapshotId);3270  if (!snapshot) return { ok: false, error: "snapshot_not_found" };32713272  // Snapshot the about-to-be-replaced current state so the restore is3273  // reversible. takeVaultSnapshot is no-op when the most recent entry is3274  // byte-identical, so a restore-of-a-restore stays clean.3275  await takeVaultSnapshot("restore");32763277  let restoredA: StoredVault;3278  let restoredB: StoredVault;3279  try {3280    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3281    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3282  } catch {3283    return { ok: false, error: "snapshot_corrupt" };3284  }32853286  await writeSlot(SLOT_A, restoredA);3287  await writeSlot(SLOT_B, restoredB);3288  await stampLocalVaultMutation();32893290  // Lock so the user must prove they can decrypt the restored blob. If they3291  // restored a pre-password-change snapshot they discover it immediately at3292  // the lock screen rather than silently holding an inaccessible vault.3293  await lock();32943295  return { ok: true };3296}32973298/**3299 * Rotate the vault's sync identity by generating a FRESH ROOT KEY and3300 * re-wrapping everything under it (MV-8). Because every sync credential —3301 * vault_id, the Noise static keypair, the Ed25519 manifest signing key,3302 * and the per-entry envelope keys — derives from the root key, rotation3303 * makes a lost/stolen device's retained key material useless. All paired3304 * devices must re-pair afterwards.3305 *3306 * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from3307 * the root key, so rotation INVALIDATES the old recovery phrase. The flow3308 * sets peach_recovery_pending so the popup re-presents the new words for3309 * confirmation, same as the initial recovery-word confirmation path.3310 */3311export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3312  if (!derivedKey || vaultMode !== "real") {3313    return { ok: false, error: "vault_locked" };3314  }3315  const slotA = await loadSlot(SLOT_A);3316  if (!slotA) return { ok: false, error: "missing_slot_a" };33173318  // 1. Fresh root key + recovery escrow (new words).3319  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3320    await generateAndWrapRootKey();33213322  // 2. Re-wrap the new root key under the current password-derived key.3323  const newRootKey = unhex(rawRootKey);3324  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);33253326  // 3. Snapshot, then single atomic slot write — both escrow wrappings of3327  //    the new root key land together (same transactional rule as3328  //    changePassword). Entries are untouched: they are sealed under the3329  //    password-derived key, not the root key.3330  await takeVaultSnapshot("local_save");3331  const nextSlotA: StoredVault = {3332    ...slotA,3333    recovery_salt: recoverySalt,3334    recovery_wrapped_root_key: wrappedRootKey,3335    recovery_nonce: wrappedNonce,3336    recovery_tag: wrappedTag,3337    ...pwRoot,3338  };3339  await writeSlot(SLOT_A, nextSlotA);33403341  // 4. Swap in memory only after disk committed.3342  if (rootKey) zeroKey(rootKey);3343  rootKey = newRootKey;33443345  // 5. Old peers' identities derive from the dead root key — drop them all,3346  // with their rendezvous secrets (PB-26-213: rotation clears the map, so3347  // there is nothing to rewrap under the new key).3348  await clearPairedDevices();3349  await clearPairSecrets();33503351  // 6. Force re-presentation of the NEW recovery words on next popup view.3352  await setRecoveryConfirmPending(); // same flag the create-vault flow uses33533354  await stampLocalVaultMutation();3355  return { ok: true };3356}33573358/**3359 * Change the master password. Re-encrypts slot A with a fresh salt + new3360 * derived key. Slot B (duress or noise) is left untouched when duress is3361 * configured — it uses an independent key derived from the duress password —3362 * and regenerated as noise when duress is not configured.3363 */3364export async function changePassword(3365  currentPassword: string,3366  newPassword: string3367): Promise<boolean> {3368  const epoch = vaultEpoch;3369  const stale = () => epoch !== vaultEpoch;3370  if (!derivedKey || vaultMode !== "real") return false;33713372  // Verify current password before allowing any change3373  const valid = await verifyMainPassword(currentPassword);3374  if (!valid) return false;33753376  const slotA = await loadSlot(SLOT_A);3377  if (!slotA) return false;3378  if (!rootKey) return false;33793380  // Derive fresh key from new password + new random salt3381  const newSalt = generateSalt();3382  const newKey = await deriveKey(newPassword, newSalt);3383  if (stale()) {3384    zeroKey(newKey);3385    return false;3386  }33873388  // Re-encrypt the in-memory entries with the new key3389  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3390  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");33913392  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey3393  // under recovery words and remain untouched — recovery phrase keeps working3394  // across password changes (H4/COR-002).3395  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);33963397  const finalSlotA: StoredVault = {3398    ...slotA,3399    salt: hex(newSalt),3400    nonce,3401    ciphertext,3402    tag,3403    ...pwRoot,3404  };34053406  // Snapshot the OLD-key blob before overwriting it. If the user later3407  // restores from this snapshot they will need the OLD password — the3408  // post-restore lock surfaces that immediately.3409  await takeVaultSnapshot("local_save");34103411  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in3412  // one storage transaction. An SW kill before this point leaves the old3413  // slot intact; after this point everything tied to the new password is3414  // already on disk together (M6/COR-004).3415  await writeSlot(SLOT_A, finalSlotA);3416  if (stale()) {3417    zeroKey(newKey);3418    return false;3419  }34203421  // Swap the in-memory key synchronously before any further await. If we3422  // threw between the Slot A write and this swap, the next persistEntries()3423  // would overwrite Slot A with old-key ciphertext, locking the user out.3424  zeroKey(derivedKey);3425  derivedKey = newKey;34263427  // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,3428  // the next service-worker restore decrypts the new-key Slot A with the stale3429  // key, fails tryDecryptSlotResult, and silently drops the user to a lock3430  // screen. Re-escrow the new key so restore survives a password change.3431  if (stale()) {3432    await lock();3433    return false;3434  }3435  await saveSessionKey(newKey, "real", epoch);3436  if (stale()) return false;34373438  // Regenerate noise for slot B when duress is not in use. Runs after the3439  // key swap so a failure here is a disposable noise regeneration, not a3440  // lockout.3441  if (!duressConfigured) {3442    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3443  }3444  await clearPinUnlockStorage();3445  await stampLocalVaultMutation();34463447  return true;3448}