vault.ts

122.6 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { 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 saveSessionKey(unwrappedKey, "real");287    await resetPinFailedAttempts();288    return true;289  } catch {290    await recordPinFailure(failedAttempts + 1);291    return false;292  } finally {293    zeroKey(pinKey);294    if (unwrappedKey) zeroKey(unwrappedKey);295  }296}297298async function savePinWrap(stored: StoredPinWrap): Promise<void> {299  inMemoryPinWrap = stored;300  inMemoryPinFailedAttempts = 0;301  sessionPinStorageFailed = !chrome.storage.session;302  if (!chrome.storage.session) return;303  try {304    await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });305    sessionPinStorageFailed = false;306  } catch {307    // Firefox fallback: keep the wrapper only in this background context.308    sessionPinStorageFailed = true;309  }310}311312async function loadPinState(): Promise<{313  stored: StoredPinWrap | undefined;314  failedAttempts: number;315}> {316  if (!chrome.storage.session) {317    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };318  }319  try {320    const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);321    return {322      stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??323        (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),324      failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"325        ? result[PIN_ATTEMPTS_STORAGE]326        : 0,327    };328  } catch {329    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };330  }331}332333async function recordPinFailure(failedAttempts: number): Promise<void> {334  inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);335  if (!chrome.storage.session) return;336  try {337    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });338  } catch { /* in-memory counter remains the fallback */ }339}340341async function resetPinFailedAttempts(): Promise<void> {342  inMemoryPinFailedAttempts = 0;343  if (!chrome.storage.session) return;344  try {345    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });346  } catch { /* in-memory counter remains the fallback */ }347}348349export async function getPinUnlockStatus(): Promise<{350  configured: boolean;351  available: boolean;352  attemptsRemaining: number;353}> {354  await loadSettings();355  if (!settings.pinEnabled) {356    return { configured: false, available: false, attemptsRemaining: 0 };357  }358  const { stored, failedAttempts } = await loadPinState();359  const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;360  return {361    configured: true,362    available,363    attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,364  };365}366367async function persistSettings(): Promise<void> {368  try {369    await chrome.storage.local.set({370      peach_settings: {371        ...settings,372        manualSummonDomains: undefined,373        manualSummonSalt: undefined,374        cardFingerprintSalt: undefined,375        declinedCardFingerprints: settings.declinedCardFingerprints ?? [],376        metadataVersion: 1,377      },378    });379  } catch (err) {380    throw mapStorageError(err);381  }382}383384function mapStorageError(err: unknown): Error {385  const message = err instanceof Error ? err.message : String(err);386  if (/QUOTA_BYTES|quota/i.test(message)) {387    return new Error("STORAGE_QUOTA_EXCEEDED");388  }389  return err instanceof Error ? err : new Error(message);390}391392async function loadSettings(): Promise<void> {393  const result = await chrome.storage.local.get("peach_settings");394  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};395}396397// Fire-and-forget sync trigger after a committed vault write. A failure here398// must never fail the write that caused it.399function fireSyncTrigger(reason: "vault_change"): void {400  void Promise.resolve()401    .then(() => triggerSyncIfReachable({ reason }))402    .catch(() => {403      /* no sync runner registered, or nothing reachable — non-fatal */404    });405}406407let lockedSyncRefreshHook: (() => Promise<void>) | null = null;408let lockedSyncUnlockHook: (() => Promise<void>) | null = null;409410export function setLockedSyncHooks(hooks: {411  refresh: () => Promise<void>;412  afterUnlock: () => Promise<void>;413}): void {414  lockedSyncRefreshHook = hooks.refresh;415  lockedSyncUnlockHook = hooks.afterUnlock;416}417418async function refreshLockedSyncState(): Promise<void> {419  if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;420  await lockedSyncRefreshHook().catch((error) => {421    console.warn("[Peach sync] locked spool refresh failed:", error);422  });423}424425// ---------------------------------------------------------------------------426// HIBP breach check (k-anonymity model)427// ---------------------------------------------------------------------------428429export async function checkBreach(password: string): Promise<number> {430  // HIBP k-anonymity API is keyed on SHA-1 prefixes — this is dictated by431  // the public API, not a security choice. Do not migrate to SHA-256 here.432  const encoder = new TextEncoder();433  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));434  const hashHex = Array.from(new Uint8Array(hashBuffer))435    .map((b) => b.toString(16).padStart(2, "0"))436    .join("")437    .toUpperCase();438439  const prefix = hashHex.slice(0, 5);440  const suffix = hashHex.slice(5);441442  try {443    // PCH-02-007: request HIBP response padding so response length does not444    // reveal how many suffixes a queried prefix returned.445    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {446      headers: { "Add-Padding": "true" },447    });448    if (!res.ok) return 0;449    const text = await res.text();450    const match = text.split("\r\n").find((l) => l.startsWith(suffix));451    return match ? parseInt(match.split(":")[1], 10) : 0;452  } catch {453    return 0; // Network unavailable — don't block454  }455}456457export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {458  // Unreachable from the message layer, which gates on isUnlocked first.459  const key = await cardDeclineKey();460  if (!key) return;461  const fingerprint = await cardFingerprint(last4, expMonth, expYear);462  await loadSettings();463  settings = {464    ...settings,465    declinedCardFingerprints: [466      // Legacy records predate the keyed digest and are silently dropped here:467      // they are 16 hex characters rather than 32, and are no longer accepted.468      ...(settings.declinedCardFingerprints ?? []).filter(469        (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,470      ),471      await metadataDigest(fingerprint, key),472    ].slice(-CARD_DECLINE_MAX),473  };474  await persistSettings();475}476477export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {478  await loadSettings();479  const key = await cardDeclineKey();480  if (!key) return false;481  const fingerprint = await cardFingerprint(last4, expMonth, expYear);482  const candidate = await metadataDigest(fingerprint, key);483  return (settings.declinedCardFingerprints ?? []).some(484    (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,485  );486}487488/**489 * Keying material for the declined-card digest, derived from the vault root490 * key so it is available only while unlocked and never written to storage.491 *492 * PB-26-108: the previous scheme stored a random salt beside the records. A493 * salt defeats precomputed tables, not an attacker holding the file — the494 * candidate space here is ~10^4 last-four values x 12 months x a few years, so495 * a stored salt left the card's last four and expiry recoverable offline in496 * seconds. `peach_settings` is unencrypted and directly readable by a content497 * script on Firefox, so the key has to live outside storage entirely.498 */499async function cardDeclineKey(): Promise<string | null> {500  const root = getRootKeyHex();501  if (!root) return null;502  const digest = new Uint8Array(503    await crypto.subtle.digest(504      "SHA-256",505      new TextEncoder().encode(`peach-card-decline-v1:${root}`),506    ),507  );508  return hex(digest);509}510511async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {512  const encoder = new TextEncoder();513  const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));514  return Array.from(new Uint8Array(buf).slice(0, 8))515    .map((b) => b.toString(16).padStart(2, "0"))516    .join("");517}518519function hex(buf: Uint8Array): string {520  return Array.from(buf)521    .map((b) => b.toString(16).padStart(2, "0"))522    .join("");523}524525function unhex(s: string): Uint8Array {526  const out = new Uint8Array(s.length / 2);527  for (let i = 0; i < s.length; i += 2) {528    out[i / 2] = parseInt(s.slice(i, i + 2), 16);529  }530  return out;531}532533// ---------------------------------------------------------------------------534// Root key generation and recovery word wrapping535// ---------------------------------------------------------------------------536537/**538 * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key539 * derived from the recovery word material via HKDF-SHA256. The wrapped blob540 * fields are stored in slot A alongside the password-wrapped ciphertext. The541 * root key itself is kept in memory for the session so getRecoveryWords() can542 * encode it without re-deriving.543 *544 * SECURITY MODEL (PCH-01-005): the recovery KEK is derived from the 12-word545 * material, which encodes only rootKey[0..16] (recovery-words.ts). The KEK is thus546 * derived from a PREFIX of the very plaintext it wraps, and effective recovery547 * security is 128-bit (standard for a 12-word phrase), NOT 256-bit. This is safe —548 * unwrapping requires the words, and the GCM ciphertext leaks nothing without them —549 * but it is intentional, not an oversight. A future escrow version wanting 256-bit550 * recovery must derive the words from INDEPENDENT entropy, not from the root key.551 */552async function generateAndWrapRootKey(): Promise<{553  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey554  recoverySalt: string;555  wrappedRootKey: string;556  wrappedNonce: string;557  wrappedTag: string;558}> {559  const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));560561  // Derive the recovery key from the BIP39 words encoding of the root key.562  const words = await rootKeyToWords(rootKeyBytes);563  const wordMaterial = await wordsToRootKeyMaterial(words);564  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));565  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);566567  // Wrap the root key with the recovery key using AES-256-GCM directly.568  // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.569  const nonce = crypto.getRandomValues(new Uint8Array(12));570  const encoded = new TextEncoder().encode(hex(rootKeyBytes));571  const raw = await crypto.subtle.encrypt(572    {573      name: "AES-GCM",574      iv: nonce,575      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,576      tagLength: 128,577    },578    recoveryKey,579    encoded580  );581  // WebCrypto returns ciphertext || auth tag concatenated; split them.582  const rawU8 = new Uint8Array(raw);583  const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));584  const tag = hex(rawU8.slice(rawU8.length - 16));585586  return {587    rawRootKey: hex(rootKeyBytes),588    recoverySalt: hex(recoverySalt),589    wrappedRootKey: ciphertext,590    wrappedNonce: hex(nonce),591    wrappedTag: tag,592  };593}594595async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{596  recoverySalt: string;597  wrappedRootKey: string;598  wrappedNonce: string;599  wrappedTag: string;600}> {601  if (rootKeyBytes.length !== 32) {602    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");603  }604605  const words = await rootKeyToWords(rootKeyBytes);606  const wordMaterial = await wordsToRootKeyMaterial(words);607  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));608  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);609  const nonce = crypto.getRandomValues(new Uint8Array(12));610  const encoded = new TextEncoder().encode(hex(rootKeyBytes));611  const raw = await crypto.subtle.encrypt(612    {613      name: "AES-GCM",614      iv: nonce,615      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,616      tagLength: 128,617    },618    recoveryKey,619    encoded,620  );621  const rawU8 = new Uint8Array(raw);622  return {623    recoverySalt: hex(recoverySalt),624    wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),625    wrappedNonce: hex(nonce),626    wrappedTag: hex(rawU8.slice(rawU8.length - 16)),627  };628}629630/**631 * Unwrap the root key using recovery words. Called during recovery flow when632 * the user provides their 12-word phrase and a new password.633 * Exported for use by the RECOVER_WITH_WORDS message handler.634 */635export async function unwrapRootKeyForRecovery(636  wrappedRootKey: string,637  wrappedNonce: string,638  wrappedTag: string,639  recoverySaltHex: string,640  words: string[]641): Promise<void> {642  const wordMaterial = await wordsToRootKeyMaterial(words);643  const recoverySalt = unhex(recoverySaltHex);644  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);645646  // Decrypt and cache the root key. The root key bytes were hex-encoded before647  // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt648  // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.649  // Each component is stored separately in the vault slot; decode them individually650  // so the nonce is always exactly the 12 bytes from wrappedNonce.651  const nonceRaw = unhex(wrappedNonce);652  const ciphertextRaw = unhex(wrappedRootKey);653  const tagRaw = unhex(wrappedTag);654  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).655  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.656  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;657  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);658  combined.set(ciphertextRaw, 0);659  combined.set(tagRaw, ciphertextRaw.length);660  const raw = await crypto.subtle.decrypt(661    { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },662    recoveryKey,663    combined664  );665  const decryptedHex = new TextDecoder().decode(raw);666  rootKey = unhex(decryptedHex);667}668669/**670 * Re-wrap the cached root key under a new derived key (from the user's new671 * password). Called after password change and after recovery re-key so the672 * same recovery words continue to work with the updated password — and so673 * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.674 *675 * recovery_* fields are intentionally not touched: those wrap rootKey under676 * the recovery words, which do not change when the password changes.677 */678export async function rewrapRootKeyForPassword(679  newDerivedKey: Uint8Array,680  _newSalt: Uint8Array681): Promise<void> {682  if (!rootKey) throw new Error("root key not set");683  const slotA = await loadSlot(SLOT_A);684  if (!slotA) throw new Error("missing slot A");685  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);686  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });687}688689/**690 * Change the vault encryption to a new derived key (already derived from691 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.692 * Caller is responsible for key swap and snapshot.693 */694async function reencryptVaultForNewKey(695  newKey: Uint8Array,696  slotA: StoredVault697): Promise<void> {698  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);699  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");700  await writeSlot(SLOT_A, {701    ...slotA,702    nonce,703    ciphertext,704    tag,705  });706}707708/**709 * Full password change with an already-derived key (used after recovery).710 * Unlike changePassword(), this skips re-verification of current password711 * since recovery flow has already validated the user's identity via words.712 */713export async function changePasswordWithNewKey(714  newSalt: Uint8Array,715  newKey: Uint8Array716): Promise<void> {717  const slotA = await loadSlot(SLOT_A);718  if (!slotA) throw new Error("missing slot A");719  // reencryptVaultForNewKey must receive the updated salt so that subsequent720  // UNLOCK calls with the new password can re-derive the correct key.721  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });722  if (derivedKey) zeroKey(derivedKey);723  derivedKey = newKey;724  // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow725  // reaches this path, so a post-recovery SW restart would otherwise lock out726  // a user who just reset their password.727  void saveSessionKey(newKey, "real");728  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works729  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow730  // should have populated it — fall back silently rather than blowing up the731  // recovery path; the escrow is already present on every supported vault.732  if (rootKey) {733    const slotAfter = await loadSlot(SLOT_A);734    if (slotAfter) {735      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);736      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });737    }738  }739  if (!duressConfigured) {740    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));741  }742  await clearPinUnlockStorage();743  await stampLocalVaultMutation();744}745746// ---------------------------------------------------------------------------747// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive748// a new key after password reset without re-importing the crypto module.749// ---------------------------------------------------------------------------750751export { deriveKey, generateSalt };752753export async function loadSlotA(): Promise<StoredVault | null> {754  return loadSlot(SLOT_A);755}756757/**758 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields759 * (a second wrapping of the root key under the password-derived key). This760 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without761 * the user re-entering their recovery phrase: the same root key bytes —762 * and therefore the same 12 words — survive every unlock.763 *764 * Returns null when the escrow is absent or decryption fails.765 */766async function deriveRootKeyFromDerivedKey(767  derivedKeyBytes: Uint8Array,768  slotA: StoredVault,769): Promise<Uint8Array | null> {770  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {771    return null;772  }773  try {774    const rootHex = await decrypt(775      derivedKeyBytes,776      slotA.pw_root_nonce,777      slotA.pw_root_ciphertext,778      slotA.pw_root_tag,779      "pw_root_v1",780    );781    return unhex(rootHex);782  } catch {783    return null;784  }785}786787// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the788// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.789async function wrapRootKeyForDerivedKey(790  derivedKeyBytes: Uint8Array,791  rootKeyBytes: Uint8Array,792): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {793  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");794  return {795    pw_root_ciphertext: ciphertext,796    pw_root_nonce: nonce,797    pw_root_tag: tag,798  };799}800801/** Returns the 12 recovery words for the current session's root key. */802export async function getRecoveryWords(): Promise<string[]> {803  if (!rootKey) {804    throw new Error("recovery not set up");805  }806  return rootKeyToWords(rootKey);807}808809async function loadSlot(key: string): Promise<StoredVault | null> {810  const result = await chrome.storage.local.get(key);811  return (result as Record<string, StoredVault | undefined>)[key] ?? null;812}813814async function writeSlot(key: string, vault: StoredVault): Promise<void> {815  try {816    await chrome.storage.local.set({ [key]: vault });817  } catch (err) {818    throw mapStorageError(err);819  }820}821822/**823 * Roll back a vault that was created by [createVaultFromRootKey] but824 * couldn't be paired (e.g. the subsequent license activation, identity825 * derivation, or paired-device upsert failed). Without this, a failed826 * receiver session leaves behind an empty vault — the user has to827 * re-pair to get a working setup, but the empty vault sits in the way.828 *829 * The call is idempotent: removing keys that aren't there is a no-op.830 */831export async function rollbackAdoptedVault(): Promise<void> {832  await lock();833  try {834    await chrome.storage.local.remove([835      SLOT_A,836      SLOT_B,837      "peach_last_modified_at",838    ]);839  } catch (err) {840    throw mapStorageError(err);841  }842}843844async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {845  const raw = await chrome.storage.local.get("peach_last_modified_at");846  const previous = Number(847    (raw as Record<string, unknown>).peach_last_modified_at,848  );849  const next = Math.max(850    Date.now(),851    Number.isFinite(previous) ? previous + 1 : 0,852  );853  try {854    await chrome.storage.local.set({855      [SLOT_A]: slotA,856      [SLOT_B]: slotB,857      peach_last_modified_at: String(next),858    });859  } catch (err) {860    throw mapStorageError(err);861  }862}863864// Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the865// sync layer's last-write-wins merge against wall-clock regressions (NTP step,866// manual clock change, DST glitch): a backwards Date.now() must never produce867// an entry timestamp that's <= the entry's existing modifiedAt, or a genuine868// local edit would lose to a stale remote copy. The returned value is always869// strictly greater than both the entry's current modifiedAt and the previous870// call's result (SEV-2-E).871let lastEntryTimestamp = 0;872function monotonicEntryTimestamp(currentModifiedAt?: number): number {873  const base = Math.max(874    Date.now(),875    (currentModifiedAt ?? 0) + 1,876    lastEntryTimestamp + 1,877  );878  lastEntryTimestamp = base;879  return base;880}881882async function stampLocalVaultMutation(): Promise<void> {883  const raw = await chrome.storage.local.get("peach_last_modified_at");884  const previous = Number(885    (raw as Record<string, unknown>).peach_last_modified_at,886  );887  const next = Math.max(888    Date.now(),889    Number.isFinite(previous) ? previous + 1 : 0,890  );891  await chrome.storage.local.set({892    peach_last_modified_at: String(next),893  });894}895896// ---------------------------------------------------------------------------897// Padding + noise helpers898// ---------------------------------------------------------------------------899900/**901 * Pads the plaintext string with trailing spaces so that the encoded byte902 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing903 * whitespace after a complete document, so the padding is transparent on904 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.905 */906function padPlaintext(json: string, minBytes = 0): string {907  const encoded = new TextEncoder().encode(json);908  const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);909  // minBytes lets the duress path force a fixed ceiling so both slots match.910  const target = Math.max(bucket, minBytes);911  const padLen = target - encoded.length;912  return json + " ".repeat(padLen);913}914915/**916 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The917 * ciphertext length matches the given byte count. Because GCM ciphertext is918 * pseudo-random, this blob is indistinguishable at rest from a real919 * encrypted vault without the key.920 *921 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto922 * spec hard limit). Chunk the fill so vaults larger than that — common once923 * the user has dozens of entries or any passkey — don't throw.924 */925const CSPRNG_CHUNK = 65_536;926927function fillRandom(buf: Uint8Array): Uint8Array {928  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {929    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);930    const chunk = new Uint8Array(end - offset);931    crypto.getRandomValues(chunk);932    buf.set(chunk, offset);933  }934  return buf;935}936937function generateNoiseBlob(ciphertextBytes: number): StoredVault {938  const salt = crypto.getRandomValues(new Uint8Array(16));939  const nonce = crypto.getRandomValues(new Uint8Array(12));940  const cipher = fillRandom(new Uint8Array(ciphertextBytes));941  const tag = crypto.getRandomValues(new Uint8Array(16));942  return {943    version: 1,944    salt: hex(salt),945    nonce: hex(nonce),946    ciphertext: hex(cipher),947    tag: hex(tag),948  };949}950951// ---------------------------------------------------------------------------952// Encrypted plaintext envelope953// ---------------------------------------------------------------------------954955type VaultPayload = {956  entries: VaultEntry[];957  tombstones?: Record<string, number>;958  duressConfigured?: boolean;959};960961type DecryptedPayload = {962  entries: VaultEntry[];963  tombstones: Map<string, number>;964  duressConfigured: boolean;965};966967function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {968  const envelope: VaultPayload = {969    entries,970    tombstones: Object.fromEntries(tombstones),971    duressConfigured: duress,972  };973  return padPlaintext(JSON.stringify(envelope), minBytes);974}975976function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {977  const result = new Map<string, number>();978  if (raw && typeof raw === "object" && !Array.isArray(raw)) {979    for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {980      if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {981        result.set(id, timestamp);982      }983    }984  }985  // Older payloads had no ledger. Rebuild the durable portion from any986  // soft-deleted entries before they are eligible for a later purge.987  for (const entry of allEntries) {988    if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {989      continue;990    }991    if ((result.get(entry.id) ?? 0) < entry.deletedAt) {992      result.set(entry.id, entry.deletedAt);993    }994  }995  return result;996}997998function reconcileTombstones(): void {999  const byId = new Map(entries.map((entry) => [entry.id, entry]));1000  for (const [id] of tombstones) {1001    if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1002  }1003  for (const entry of entries) {1004    if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1005      if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1006        tombstones.set(entry.id, entry.deletedAt);1007      }1008    }1009  }1010}10111012function decodePayload(plain: string): DecryptedPayload {1013  const parsed = JSON.parse(plain) as VaultPayload;1014  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1015    throw new Error("invalid vault payload");1016  }1017  const normalized = parsed.entries.map(normalizeEntry);1018  return {1019    entries: normalized,1020    tombstones: decodeTombstones(parsed.tombstones, normalized),1021    duressConfigured: parsed.duressConfigured === true,1022  };1023}10241025/**1026 * Consolidate duplicate passkey records after registration.1027 *1028 * Multiple registrations for one RP can leave duplicate passkey records. This1029 * pass collapses the duplicates:1030 *1031 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.1032 *   2. Within each group, the most recently-active credential wins (prefer1033 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).1034 *   3. If a NON-passkey login entry exists for that RP (matched by url or1035 *      additionalUrls hostname), attach the winning passkey to it and1036 *      soft-delete every passkey-bearing entry in the group.1037 *   4. Otherwise, soft-delete every loser in the group, keeping the winner1038 *      as a passkey-only entry.1039 *1040 * Soft delete (deletedAt) rather than splice — the trash flow keeps the1041 * record around for retention and the user can restore if the heuristic1042 * picked wrong. The `merged` flag goes back to decodePayload so the1043 * unlock path persists the consolidated shape immediately.1044 */1045function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1046  let merged = false;1047  const now = Date.now();10481049  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1050  const byRpId = new Map<string, VaultEntry[]>();1051  for (const e of candidates) {1052    const rpId = e.passkey!.rpId;1053    const list = byRpId.get(rpId);1054    if (list) list.push(e);1055    else byRpId.set(rpId, [e]);1056  }10571058  for (const [rpId, group] of byRpId) {1059    if (group.length === 0) continue;1060    group.sort((a, b) => {1061      const aT =1062        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1063      const bT =1064        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1065      return bT - aT;1066    });1067    const winner = group[0]!;10681069    // Look for a NON-passkey login entry matching this RP. Use the URL1070    // hostname rather than a substring search so a "login.foo.com" entry1071    // doesn't accidentally absorb a passkey for "foo.com".1072    const loginTarget = entries.find((e) => {1073      if (e.deletedAt || e.passkey || e.type !== "login") return false;1074      if (e.url && extractDomain(e.url) === rpId) return true;1075      for (const extra of e.additionalUrls ?? []) {1076        if (extractDomain(extra) === rpId) return true;1077      }1078      return false;1079    });10801081    if (loginTarget) {1082      loginTarget.passkey = winner.passkey;1083      // A passkey entry may also carry the site's TOTP seed. Preserve it when1084      // the passkey is consolidated onto an existing login instead of1085      // soft-deleting the only record that held the 2FA configuration.1086      if (!loginTarget.totp && winner.totp) {1087        loginTarget.totp = winner.totp;1088        loginTarget.totpAlgorithm = winner.totpAlgorithm;1089        loginTarget.totpDigits = winner.totpDigits;1090        loginTarget.totpPeriod = winner.totpPeriod;1091      }1092      loginTarget.modifiedAt = now;1093      for (const dup of group) {1094        if (!dup.deletedAt) {1095          dup.deletedAt = now;1096          dup.modifiedAt = now;1097        }1098      }1099      merged = true;1100    } else if (group.length > 1) {1101      for (let i = 1; i < group.length; i += 1) {1102        const dup = group[i]!;1103        if (!dup.deletedAt) {1104          dup.deletedAt = now;1105          dup.modifiedAt = now;1106        }1107      }1108      merged = true;1109    }1110  }11111112  return { merged };1113}11141115// Validates that a `StoredVault` record on disk has the field shape we expect1116// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only1117// authenticates the static VERSION_BYTE AAD, not the `version` field on the1118// outer record — a corrupted `version: 42` slot still decrypts cleanly. This1119// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a1120// history-restore path instead of falsely reporting "wrong password".1121function hasValidVaultSchema(v: unknown): v is StoredVault {1122  if (typeof v !== "object" || v === null) return false;1123  const r = v as Record<string, unknown>;1124  return (1125    r.version === 1 &&1126    typeof r.salt === "string" &&1127    typeof r.nonce === "string" &&1128    typeof r.ciphertext === "string" &&1129    typeof r.tag === "string"1130  );1131}11321133export type TryDecryptResult =1134  | { status: "ok"; payload: DecryptedPayload }1135  | { status: "wrong_key" }1136  | { status: "corrupted"; reason: string };11371138// Exported solely for `unlockFromCodex` below, which needs to verify a1139// supplied passphrase against a slotA blob *without* committing anything to1140// storage. Keep this internal otherwise — callers crossing the messages.ts1141// boundary should always go through `unlock` / `verifyMainPassword` so the1142// constant-time invariants in Projects.md §3.4 are not bypassed.1143export async function tryDecryptSlot(1144  vault: StoredVault,1145  key: Uint8Array,1146  aadTag?: string,1147): Promise<DecryptedPayload | null> {1148  const res = await tryDecryptSlotResult(vault, key, aadTag);1149  return res.status === "ok" ? res.payload : null;1150}11511152export async function tryDecryptSlotResult(1153  vault: StoredVault,1154  key: Uint8Array,1155  aadTag?: string,1156): Promise<TryDecryptResult> {1157  if (!hasValidVaultSchema(vault)) {1158    return { status: "corrupted", reason: "invalid_slot_schema" };1159  }1160  let plain: string;1161  try {1162    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1163  } catch {1164    return { status: "wrong_key" };1165  }1166  try {1167    const parsed = JSON.parse(plain) as unknown;1168    if (typeof parsed !== "object" || parsed === null) {1169      return { status: "corrupted", reason: "invalid_payload_shape" };1170    }1171    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1172      return { status: "corrupted", reason: "invalid_payload_shape" };1173    }1174    return { status: "ok", payload: decodePayload(plain) };1175  } catch {1176    return { status: "corrupted", reason: "invalid_payload_json" };1177  }1178}11791180// ---------------------------------------------------------------------------1181// Core operations1182// ---------------------------------------------------------------------------11831184function buildDomainIndexSync(all: VaultEntry[]): void {1185  domainIndex = new Map();11861187  for (const entry of all) {1188    if (entry.deletedAt) continue;1189    if (entry.type === "note" || entry.type === "card") continue;11901191    // An entry can match multiple sites via `additionalUrls`. Index each1192    // hostname so `queryDomain` returns the entry regardless of which alias1193    // the user is currently visiting. Dedupe per-entry so a single entry1194    // never appears twice in one bucket.1195    const domains = new Set<string>();1196    const primary = extractDomain(entry.url ?? "");1197    if (primary) domains.add(primary);1198    for (const extra of entry.additionalUrls ?? []) {1199      const d = extractDomain(extra);1200      if (d) domains.add(d);1201    }1202    if (domains.size === 0) continue;12031204    for (const domain of domains) {1205      const bucket = domainIndex.get(domain) ?? [];1206      bucket.push(entry);1207      domainIndex.set(domain, bucket);1208    }1209  }1210}12111212export async function createVault(password: string): Promise<void> {1213  const salt = generateSalt();1214  derivedKey = await deriveKey(password, salt);1215  entries = [];1216  tombstones = new Map();1217  vaultMode = "real";1218  duressConfigured = false;1219  buildDomainIndexSync(entries);12201221  // Generate and wrap the root key for recovery word escrow.1222  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1223    await generateAndWrapRootKey();1224  rootKey = unhex(rawRootKey);12251226  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1227  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");12281229  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate1230  // it without recovery words (H2/COR-006).1231  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12321233  const slotA: StoredVault = {1234    version: 1,1235    salt: hex(salt),1236    nonce,1237    ciphertext,1238    tag,1239    recovery_salt: recoverySalt,1240    recovery_wrapped_root_key: wrappedRootKey,1241    recovery_nonce: wrappedNonce,1242    recovery_tag: wrappedTag,1243    ...pwRoot,1244  };1245  await writeSlot(SLOT_A, slotA);12461247  // Matching-size noise blob for slot B, so a fresh install shows two1248  // similarly-shaped records regardless of whether duress is ever set up.1249  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1250  await stampLocalVaultMutation();12511252  touchAutoLock();1253}12541255/**1256 * Creates a local vault from an existing vault root key received through the1257 * SAS-gated adoption protocol. The password is local to this device; it wraps1258 * the adopted root key at rest and does not need to match the source device.1259 */1260export async function createVaultFromRootKey(1261  password: string,1262  rootKeyHex: string,1263): Promise<{ vaultId: string }> {1264  if (typeof password !== "string" || password.length === 0) {1265    throw new Error("ADOPT_INVALID_PASSWORD");1266  }1267  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1268    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1269  }12701271  const rootKeyBytes = unhex(rootKeyHex);1272  if (rootKeyBytes.length !== 32) {1273    rootKeyBytes.fill(0);1274    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1275  }12761277  let nextDerivedKey: Uint8Array | null = null;1278  let sessionRootKey: Uint8Array | null = null;1279  try {1280    if ((await loadSlot(SLOT_A)) !== null) {1281      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1282    }12831284    const nextEntries: VaultEntry[] = [];1285    const nextDuressConfigured = false;1286    const vaultId = await computeVaultId(rootKeyBytes);1287    const salt = generateSalt();1288    nextDerivedKey = await deriveKey(password, salt);1289    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);12901291    sessionRootKey = new Uint8Array(rootKeyBytes);1292    const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1293    const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1294    const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1295    const slotA: StoredVault = {1296      version: 1,1297      salt: hex(salt),1298      nonce,1299      ciphertext,1300      tag,1301      recovery_salt: recovery.recoverySalt,1302      recovery_wrapped_root_key: recovery.wrappedRootKey,1303      recovery_nonce: recovery.wrappedNonce,1304      recovery_tag: recovery.wrappedTag,1305      ...pwRoot,1306    };1307    const slotB = generateNoiseBlob(ciphertext.length / 2);1308    await commitCreatedVaultSlots(slotA, slotB);13091310    const previousDerivedKey = derivedKey;1311    const previousRootKey = rootKey;1312    derivedKey = nextDerivedKey;1313    rootKey = sessionRootKey;1314    nextDerivedKey = null;1315    sessionRootKey = null;1316    entries = nextEntries;1317    tombstones = new Map();1318    vaultMode = "real";1319    duressConfigured = nextDuressConfigured;1320    buildDomainIndexSync(entries);1321    if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1322    if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1323    touchAutoLock();13241325    return { vaultId };1326  } finally {1327    rootKeyBytes.fill(0);1328    if (nextDerivedKey) zeroKey(nextDerivedKey);1329    if (sessionRootKey) sessionRootKey.fill(0);1330  }1331}13321333export async function hasVault(): Promise<boolean> {1334  return (await loadSlot(SLOT_A)) !== null;1335}13361337/**1338 * Returns whether the real vault has recorded that a duress passphrase is1339 * configured. Reliable only after a real-mode unlock — before unlock or in1340 * decoy mode, returns false because we cannot read slot A's plaintext.1341 */1342export async function isDuressEnabled(): Promise<boolean> {1343  return vaultMode === "real" && duressConfigured;1344}13451346export async function setupDuress(duressPassword: string): Promise<void> {1347  if (!derivedKey || vaultMode !== "real") {1348    throw new Error("vault must be unlocked in real mode");1349  }1350  // Encrypt the decoy payload (empty entries) under the duress key. Pad to1351  // at least the current slot A ciphertext size so the two slots look the1352  // same at rest at setup time.1353  const slotA = await loadSlot(SLOT_A);1354  if (!slotA) throw new Error("missing slot A");13551356  const decoySalt = generateSalt();1357  const decoyKey = await deriveKey(duressPassword, decoySalt);1358  try {1359    // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1360    // same ceiling by the persistSlotA() call below (duressConfigured is now1361    // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1362    // lengths cannot reveal that duress is configured (audit MEDIUM).1363    const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1364    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1365    await writeSlot(SLOT_B, {1366      version: 1,1367      salt: hex(decoySalt),1368      nonce,1369      ciphertext,1370      tag,1371    });1372  } finally {1373    zeroKey(decoyKey);1374  }13751376  // Re-encrypt slot A with the duressConfigured flag set to true. No external1377  // flag is written — the bit lives inside the encrypted envelope.1378  duressConfigured = true;1379  await persistSlotA();1380}13811382export async function disableDuress(): Promise<void> {1383  if (!derivedKey || vaultMode !== "real") {1384    throw new Error("vault must be unlocked in real mode");1385  }1386  // Overwrite slot B with fresh noise that matches slot A's size. The slot1387  // remains occupied so its mere existence does not signal that duress was1388  // ever configured.1389  const slotA = await loadSlot(SLOT_A);1390  if (slotA) {1391    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1392  }13931394  duressConfigured = false;1395  await persistSlotA();1396}13971398// L14/COR-015: this function intentionally checks slot A (real vault) only.1399// A duress-mode user attempting changePassword is rejected earlier by the1400// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1401// distinction here is by design — duress users cannot rotate the real1402// password by guessing it, and the asymmetry in error timing is acceptable1403// because duress mode is opt-in and the user knows their own decoy key.1404export async function verifyMainPassword(password: string): Promise<boolean> {1405  const slotA = await loadSlot(SLOT_A);1406  if (!slotA) return false;1407  const key = await deriveKey(password, unhex(slotA.salt));1408  try {1409    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1410  } finally {1411    zeroKey(key);1412  }1413}14141415export async function unlock(password: string): Promise<boolean> {1416  return attemptUnlockWithDerive(password, deriveKey);1417}14181419async function attemptUnlockWithDerive(1420  password: string,1421  derive: typeof deriveKey,1422): Promise<boolean> {1423  const epoch = vaultEpoch;1424  const stale = () => epoch !== vaultEpoch;1425  // Load both current-format slots in parallel. Both exist on a supported1426  // install; the1427  // parallel load and the parallel derivation below keep wall-clock time1428  // constant regardless of which slot's passphrase (real or decoy) the1429  // caller supplied, so a timing side channel cannot distinguish the1430  // duress path from the real-unlock path (DUR-002).1431  const [slotA, slotB] = await Promise.all([1432    loadSlot(SLOT_A),1433    loadSlot(SLOT_B),1434  ]);14351436  // Derive both keys in parallel, unconditionally.1437  const [keyA, keyB] = await Promise.all([1438    slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1439    slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1440  ]);14411442  // Attempt both decrypts in parallel. The Result variant lets us distinguish1443  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1444  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1445  const [resultA, resultB] = await Promise.all([1446    keyA && slotA1447      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1448      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1449    keyB && slotB1450      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1451      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1452  ]);1453  const payloadA = resultA.status === "ok" ? resultA.payload : null;1454  const payloadB = resultB.status === "ok" ? resultB.payload : null;14551456  if (stale()) {1457    if (keyA) zeroKey(keyA);1458    if (keyB) zeroKey(keyB);1459    return false;1460  }14611462  if (payloadA && keyA) {1463    if (stale()) {1464      zeroKey(keyA);1465      if (keyB) zeroKey(keyB);1466      return false;1467    }1468    derivedKey = keyA;1469    vaultMode = "real";1470    entries = payloadA.entries;1471    tombstones = payloadA.tombstones;1472    duressConfigured = payloadA.duressConfigured;1473    buildDomainIndexSync(entries);1474    await loadSettings();1475    if (stale()) {1476      await lock();1477      return false;1478    }1479    touchAutoLock();1480    if (keyB) zeroKey(keyB);14811482    // Current vaults always carry a password-wrapped root key. Older vaults1483    // without this escrow are outside the supported storage format.1484    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1485    if (!rootKey) {1486      await lock();1487      return false;1488    }14891490    // PB-26-213: adopt legacy plaintext pair secrets into the encrypted1491    // record and refresh the trusted-session operational copy.1492    await reconcilePairSecrets(rootKey);1493    if (stale()) {1494      await lock();1495      return false;1496    }1497    await lockedSyncUnlockHook?.().catch((error) => {1498      console.warn("[Peach sync] locked inbox drain failed:", error);1499    });1500    if (stale()) {1501      await lock();1502      return false;1503    }1504    await resetPinFailedAttempts();1505    if (stale()) {1506      await lock();1507      return false;1508    }1509    await saveSessionKey(keyA, "real");1510    return true;1511  }15121513  if (payloadB && keyB) {1514    if (stale()) {1515      zeroKey(keyB);1516      if (keyA) zeroKey(keyA);1517      return false;1518    }1519    derivedKey = keyB;1520    vaultMode = "decoy";1521    vaultMode = "decoy";1522    entries = payloadB.entries;1523    tombstones = payloadB.tombstones;1524    duressConfigured = false;1525    buildDomainIndexSync(entries);1526    await loadSettings();1527    touchAutoLock();1528    if (keyA) zeroKey(keyA);1529    // PB-26-213: a duress session must never serve the real pair-secret map.1530    await dropSessionCache();1531    if (stale()) {1532      await lock();1533      return false;1534    }1535    await saveSessionKey(keyB, "decoy");1536    return true;1537  }15381539  if (keyA) zeroKey(keyA);1540  if (keyB) zeroKey(keyB);1541  // Surface corruption (good AES tag, broken schema) distinctly from1542  // wrong-password so the popup can offer history-restore (M7/COR-007).1543  if (resultA.status === "corrupted") {1544    lastUnlockFailureReason = resultA.reason;1545  } else if (resultB.status === "corrupted") {1546    lastUnlockFailureReason = resultB.reason;1547  }1548  return false;1549}15501551// ---------------------------------------------------------------------------1552// MV3 service worker session key — survives SW restarts within a browser session1553// ---------------------------------------------------------------------------15541555// chrome.storage.session is cleared on browser close; survives SW restart.1556// Firefox does not implement it (persistent background script, no restart risk).1557const SESSION_KEY_STORAGE = "peach_session_dk";15581559// PB-26-203: the session-key escrow may only operate where startup positively1560// established trusted-only session storage. Defaults to available so direct1561// vault imports (tests, non-entry bootstraps) keep working; entry.ts applies1562// the real hardening result before the first restore.1563let sessionEscrowAvailable = true;15641565/**1566 * Apply the storage-hardening result at startup. When the trusted-only1567 * session state cannot be established, any stored session key is removed and1568 * the escrow stays off: the user unlocks again after a worker restart1569 * instead of trusting an unproven boundary.1570 */1571export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1572  setSessionAllowed(sessionEscrow);1573  if (sessionEscrow) return;1574  sessionEscrowAvailable = false;1575  await clearSessionKey();1576}15771578async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1579  if (!chrome.storage.session || !sessionEscrowAvailable) return;1580  await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1581}15821583async function clearSessionKey(): Promise<void> {1584  if (!chrome.storage.session) return;1585  await chrome.storage.session.remove(SESSION_KEY_STORAGE);1586}15871588// A lock that only holds while the service worker stays alive is no lock at1589// all: a cold worker's restoreFromSession() would find the surviving session1590// key and silently re-unlock ~a minute after the user locked. So lock()1591// bumps this epoch synchronously at entry and zeroes keys/indexes before any1592// await; a restore that started earlier sees the mismatch after its next1593// await and aborts instead of publishing.1594let vaultEpoch = 0;15951596// One in-flight restore shared by concurrent callers: storage is read once1597// and the locked-sync unlock hook runs once.1598let restoreInFlight: Promise<boolean> | null = null;15991600/**1601 * On SW restart, re-derive all in-memory vault state from the session key1602 * stored in chrome.storage.session (set at unlock time, cleared by lock();1603 * also cleared on browser close or when restoration fails).1604 * Returns true if the vault was restored, false if no session key was found.1605 * No-ops if the vault is already unlocked.1606 */1607export async function restoreFromSession(): Promise<boolean> {1608  if (derivedKey !== null) return true;1609  if (restoreInFlight) return restoreInFlight;1610  const task = restoreFromSessionInner();1611  restoreInFlight = task;1612  try {1613    return await task;1614  } finally {1615    restoreInFlight = null;1616  }1617}16181619async function restoreFromSessionInner(): Promise<boolean> {1620  const epoch = vaultEpoch;1621  const stale = () => epoch !== vaultEpoch;1622  if (derivedKey !== null) return true;1623  if (!sessionEscrowAvailable) return false;1624  if (!chrome.storage.session) return false;1625  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1626  if (stale()) return false;1627  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1628  if (!entry?.keyHex) return false;16291630  const key = unhex(entry.keyHex);1631  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1632  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1633  const slot = await loadSlot(slotKey);1634  if (stale()) { zeroKey(key); return false; }1635  if (!slot) { await clearSessionKey(); return false; }16361637  const result = await tryDecryptSlotResult(slot, key, aadTag);1638  if (stale()) { zeroKey(key); return false; }1639  if (result.status !== "ok") { await clearSessionKey(); return false; }16401641  // Decrypted payload stays in locals until the final epoch-checked commit1642  // below; nothing observable is published before then.1643  const payload = result.payload;1644  await loadSettings();1645  if (stale()) { zeroKey(key); return false; }16461647  // Rehydrate the root key used by sync identity derivation from the password1648  // escrow stored on the current vault format.1649  let freshRoot: Uint8Array | null = null;1650  if (entry.mode === "real") {1651    freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1652    if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1653    if (!freshRoot) {1654      await lock();1655      return false;1656    }1657  }16581659  if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1660  if (freshRoot) {1661    // PB-26-213: encrypted pair-secret record + session copy, before publish.1662    await reconcilePairSecrets(freshRoot);1663    if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1664  } else {1665    // PB-26-213: a duress restore must never serve the real pair-secret map.1666    await dropSessionCache();1667    if (stale()) { zeroKey(key); return false; }1668  }1669  derivedKey = key;1670  vaultMode = entry.mode;1671  entries = payload.entries;1672  tombstones = payload.tombstones;1673  duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1674  if (freshRoot) rootKey = freshRoot;1675  buildDomainIndexSync(entries);1676  touchAutoLock();16771678  await lockedSyncUnlockHook?.().catch((error) => {1679    console.warn("[Peach sync] locked inbox drain failed:", error);1680  });1681  if (stale()) return false;16821683  return true;1684}16851686export async function lock(): Promise<void> {1687  // A user-initiated lock must be durable across MV3 service-worker restarts.1688  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1689  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1690  // If the session key survived lock(), that restore would silently re-unlock1691  // the vault ~a minute after the user locked it — a lock that only held1692  // while the SW happened to stay alive. The background-sync convenience the1693  // session key once bought (sync runner re-waking to a restored vault) is1694  // not worth an unlocked vault the user never asked to unlock; when locked,1695  // sync-runner correctly reports VAULT_LOCKED instead.1696  vaultEpoch++;1697  if (derivedKey) {1698    zeroKey(derivedKey);1699    derivedKey = null;1700  }1701  if (rootKey) {1702    rootKey.fill(0);1703    rootKey = null;1704  }17051706  entries = [];1707  tombstones = new Map();1708  domainIndex = new Map();1709  vaultMode = "real";1710  duressConfigured = false;1711  clearAutoLock();1712  await clearSessionKey();1713  await clearRecoveryConfirmPending();1714}17151716export function isUnlocked(): boolean {1717  return derivedKey !== null;1718}17191720// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1721// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1722// always derives from scratch via Argon2id.1723export async function unlockWithKey(1724  masterKeyHex: string,1725): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1726  const epoch = vaultEpoch;1727  const stale = () => epoch !== vaultEpoch;1728  let masterKeyBytes: Uint8Array | null = null;1729  try {1730    masterKeyBytes = unhex(masterKeyHex);1731    const stored = await loadSlot(SLOT_A);1732    if (!stored) return { ok: false, error: "invalid_key" };1733    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1734    if (!payload || stale()) {1735      zeroKey(masterKeyBytes);1736      return { ok: false, error: "invalid_key" };1737    }17381739    derivedKey = masterKeyBytes;1740    vaultMode = "real";1741    entries = payload.entries;1742    tombstones = payload.tombstones;1743    duressConfigured = payload.duressConfigured;1744    buildDomainIndexSync(entries);1745    await loadSettings();1746    touchAutoLock();17471748    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1749    if (!rootKey) {1750      await lock();1751      return { ok: false, error: "invalid_key" };1752    }1753    await reconcilePairSecrets(rootKey);1754    if (stale()) {1755      await lock();1756      return { ok: false, error: "invalid_key" };1757    }1758    await saveSessionKey(masterKeyBytes, "real");1759    return { ok: true, entries };1760  } catch {1761    if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1762    return { ok: false, error: "invalid_key" };1763  }1764}17651766// --- Adoption duress-slot helpers (passthrough, no decrypt) ---17671768/**1769 * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1770 * duress is configured and slot B exists, so the inline snapshot can ship it1771 * to the receiver. Returns null otherwise. Never decrypts.1772 */1773export async function exportDuressSlotForAdoption(): Promise<string | null> {1774  if (!(await isDuressEnabled())) return null;1775  const slotB = await loadSlot(SLOT_B);1776  if (!slotB) return null;1777  return JSON.stringify(slotB);1778}17791780/**1781 * Adoption RECEIVER helper: installs a duress slot received from a source1782 * device. Validates the StoredVault shape (same guard as unlockFromCodex)1783 * and writes it to slot B. Throws on invalid shape so the caller can treat1784 * it as a non-fatal snapshot failure. Never decrypts.1785 */1786export async function installAdoptedDuressSlot(json: string): Promise<void> {1787  let parsed: StoredVault;1788  try {1789    const raw = JSON.parse(json) as unknown;1790    if (!hasValidVaultSchema(raw)) {1791      throw new Error("invalid_duress_slot_shape");1792    }1793    parsed = raw;1794  } catch (err) {1795    if (err instanceof SyntaxError) {1796      throw new Error("invalid_duress_slot_shape");1797    }1798    throw err;1799  }1800  await writeSlot(SLOT_B, parsed);1801}18021803/**1804 * Restore the vault from a parsed Peach Codex payload.1805 *1806 * The supplied passphrase is verified against the Codex's slotA *before* any1807 * storage mutation — wrong passphrases return without touching disk. On1808 * success, the current vault state is snapshotted (reason: "restore") so the1809 * user can revert via Settings -> Vault History, then slotA/slotB are written1810 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A1811 * regular `unlock()` follows so the rest of the SW sees the standard1812 * post-unlock state.1813 *1814 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex1815 * printed by a Pro user must always be restorable, even on a free device.1816 *1817 * Timing is not held constant here (in contrast to `unlock`): the Codex flow1818 * is an explicit user action with a known passphrase, not a passive lock1819 * screen, so the duress-vs-real timing side channel does not apply.1820 */1821export async function unlockFromCodex(params: {1822  slotA: string; // JSON string of StoredVault for slot A1823  slotB: string | null; // JSON string of StoredVault for slot B, or null1824  timestamp: number;1825  masterPassphrase: string;1826}): Promise<1827  | { ok: true; entryCount: number }1828  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1829> {1830  let parsedSlotA: StoredVault;1831  let parsedSlotB: StoredVault | null = null;1832  try {1833    const rawA = JSON.parse(params.slotA) as unknown;1834    if (!hasValidVaultSchema(rawA)) {1835      return { ok: false, reason: "invalid_slot_format" };1836    }1837    parsedSlotA = rawA;1838    if (params.slotB !== null) {1839      const rawB = JSON.parse(params.slotB) as unknown;1840      if (!hasValidVaultSchema(rawB)) {1841        return { ok: false, reason: "invalid_slot_format" };1842      }1843      parsedSlotB = rawB;1844    }1845  } catch {1846    return { ok: false, reason: "invalid_slot_format" };1847  }18481849  // Verify the passphrase against the supplied slotA WITHOUT writing anything.1850  // If this fails the on-disk vault is left completely untouched.1851  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1852  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1853  if (!trialPayload) {1854    zeroKey(trialKey);1855    return { ok: false, reason: "wrong_passphrase" };1856  }1857  // Drop the trial key; `unlock()` below derives its own copy. Holding two1858  // copies of the master key in memory at once would be a needless surface.1859  zeroKey(trialKey);18601861  // Snapshot the about-to-be-overwritten on-disk state so the user can revert1862  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.1863  await takeVaultSnapshot("restore");18641865  await writeSlot(SLOT_A, parsedSlotA);1866  if (parsedSlotB) {1867    await writeSlot(SLOT_B, parsedSlotB);1868  } else {1869    // Slot B must always exist with ciphertext length matching slot A1870    // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't1871    // configured); regenerate noise to satisfy the invariant.1872    await writeSlot(1873      SLOT_B,1874      generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1875    );1876  }1877  await chrome.storage.local.set({1878    peach_last_modified_at: String(params.timestamp),1879  });18801881  // Standard unlock path so domain index, settings, auto-lock, and1882  // recovery-key state all populate exactly as a normal unlock would.1883  const ok = await unlock(params.masterPassphrase);1884  if (!ok) {1885    // Should be unreachable — we just verified the passphrase against the1886    // same slotA we wrote. Treat as wrong_passphrase to keep the contract1887    // simple if some race or storage anomaly intrudes.1888    return { ok: false, reason: "wrong_passphrase" };1889  }1890  return { ok: true, entryCount: getEntries().length };1891}18921893// Returns the current in-memory derived key as a hex string. Only the PIN1894// unlock path needs this value, and it reads module state directly; this1895// accessor exists so unlockWithKey's root-key rehydration is testable.1896export /* @testable */ function getDerivedKeyHex(): string {1897  if (!derivedKey) throw new Error("vault_not_unlocked");1898  return hex(derivedKey);1899}19001901/**1902 * Returns the vault's stable root key as hex, or null when locked / not yet1903 * populated. The root key is random per-vault, generated at creation, escrowed1904 * under the recovery words, and re-wrapped (not regenerated) on every password1905 * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.1906 *1907 * This is the correct input for sync identity derivation (vault_id, the Noise1908 * NK static key, the Ed25519 manifest signing key, and per-entry envelope1909 * keys): paired devices keep the same identity and can still decrypt each1910 * other's entries after either side rotates its master password (SEV-2-B).1911 *1912 * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED1913 * instead of crashing; rootKey is populated on every supported real-mode1914 * unlock via deriveRootKeyFromDerivedKey.1915 */1916export function getRootKeyHex(): string | null {1917  return rootKey ? hex(rootKey) : null;1918}19191920export function getEntries(): VaultEntry[] {1921  if (!derivedKey) throw new Error("vault_locked");1922  return entries.filter((entry) => !entry.deletedAt);1923}19241925const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;19261927export function getIdentities(): SavedIdentity[] {1928  if (!derivedKey) throw new Error("vault_locked");1929  const active = entries.filter((e) => !e.deletedAt && e.type === "login");19301931  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1932  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();19331934  for (const entry of active) {1935    const domain = extractDomain(entry.url ?? "") ?? "";1936    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;19371938    function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1939      const existing = map.get(v);1940      if (!existing || modifiedAt > existing.lastUsed) {1941        map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1942      } else {1943        map.set(v, { ...existing, count: existing.count + 1 });1944      }1945    }19461947    const uVal = entry.username?.trim();1948    if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);19491950    // Also count the dedicated email field — entries created via the inline1951    // popup new-entry form store email here rather than in username.1952    const eVal = entry.email?.trim();1953    if (eVal) tally(emailMap, eVal);1954  }19551956  const toIdentity = (type: "email" | "username") =>1957    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1958      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1959    });19601961  return [1962    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1963    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1964  ];1965}19661967export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1968  if (!derivedKey) throw new Error("vault_locked");19691970  const now = Date.now();1971  const added: VaultEntry[] = [];1972  for (const draft of drafts) {1973    try {1974      const sanitized = sanitizeEntryDraft(draft);1975      const nextEntry: VaultEntry = {1976        ...sanitized,1977        id: crypto.randomUUID(),1978        createdAt: now,1979        modifiedAt: monotonicEntryTimestamp(),1980      };1981      entries.push(nextEntry);1982      added.push(nextEntry);1983    } catch {1984      // Skip invalid drafts silently — caller can see diff between input and output length1985    }1986  }19871988  if (added.length > 0) {1989    await persistEntries();1990  }19911992  return added;1993}19941995/**1996 * Restore a set of entries in full fidelity — preserves id, createdAt,1997 * modifiedAt, and every other field unlike `batchAddEntries` which1998 * assigns fresh identity. Used by restore paths where the backup is the1999 * authoritative source of truth. `mode` controls how entries already in2000 * the vault are treated.2001 */2002export async function restoreEntries(2003  incoming: VaultEntry[],2004  mode: "replace" | "merge"2005): Promise<{ added: number; updated: number; skipped: number }> {2006  if (!derivedKey) throw new Error("vault_locked");20072008  // A replace with zero incoming entries is almost certainly a caller bug2009  // (e.g. an import that silently dropped everything). Refuse rather than2010  // wipe the vault.2011  if (mode === "replace" && incoming.length === 0) {2012    throw new Error("refuse to replace with empty entry set");2013  }20142015  let added = 0;2016  let updated = 0;2017  let skipped = 0;20182019  if (mode === "replace") {2020    entries = [];2021    for (const entry of incoming) {2022      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2023      if (!normalized) {2024        skipped += 1;2025        continue;2026      }2027      entries.push(normalized);2028      if (normalized.deletedAt) {2029        tombstones.set(2030          normalized.id,2031          Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2032        );2033      } else {2034        tombstones.delete(normalized.id);2035      }2036      added += 1;2037    }2038  } else {2039    const byId = new Map<string, VaultEntry>();2040    for (const e of entries) byId.set(e.id, e);20412042    for (const entry of incoming) {2043      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2044      if (!normalized) {2045        skipped += 1;2046        continue;2047      }2048      const existing = byId.get(normalized.id);2049      if (!existing) {2050        entries.push(normalized);2051        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2052        else tombstones.delete(normalized.id);2053        added += 1;2054      } else if (normalized.modifiedAt > existing.modifiedAt) {2055        const idx = entries.findIndex((e) => e.id === existing.id);2056        if (idx !== -1) {2057          entries[idx] = normalized;2058          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2059          else tombstones.delete(normalized.id);2060          updated += 1;2061        }2062      } else {2063        skipped += 1;2064      }2065    }2066  }20672068  await persistEntries("import");2069  return { added, updated, skipped };2070}20712072/**2073 * A Peach entry id is a UUID. A restored or imported file carries whatever id2074 * its author used, and no reader bounds it, so one oversized id would ride2075 * into every future sync frame and be rejected there — a permanent, opaque2076 * failure on both platforms, caused by data that was already local.2077 *2078 * The action is mint, not reject: this is the last point where an id is still2079 * ours to choose, so the worst outcome is one duplicate if the same vault is2080 * later restored beside a device still holding the old id. Rejecting would2081 * lose the user's import.2082 *2083 * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries2084 * also calls. Sync has to adopt a peer's id verbatim — that is the only thing2085 * keeping two devices agreed on which entry they are discussing — so reminting2086 * there would fork identity on every device, on every round.2087 */2088const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;20892090function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2091  if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2092  return { ...entry, id: crypto.randomUUID() };2093}20942095/**2096 * Validate + normalise a VaultEntry coming from a backup, import, or sync2097 * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies2098 * only known VaultEntry fields (unknown/attacker keys are dropped), and2099 * nested `passkey`/`canary` shapes are rebuilt field-by-field, never2100 * spread. Returns null for values the live vault shouldn't accept.2101 */2102function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2103  if (!entry || typeof entry !== "object") return null;2104  const title = typeof entry.title === "string" ? entry.title.trim() : "";2105  if (!title) return null;2106  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2107  const now = Date.now();2108  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2109  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;21102111  // normalizeEntry whitelists every known VaultEntry field before the entry2112  // reaches the live vault.2113  const normalized = normalizeEntry({2114    ...entry,2115    id,2116    title,2117    type: entry.type ?? "login",2118    createdAt,2119    modifiedAt,2120  });21212122  if (normalized.passkey) {2123    normalized.passkey = sanitizePasskey(normalized.passkey);2124  }2125  if (normalized.canary) {2126    normalized.canary = sanitizeCanary(normalized.canary);2127  }2128  // PCH-01-016: bound tags on the restore path too, so both import funnels agree.2129  normalized.tags = sanitizeTags(normalized.tags);21302131  // Login entries from backup must carry a password (or be passkey-only,2132  // in which case the passkey sub-object replaces the password requirement).2133  if (2134    normalized.type === "login" &&2135    !normalized.passkey &&2136    typeof normalized.password !== "string"2137  ) {2138    return null;2139  }2140  if (entry.passkey && !normalized.passkey) {2141    // Entry claimed a passkey but the shape didn't validate — refuse the2142    // whole entry rather than silently importing a credential-less husk.2143    return null;2144  }21452146  return normalized;2147}21482149/**2150 * Rebuild a PasskeyData object from untrusted input, field by field.2151 * Returns undefined when required fields are missing/mistyped — a rogue2152 * passkey credential must not activate via import (MV-6 attack scenario).2153 */2154function sanitizePasskey(pk: unknown): PasskeyData | undefined {2155  if (!pk || typeof pk !== "object") return undefined;2156  const r = pk as Record<string, unknown>;2157  if (2158    typeof r.rpId !== "string" || !r.rpId ||2159    typeof r.credentialId !== "string" || !r.credentialId ||2160    typeof r.publicKeyCose !== "string" ||2161    typeof r.userHandle !== "string" ||2162    typeof r.userName !== "string"2163  ) {2164    return undefined;2165  }2166  return {2167    rpId: r.rpId,2168    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2169    credentialId: r.credentialId,2170    publicKeyCose: r.publicKeyCose,2171    privateKeyJwk:2172      r.privateKeyJwk && typeof r.privateKeyJwk === "object"2173        ? (r.privateKeyJwk as JsonWebKey)2174        : undefined,2175    userHandle: r.userHandle,2176    userName: r.userName,2177    userDisplayName:2178      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2179    algorithm: r.algorithm === -8 ? -8 : -7,2180    signCount: typeof r.signCount === "number" ? r.signCount : 0,2181    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2182    backupEligible: r.backupEligible === true,2183    backupState: r.backupState === true,2184    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2185    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2186    transports: Array.isArray(r.transports)2187      ? r.transports.filter((t): t is string => typeof t === "string")2188      : undefined,2189    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2190  };2191}21922193/**2194 * Rebuild a Canary object from untrusted input. The webhook must be a2195 * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing2196 * exfiltration endpoint that the canary subsystem will POST entry titles2197 * to (MV-6 attack scenario). Invalid → the canary is dropped, not the2198 * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire2199 * time (isAllowedCanaryWebhookUrl), so this import-time check is the2200 * outer of two gates.2201 */2202function sanitizeCanary(c: unknown): Canary | undefined {2203  if (!c || typeof c !== "object") return undefined;2204  const r = c as Record<string, unknown>;2205  if (typeof r.webhookUrl !== "string") return undefined;2206  let url: URL;2207  try {2208    url = new URL(r.webhookUrl);2209  } catch {2210    return undefined;2211  }2212  if (url.protocol !== "https:") return undefined;2213  return {2214    webhookUrl: url.toString(),2215    label: typeof r.label === "string" ? r.label : undefined,2216    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2217    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2218  };2219}22202221/**2222 * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted.2223 * Keep only non-empty strings, cap each at 256 chars, and cap the count at 2552224 * (the same per-entry ceiling the export codec enforces, peachPayload.ts).2225 * Non-array or all-invalid input collapses to undefined.2226 */2227function sanitizeTags(tags: unknown): string[] | undefined {2228  if (!Array.isArray(tags)) return undefined;2229  const out: string[] = [];2230  for (const t of tags) {2231    if (typeof t !== "string") continue;2232    const trimmed = t.trim();2233    if (!trimmed) continue;2234    out.push(trimmed.slice(0, 256));2235    if (out.length >= 255) break;2236  }2237  return out.length > 0 ? out : undefined;2238}22392240// Soft-deletes every non-deleted entry in one call. The literal string2241// argument is a tripwire against accidental calls — any caller that would2242// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this2243// to a boolean.2244export async function clearVaultEntries(2245  confirmation: "REPLACE_ALL"2246): Promise<void> {2247  if (confirmation !== "REPLACE_ALL") {2248    throw new Error("clearVaultEntries requires explicit confirmation");2249  }2250  if (!derivedKey) throw new Error("vault_locked");2251  for (const entry of entries) {2252    if (!entry.deletedAt) {2253      const stamp = monotonicEntryTimestamp(Math.max(2254        entry.modifiedAt,2255        tombstones.get(entry.id) ?? 0,2256      ));2257      entry.deletedAt = stamp;2258      entry.modifiedAt = stamp;2259      tombstones.set(entry.id, stamp);2260    }2261  }2262  await persistEntries();2263}22642265export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2266  if (!derivedKey) throw new Error("vault_locked");22672268  const sanitized = sanitizeEntryDraft(entry);2269  const now = Date.now();2270  const stamp = monotonicEntryTimestamp();2271  const nextEntry: VaultEntry = {2272    ...sanitized,2273    id: crypto.randomUUID(),2274    createdAt: now,2275    modifiedAt: stamp,2276    // Stamp passwordChangedAt at creation so age tracking is accurate from2277    // day one. Non-login entries carry the same stamp harmlessly.2278    passwordChangedAt: sanitized.password ? stamp : undefined,2279  };22802281  // Clone-and-replace so a failed persist rolls back the in-memory state2282  // (COR-008). Without this, a quota-full or storage error leaves the2283  // module-level `entries` ahead of what's actually on disk.2284  const rollback = entries;2285  entries = [...entries, nextEntry];2286  try {2287    await persistEntries();2288  } catch (err) {2289    entries = rollback;2290    throw err;2291  }22922293  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)2294  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2295    checkBreach(nextEntry.password).then(async (count) => {2296      if (count > 0) {2297        const idx = entries.findIndex((e) => e.id === nextEntry.id);2298        if (idx !== -1) {2299          entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2300          await persistEntries();2301        }2302      }2303    });2304  }23052306  fireSyncTrigger("vault_change");23072308  return nextEntry;2309}23102311/**2312 * Run the passkey-merge pass on the live in-memory entries and persist if2313 * anything changed. Called immediately after any passkey registration so that2314 * a same-domain TOTP-only entry already in the vault gets consolidated right2315 * away instead of waiting until the next lock/unlock cycle.2316 */2317export async function consolidatePasskeyEntries(): Promise<void> {2318  if (!derivedKey) return;2319  const { merged } = mergePasskeyOnlyIntoLogins(entries);2320  if (merged) {2321    await persistEntries();2322  }2323}23242325export async function updateEntry(2326  id: string,2327  patch: Partial<VaultEntry>2328): Promise<void> {2329  if (!derivedKey) throw new Error("vault_locked");23302331  const idx = entries.findIndex((entry) => entry.id === id);2332  if (idx === -1 || entries[idx].deletedAt) {2333    throw new Error("entry_not_found");2334  }23352336  const current = entries[idx];2337  const sanitizedPatch = sanitizeEntryPatch(patch);2338  // Refresh passwordChangedAt only when the password value actually changes —2339  // a metadata-only update (title, tags, etc.) must not reset the age clock2340  // that drives the Pro health rotation recommendation.2341  const passwordChanged =2342    "password" in patch &&2343    typeof sanitizedPatch.password === "string" &&2344    sanitizedPatch.password !== current.password;2345  const stamp = monotonicEntryTimestamp(current.modifiedAt);2346  // When the password actually changes, push the outgoing one onto the history2347  // (newest first) so the user can reveal/copy/restore it later. We record the2348  // age it carried (passwordChangedAt, falling back to createdAt) rather than2349  // `now`, so the list reflects when each password was last in effect. Only2350  // capture a non-empty prior value — clearing a password leaves no secret to2351  // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)2352  // to avoid noise.2353  const priorPassword = current.password;2354  const shouldCapture =2355    passwordChanged &&2356    typeof priorPassword === "string" &&2357    priorPassword.length > 0 &&2358    current.passwordHistory?.[0]?.password !== priorPassword;2359  const nextHistory = shouldCapture2360    ? [2361        { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2362        ...(current.passwordHistory ?? []),2363      ].slice(0, PASSWORD_HISTORY_MAX)2364    : current.passwordHistory;2365  entries[idx] = {2366    ...current,2367    ...sanitizedPatch,2368    modifiedAt: stamp,2369    ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2370  };2371237223732374  // Card entries must retain their card number2375  if (entries[idx].type === "card" && !entries[idx].cardNumber) {2376    entries[idx] = current;2377    throw new Error("card number is required for card entries");2378  }23792380  // Roll the in-memory entry back to its pre-edit state if the disk write2381  // fails (e.g. storage quota). Without this the module-level `entries` array2382  // would hold an edit that never landed on disk, diverging from the vault2383  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.2384  try {2385    await persistEntries();2386  } catch (err) {2387    entries[idx] = current;2388    throw err;2389  }23902391  // Breach check when password changes: clear immediately so old breach doesn't linger,2392  // then async-check the new password and only re-set if it's also found.2393  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2394    entries[idx] = { ...entries[idx], breach: undefined };2395    await persistEntries();2396    const password = entries[idx].password;2397    if (password) {2398      checkBreach(password).then(async (count) => {2399        const i = entries.findIndex((e) => e.id === id);2400        if (i !== -1 && count > 0) {2401          entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2402          await persistEntries();2403        }2404      });2405    }2406  }24072408  fireSyncTrigger("vault_change");2409}24102411export async function deleteEntry(id: string): Promise<void> {2412  if (!derivedKey) throw new Error("vault_locked");24132414  const entry = entries.find((item) => item.id === id);2415  if (!entry || entry.deletedAt) {2416    throw new Error("entry_not_found");2417  }24182419  // Tombstone timestamp must be strictly greater than the entry's last2420  // modification so the deletion wins LWW against any in-flight edit, even2421  // under clock skew (SEV-2-E).2422  const previousModifiedAt = entry.modifiedAt;2423  const previousTombstone = tombstones.get(id);2424  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2425  entry.deletedAt = stamp;2426  entry.modifiedAt = stamp;2427  tombstones.set(id, stamp);2428  // Roll the tombstone back if the disk write fails, so a quota error doesn't2429  // leave the entry deleted in memory but alive on disk (SEV-3-A).2430  try {2431    await persistEntries();2432  } catch (err) {2433    delete entry.deletedAt;2434    entry.modifiedAt = previousModifiedAt;2435    if (previousTombstone === undefined) tombstones.delete(id);2436    else tombstones.set(id, previousTombstone);2437    throw err;2438  }2439  fireSyncTrigger("vault_change");2440}24412442export function getDeletedEntries(): VaultEntry[] {2443  if (!derivedKey) return [];2444  return entries.filter((e) => !!e.deletedAt);2445}24462447/** Return all durable deletion timestamps, including purged full entries. */2448export function getTombstoneManifest(): Record<string, number> {2449  if (!derivedKey) return {};2450  return Object.fromEntries(tombstones);2451}24522453/**2454 * Returns the vault's monotonic version — the `peach_last_modified_at` value2455 * stamped on every user-initiated write. Remote (sync) writes do NOT advance2456 * this version so a sync round never cascades into a second outbound sync.2457 */2458export async function getVaultVersion(): Promise<number> {2459  try {2460    const raw = await chrome.storage.local.get("peach_last_modified_at");2461    const v = (raw as Record<string, unknown>).peach_last_modified_at;2462    if (typeof v === "number") return v;2463    if (typeof v === "string" && v !== "") {2464      const n = Number.parseInt(v, 10);2465      return Number.isFinite(n) ? n : 0;2466    }2467    return 0;2468  } catch {2469    return 0;2470  }2471}24722473/**2474 * Apply a batch of remote entries + tombstones from a sync peer.2475 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when2476 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and2477 * persists with reason `sync_received` — does NOT fire the sync dispatcher, so2478 * applying a sync round cannot cascade into another outbound sync.2479 *2480 * `applied` counts entries that actually changed local state (new add,2481 * overwrite-by-newer, or new tombstone). `received` is the raw payload size2482 * the caller can surface in the sync result UI.2483 */2484export async function applyMergedRemoteEntries(2485  remoteEntries: VaultEntry[],2486  remoteTombstones: Record<string, number>,2487  _context: { deviceName?: string } = {},2488): Promise<{ received: number; applied: number; conflicts: number }> {2489  // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices2490  // concurrently edit different fields of the same entry, the one with the2491  // newer modifiedAt wins the whole entry and the other device's field edits2492  // are dropped silently. This is the deliberate trade-off for a simple,2493  // conflict-free merge — per-field CRDT merge is out of scope. Guidance for2494  // users: edit a given entry on one device at a time.2495  if (!derivedKey) throw new Error("vault_locked");24962497  const received = remoteEntries.length + Object.keys(remoteTombstones).length;2498  let applied = 0;2499  let conflicts = 0;25002501  const byId = new Map<string, VaultEntry>();2502  for (const e of entries) byId.set(e.id, e);25032504  const mergeNow = Date.now();2505  const remoteTombstoneTimes = new Map<string, number>();2506  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2507    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2508      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2509    }2510  }25112512  for (const remote of remoteEntries) {2513    const sanitized = sanitizeRestoredEntry(remote);2514    if (!sanitized) continue;2515    // HV-1a: a far-future remote modifiedAt must not permanently pin a2516    // malicious value against honest local edits. Clamp BEFORE any2517    // comparison against local timestamps.2518    sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2519    if (typeof sanitized.deletedAt === "number") {2520      sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2521    }2522    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2523    const localTombstoneTs = tombstones.get(sanitized.id);2524    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2525    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2526      continue;2527    }2528    const local = byId.get(sanitized.id);2529    if (!local) {2530      entries.push(sanitized);2531      byId.set(sanitized.id, sanitized);2532      tombstones.delete(sanitized.id);2533      applied += 1;2534    } else if (sanitized.modifiedAt > local.modifiedAt) {2535      const idx = entries.findIndex((e) => e.id === local.id);2536      if (idx !== -1) {2537        entries[idx] = sanitized;2538        byId.set(sanitized.id, sanitized);2539        tombstones.delete(sanitized.id);2540        applied += 1;2541        if (!local.deletedAt) conflicts += 1;2542      }2543    }2544  }25452546  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2547    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2548    // HV-1a/1c: clamp the tombstone timestamp before every comparison.2549    const deletedAt = remoteTombstoneTimes.get(id)!;2550    const knownTombstone = tombstones.get(id);2551    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2552    const local = byId.get(id);2553    if (!local) {2554      tombstones.set(id, deletedAt);2555      applied += 1;2556      continue;2557    }2558    if (local.deletedAt && local.deletedAt >= deletedAt) continue;2559    if (deletedAt < local.modifiedAt) continue;2560    const idx = entries.findIndex((e) => e.id === id);2561    if (idx !== -1) {2562      entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2563      tombstones.set(id, deletedAt);2564      applied += 1;2565    }2566  }25672568  if (applied > 0) {2569    await persistEntries("sync_received");2570    buildDomainIndexSync(entries);2571  }2572  return { received, applied, conflicts };2573}25742575export async function restoreEntry(id: string): Promise<void> {2576  if (!derivedKey) throw new Error("vault_locked");2577  const entry = entries.find((e) => e.id === id && e.deletedAt);2578  if (!entry) throw new Error("deleted entry not found");2579  const previousDeletedAt = entry.deletedAt;2580  const previousModifiedAt = entry.modifiedAt;2581  const previousTombstone = tombstones.get(id);2582  delete entry.deletedAt;2583  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2584    entry.modifiedAt,2585    previousTombstone ?? 0,2586  ));2587  tombstones.delete(id);2588  try {2589    await persistEntries();2590  } catch (err) {2591    entry.deletedAt = previousDeletedAt;2592    entry.modifiedAt = previousModifiedAt;2593    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2594    throw err;2595  }2596  buildDomainIndexSync(entries);2597}25982599export async function purgeEntry(id: string): Promise<void> {2600  if (!derivedKey) throw new Error("vault_locked");2601  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2602  if (idx === -1) throw new Error("deleted entry not found");2603  const entry = entries[idx]!;2604  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2605  entries.splice(idx, 1);2606  await persistEntries();2607}26082609/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */2610export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2611  if (!derivedKey || retentionMs === 0) return 0;2612  const cutoff = Date.now() - retentionMs;2613  const before = entries.length;2614  for (const entry of entries) {2615    if (entry.deletedAt && entry.deletedAt < cutoff) {2616      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2617    }2618  }2619  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2620  const purged = before - entries.length;2621  if (purged > 0) await persistEntries();2622  return purged;2623}26242625export function queryDomain(domain: string): VaultEntry[] {2626  return domainIndex.get(domain) ?? [];2627}26282629export function getEntryById(id: string): VaultEntry | undefined {2630  return entries.find((entry) => entry.id === id && !entry.deletedAt);2631}26322633async function persistSlotA(2634  reason: VaultSnapshot["reason"] = "local_save",2635): Promise<StoredVault> {2636  if (!derivedKey) throw new Error("vault_locked");26372638  const slotA = await loadSlot(SLOT_A);2639  if (!slotA) throw new Error("missing slot A");26402641  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures2642  // are swallowed by takeVaultSnapshot — history must never block a save.2643  await takeVaultSnapshot(reason);26442645  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2646  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");26472648  // Preserve existing recovery escrow fields.2649  const nextSlotA: StoredVault = {2650    ...slotA,2651    nonce,2652    ciphertext,2653    tag,2654    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot2655  };2656  await writeSlot(SLOT_A, nextSlotA);26572658  // Slot A (the real vault) is now committed. Wrap every later write so a2659  // mid-persist failure (e.g. a storage error after slot A landed) restores the2660  // prior slot A before rethrowing. Without this, a "failed" save that already2661  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/2662  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces2663  // as a duplicate on the next unlock (observed under storage-quota pressure).2664  try {2665    // When duress is not configured, keep slot B size-matched with slot A by2666    // regenerating its noise. When duress IS configured, leave slot B alone —2667    // we don't have the decoy key to re-encrypt it. This is a known trade-off.2668    if (!duressConfigured) {2669      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2670    }2671    // Only advance the vault version for locally-originated writes. Entries2672    // applied from a sync peer ("sync_received") must NOT bump the counter —2673    // otherwise applying a remote round makes us look newer than the peer and2674    // triggers a redundant outbound sync, defeating the no-op optimisation2675    // and risking a ping-pong between two devices (SEV-2-C).2676    if (reason !== "sync_received") {2677      await stampLocalVaultMutation();2678    }2679  } catch (err) {2680    // Best-effort restore — the prior slot A is smaller-or-equal, so this write2681    // fits even when the failure was a quota overflow.2682    await writeSlot(SLOT_A, slotA).catch(() => {});2683    throw err;2684  }26852686  return nextSlotA;2687}26882689async function persistSlotB(): Promise<void> {2690  if (!derivedKey) throw new Error("vault_locked");26912692  const slotB = await loadSlot(SLOT_B);2693  if (!slotB) throw new Error("missing slot B");26942695  // Decoy plaintext always reports duressConfigured=false — the decoy user2696  // sees a clean slate. persistSlotB only runs in decoy mode, which only2697  // exists once duress is configured, so pad to the fixed duress ceiling to2698  // keep slot B's length equal to slot A's (audit MEDIUM — deniability).2699  const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2700  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");27012702  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2703  // Symmetric with persistSlotA: if the version stamp fails after the decoy2704  // slot committed, restore the prior slot B so a failed decoy-mode save can't2705  // leave disk ahead of the in-memory rollback.2706  try {2707    await stampLocalVaultMutation();2708  } catch (err) {2709    await writeSlot(SLOT_B, slotB).catch(() => {});2710    throw err;2711  }2712}27132714async function persistEntries(2715  reason: VaultSnapshot["reason"] = "local_save",2716): Promise<void> {2717  if (!derivedKey) throw new Error("vault_locked");27182719  // Reconcile direct soft-delete callers while restoring the previous map if2720  // storage rejects the save.2721  const previousTombstones = tombstones;2722  tombstones = new Map(tombstones);2723  reconcileTombstones();2724  try {2725    if (vaultMode === "decoy") {2726      await persistSlotB();2727    } else {2728      await persistSlotA(reason);2729    }2730  } catch (err) {2731    tombstones = previousTombstones;2732    throw err;2733  }2734  buildDomainIndexSync(entries);2735  await refreshLockedSyncState();2736}27372738// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage2739// boundary. Schemeless values (e.g. "github.com") are inert and kept — they2740// pre-date this guard in existing data. Unparseable values are kept: the2741// reviewed render paths never execute them (favicons via _favicon/, domains2742// via tldts); this is defense-in-depth against future sinks.2743function allowHttpUrl(url: string | undefined): string {2744  const trimmed = url?.trim() ?? "";2745  if (!trimmed) return "";2746  try {2747    const parsed = new URL(trimmed);2748    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2749  } catch {2750    // No scheme to reject.2751  }2752  return trimmed;2753}27542755function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2756  if (!Array.isArray(values)) return undefined;2757  const seen = new Set<string>();2758  const cleaned: string[] = [];2759  for (const value of values) {2760    const trimmed = typeof value === "string" ? value.trim() : "";2761    if (!trimmed) continue;2762    if (!allowHttpUrl(trimmed)) continue;2763    const key = trimmed.toLowerCase();2764    if (seen.has(key)) continue;2765    seen.add(key);2766    cleaned.push(trimmed);2767  }2768  return cleaned.length > 0 ? cleaned : undefined;2769}27702771function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2772  const title = entry.title.trim();2773  const type: VaultEntry["type"] = entry.type ?? "login";27742775  if (!title) throw new Error("title is required");27762777  if (entry.passkey) {2778    const p = entry.passkey;2779    if (2780      !p.rpId ||2781      !p.credentialId ||2782      !p.publicKeyCose ||2783      !p.userHandle ||2784      !p.userName ||2785      (p.algorithm !== -7 && p.algorithm !== -8)2786    ) {2787      throw new Error("invalid passkey credential");2788    }2789  }2790  if (type === "card") {2791    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2792      throw new Error("cardholder name and card number are required");2793    }2794  }27952796  return {2797    type,2798    title,2799    username: type === "login" ? entry.username?.trim() ?? "" : undefined,2800    email: type === "login" ? entry.email?.trim() || undefined : undefined,2801    password: type === "login" ? entry.password : undefined,2802    url: type === "login" ? allowHttpUrl(entry.url) : undefined,2803    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2804    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2805    // PCH-01-019: preserve non-default TOTP params through batch-add/import so2806    // they are not silently stripped (they fall back to SHA-1/6/30 when absent).2807    totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2808    totpDigits: type === "login" ? entry.totpDigits : undefined,2809    totpPeriod: type === "login" ? entry.totpPeriod : undefined,2810    notes: entry.notes?.trim() || undefined,2811    breach: type === "login" ? entry.breach : undefined,2812    // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags2813    // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here.2814    canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2815    tags: sanitizeTags(entry.tags),2816    passkey: entry.passkey,2817    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2818    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2819    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2820    expYear: type === "card" ? entry.expYear?.trim() : undefined,2821    cvc: type === "card" ? entry.cvc?.trim() : undefined,2822    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2823    network: type === "card" ? entry.network : undefined,2824    last4: type === "card" ? entry.last4?.trim() : undefined,2825  };2826}28272828function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2829  const nextPatch: Partial<VaultEntry> = {};28302831  if ("type" in patch) {2832    nextPatch.type = patch.type;2833  }28342835  if ("title" in patch) {2836    const title = patch.title?.trim() ?? "";2837    if (!title) throw new Error("title is required");2838    nextPatch.title = title;2839  }28402841  if ("url" in patch) {2842    nextPatch.url = allowHttpUrl(patch.url);2843  }28442845  if ("additionalUrls" in patch) {2846    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2847    const seen = new Set<string>();2848    const cleaned: string[] = [];2849    for (const value of incoming) {2850      const trimmed = typeof value === "string" ? value.trim() : "";2851      if (!trimmed || !allowHttpUrl(trimmed)) continue;2852      const key = trimmed.toLowerCase();2853      if (seen.has(key)) continue;2854      seen.add(key);2855      cleaned.push(trimmed);2856    }2857    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2858  }28592860  if ("username" in patch) {2861    nextPatch.username = patch.username?.trim() ?? "";2862  }28632864  if ("email" in patch) {2865    nextPatch.email = patch.email?.trim() || undefined;2866  }28672868  if ("password" in patch) {2869    const password = patch.password ?? "";2870    // Allow clearing a password (e.g. on passkey-only entries)2871    nextPatch.password = password || undefined;2872  }28732874  if ("totp" in patch) {2875    nextPatch.totp = patch.totp?.trim() || undefined;2876  }28772878  if ("notes" in patch) {2879    nextPatch.notes = patch.notes?.trim() || undefined;2880  }28812882  if ("breach" in patch) {2883    nextPatch.breach = patch.breach;2884  }28852886  if ("canary" in patch) {2887    // PCH-02-001: route update patches through the same canary whitelist used2888    // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.2889    nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2890  }28912892  if ("passkey" in patch) {2893    // A patch with `passkey: undefined` removes the credential from the entry2894    // (used by "remove passkey" without deleting the whole entry).2895    if (patch.passkey === undefined || patch.passkey === null) {2896      nextPatch.passkey = undefined;2897    } else {2898      const sanitized = sanitizePasskey(patch.passkey);2899      if (!sanitized) throw new Error("invalid passkey credential");2900      nextPatch.passkey = sanitized;2901    }2902  }29032904  if ("tags" in patch) {2905    // PCH-02-001: bound tags exactly like both import funnels.2906    nextPatch.tags = sanitizeTags(patch.tags);2907  }29082909  if ("cardholderName" in patch) {2910    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2911  }2912  if ("cardNumber" in patch) {2913    const n = patch.cardNumber ?? "";2914    nextPatch.cardNumber = n || undefined;2915  }2916  if ("expMonth" in patch) {2917    nextPatch.expMonth = patch.expMonth?.trim() || undefined;2918  }2919  if ("expYear" in patch) {2920    nextPatch.expYear = patch.expYear?.trim() || undefined;2921  }2922  if ("cvc" in patch) {2923    nextPatch.cvc = patch.cvc?.trim() || undefined;2924  }2925  if ("cardNickname" in patch) {2926    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2927  }2928  if ("network" in patch) {2929    nextPatch.network = patch.network;2930  }2931  if ("last4" in patch) {2932    nextPatch.last4 = patch.last4?.trim() || undefined;2933  }29342935  return nextPatch;2936}29372938function normalizeEntry(entry: VaultEntry): VaultEntry {2939  const base: VaultEntry = {2940    id: entry.id,2941    type: entry.type,2942    title: entry.title,2943    username: entry.username,2944    email: entry.email,2945    password: entry.password,2946    url: entry.url,2947    additionalUrls:2948      "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2949        ? entry.additionalUrls2950        : undefined,2951    totp: entry.totp,2952    // PCH-01-019: carry TOTP params through the normalize/restore path too.2953    totpAlgorithm: entry.totpAlgorithm,2954    totpDigits: entry.totpDigits,2955    totpPeriod: entry.totpPeriod,2956    notes: entry.notes,2957    breach: entry.breach,2958    // Preserve the password-age clock across lock/unlock. Without this the2959    // field is dropped on every decode and the advanced-health "aged password"2960    // signal silently resets to createdAt on each unlock (SEV-2-A).2961    passwordChangedAt:2962      "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2963    // Preserve past-password history across lock/unlock. normalizeEntry is a2964    // field whitelist, so an unlisted field is silently dropped on every decode2965    // (same class as the passwordChangedAt SEV-2-A above).2966    passwordHistory:2967      "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2968        ? entry.passwordHistory2969        : undefined,2970    canary: entry.canary,2971    passkey: entry.passkey,2972    createdAt: entry.createdAt,2973    modifiedAt: entry.modifiedAt,2974    deletedAt: entry.deletedAt,2975    tags: entry.tags,2976    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2977    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2978    expMonth: "expMonth" in entry ? entry.expMonth : undefined,2979    expYear: "expYear" in entry ? entry.expYear : undefined,2980    cvc: "cvc" in entry ? entry.cvc : undefined,2981    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2982    network: "network" in entry ? entry.network : undefined,2983    last4: "last4" in entry ? entry.last4 : undefined,2984  };29852986  return base;2987}298829892990// ---------------------------------------------------------------------------2991// Deduplication2992// ---------------------------------------------------------------------------29932994function entryDupeKey(e: VaultEntry): string {2995  if (e.type === "note") return `note|${e.title.toLowerCase()}`;2996  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins2997  // on different sites that happen to share a username are still distinct;2998  // two passkey credentials for the same RP+user are duplicates regardless2999  // of any login fields they may also carry.3000  if (e.passkey) {3001    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3002  }3003  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3004}30053006/** Returns the number of duplicate entries (those that would be removed). */3007export function countDuplicates(): number {3008  const best = new Map<string, VaultEntry>();3009  let dupeCount = 0;3010  for (const e of entries.filter((e) => !e.deletedAt)) {3011    const key = entryDupeKey(e);3012    const existing = best.get(key);3013    if (!existing) {3014      best.set(key, e);3015    } else {3016      dupeCount++;3017      // Keep the more recently modified one in the map3018      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3019        best.set(key, e);3020      }3021    }3022  }3023  return dupeCount;3024}30253026/**3027 * Soft-deletes duplicate entries, keeping the most recently modified copy of3028 * each group. Returns the number of entries moved to trash.3029 */3030export async function deduplicateEntries(): Promise<number> {3031  if (!derivedKey) throw new Error("vault_locked");3032  // Dedup reshapes slot B permanently under the decoy key, which is3033  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.3034  if (vaultMode === "decoy") {3035    throw new Error("deduplicate disabled in decoy mode");3036  }30373038  const best = new Map<string, VaultEntry>();3039  for (const e of entries.filter((en) => !en.deletedAt)) {3040    const key = entryDupeKey(e);3041    const existing = best.get(key);3042    if (!existing) {3043      best.set(key, e);3044    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3045      best.set(key, e);3046    }3047  }30483049  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3050  let removed = 0;3051  entries = entries.map((e) => {3052    if (!e.deletedAt && !keepIds.has(e.id)) {3053      removed++;3054      const deletedAt = monotonicEntryTimestamp(Math.max(3055        e.modifiedAt,3056        tombstones.get(e.id) ?? 0,3057      ));3058      tombstones.set(e.id, deletedAt);3059      return { ...e, deletedAt };3060    }3061    return e;3062  });30633064  if (removed > 0) await persistEntries();3065  return removed;3066}30673068// ---------------------------------------------------------------------------3069// Vault history — snapshot BEFORE every vault write so the saved blob is3070// what is about to be lost, not what was just written. Capped at 5. The3071// stored blobs are AES-256-GCM ciphertext under the master password, so3072// history adds zero security surface.3073// ---------------------------------------------------------------------------30743075const HISTORY_KEY = "peach_vault_history";3076const HISTORY_MAX = 5;30773078// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the3079// plaintext snapshot field cannot reveal exact vault size or single-entry change3080// deltas. Buckets widen as the vault grows so absolute leakage stays roughly3081// proportional rather than exact.3082function bucketEntryCount(n: number): number {3083  if (n <= 0) return 0;3084  if (n <= 10) return 10;3085  if (n <= 25) return 25;3086  if (n <= 50) return 50;3087  if (n <= 100) return 100;3088  if (n <= 250) return 250;3089  if (n <= 500) return 500;3090  return Math.ceil(n / 500) * 500;3091}30923093/**3094 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE3095 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost3096 * state. Identical-to-most-recent blobs are skipped to avoid filling history3097 * with no-op writes.3098 */3099export async function takeVaultSnapshot(3100  reason: VaultSnapshot["reason"],3101): Promise<void> {3102  try {3103    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3104    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3105    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];31063107    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,3108    // pre-createVault). createVault skips snapshots intentionally — there is3109    // no prior state to preserve.3110    if (!slotA) return;31113112    const vault_a = JSON.stringify(slotA);3113    const vault_b = JSON.stringify(slotB ?? slotA);31143115    // Best-effort entry count: only meaningful when the in-memory state is3116    // unlocked AND in real mode. Decoy mode would leak the decoy entry3117    // count; locked mode has no entries to count. Both fall through to null.3118    // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage3119    // .local sits next to the AES-GCM blobs in plaintext, so an exact count would3120    // leak vault size and per-write deltas (the metadata padding hides this).3121    let entryCount: number | null = null;3122    if (derivedKey && vaultMode === "real") {3123      try {3124        entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3125      } catch {3126        entryCount = null;3127      }3128    }31293130    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3131    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3132      ? existingRaw3133      : { snapshots: [] };31343135    // Deduplication: if the most recent snapshot has byte-identical blobs,3136    // skip — saves the same vault N times in a row would otherwise burn the3137    // entire history window with no recovery value.3138    const latest = history.snapshots[0];3139    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3140      return;3141    }31423143    const snapshot: VaultSnapshot = {3144      id: crypto.randomUUID(),3145      savedAt: Date.now(),3146      reason,3147      vault_a,3148      vault_b,3149      entryCount,3150    };31513152    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3153    await chrome.storage.local.set({ [HISTORY_KEY]: history });3154  } catch (err) {3155    // Non-fatal — history must never block a save. A failed snapshot just3156    // means the user loses one rung of the safety ladder for this write.3157    // M4/QUA-006: log so the failure is visible; if it looks like a quota3158    // problem, notify the popup so the user can prune history themselves.3159    console.warn('[Peach] takeVaultSnapshot failed:', err);3160    if (String(err).includes('QUOTA') || String(err).includes('quota')) {3161      try {3162        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3163      } catch {3164        // popup may be closed; surfaced via console.warn above3165      }3166    }3167  }3168}31693170/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */3171export async function clearVaultHistory(): Promise<void> {3172  try {3173    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3174  } catch {3175    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().3176  }3177}31783179/** Read all snapshots, newest first, for the Settings vault history view. */3180export async function getVaultHistory(): Promise<VaultSnapshot[]> {3181  const raw = await chrome.storage.local.get(HISTORY_KEY);3182  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3183  if (!history || !Array.isArray(history.snapshots)) return [];3184  return history.snapshots;3185}31863187/**3188 * Replace the on-disk vault with the chosen snapshot's blobs and lock the3189 * vault. The current state is itself snapshotted with reason 'restore' first3190 * so the restore is reversible. After this returns, the user must unlock3191 * with the password that matches the restored blob.3192 */3193export async function restoreVaultSnapshot(3194  snapshotId: string,3195): Promise<{ ok: true } | { ok: false; error: string }> {3196  const history = await getVaultHistory();3197  const snapshot = history.find((s) => s.id === snapshotId);3198  if (!snapshot) return { ok: false, error: "snapshot_not_found" };31993200  // Snapshot the about-to-be-replaced current state so the restore is3201  // reversible. takeVaultSnapshot is no-op when the most recent entry is3202  // byte-identical, so a restore-of-a-restore stays clean.3203  await takeVaultSnapshot("restore");32043205  let restoredA: StoredVault;3206  let restoredB: StoredVault;3207  try {3208    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3209    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3210  } catch {3211    return { ok: false, error: "snapshot_corrupt" };3212  }32133214  await writeSlot(SLOT_A, restoredA);3215  await writeSlot(SLOT_B, restoredB);3216  await stampLocalVaultMutation();32173218  // Lock so the user must prove they can decrypt the restored blob. If they3219  // restored a pre-password-change snapshot they discover it immediately at3220  // the lock screen rather than silently holding an inaccessible vault.3221  await lock();32223223  return { ok: true };3224}32253226/**3227 * Rotate the vault's sync identity by generating a FRESH ROOT KEY and3228 * re-wrapping everything under it (MV-8). Because every sync credential —3229 * vault_id, the Noise static keypair, the Ed25519 manifest signing key,3230 * and the per-entry envelope keys — derives from the root key, rotation3231 * makes a lost/stolen device's retained key material useless. All paired3232 * devices must re-pair afterwards.3233 *3234 * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from3235 * the root key, so rotation INVALIDATES the old recovery phrase. The flow3236 * sets peach_recovery_pending so the popup re-presents the new words for3237 * confirmation, same as the initial recovery-word confirmation path.3238 */3239export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3240  if (!derivedKey || vaultMode !== "real") {3241    return { ok: false, error: "vault_locked" };3242  }3243  const slotA = await loadSlot(SLOT_A);3244  if (!slotA) return { ok: false, error: "missing_slot_a" };32453246  // 1. Fresh root key + recovery escrow (new words).3247  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3248    await generateAndWrapRootKey();32493250  // 2. Re-wrap the new root key under the current password-derived key.3251  const newRootKey = unhex(rawRootKey);3252  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);32533254  // 3. Snapshot, then single atomic slot write — both escrow wrappings of3255  //    the new root key land together (same transactional rule as3256  //    changePassword). Entries are untouched: they are sealed under the3257  //    password-derived key, not the root key.3258  await takeVaultSnapshot("local_save");3259  const nextSlotA: StoredVault = {3260    ...slotA,3261    recovery_salt: recoverySalt,3262    recovery_wrapped_root_key: wrappedRootKey,3263    recovery_nonce: wrappedNonce,3264    recovery_tag: wrappedTag,3265    ...pwRoot,3266  };3267  await writeSlot(SLOT_A, nextSlotA);32683269  // 4. Swap in memory only after disk committed.3270  if (rootKey) zeroKey(rootKey);3271  rootKey = newRootKey;32723273  // 5. Old peers' identities derive from the dead root key — drop them all,3274  // with their rendezvous secrets (PB-26-213: rotation clears the map, so3275  // there is nothing to rewrap under the new key).3276  await clearPairedDevices();3277  await clearPairSecrets();32783279  // 6. Force re-presentation of the NEW recovery words on next popup view.3280  await setRecoveryConfirmPending(); // same flag the create-vault flow uses32813282  await stampLocalVaultMutation();3283  return { ok: true };3284}32853286/**3287 * Change the master password. Re-encrypts slot A with a fresh salt + new3288 * derived key. Slot B (duress or noise) is left untouched when duress is3289 * configured — it uses an independent key derived from the duress password —3290 * and regenerated as noise when duress is not configured.3291 */3292export async function changePassword(3293  currentPassword: string,3294  newPassword: string3295): Promise<boolean> {3296  const epoch = vaultEpoch;3297  const stale = () => epoch !== vaultEpoch;3298  if (!derivedKey || vaultMode !== "real") return false;32993300  // Verify current password before allowing any change3301  const valid = await verifyMainPassword(currentPassword);3302  if (!valid) return false;33033304  const slotA = await loadSlot(SLOT_A);3305  if (!slotA) return false;3306  if (!rootKey) return false;33073308  // Derive fresh key from new password + new random salt3309  const newSalt = generateSalt();3310  const newKey = await deriveKey(newPassword, newSalt);3311  if (stale()) {3312    zeroKey(newKey);3313    return false;3314  }33153316  // Re-encrypt the in-memory entries with the new key3317  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3318  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");33193320  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey3321  // under recovery words and remain untouched — recovery phrase keeps working3322  // across password changes (H4/COR-002).3323  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);33243325  const finalSlotA: StoredVault = {3326    ...slotA,3327    salt: hex(newSalt),3328    nonce,3329    ciphertext,3330    tag,3331    ...pwRoot,3332  };33333334  // Snapshot the OLD-key blob before overwriting it. If the user later3335  // restores from this snapshot they will need the OLD password — the3336  // post-restore lock surfaces that immediately.3337  await takeVaultSnapshot("local_save");33383339  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in3340  // one storage transaction. An SW kill before this point leaves the old3341  // slot intact; after this point everything tied to the new password is3342  // already on disk together (M6/COR-004).3343  await writeSlot(SLOT_A, finalSlotA);3344  if (stale()) {3345    zeroKey(newKey);3346    return false;3347  }33483349  // Swap the in-memory key synchronously before any further await. If we3350  // threw between the Slot A write and this swap, the next persistEntries()3351  // would overwrite Slot A with old-key ciphertext, locking the user out.3352  zeroKey(derivedKey);3353  derivedKey = newKey;33543355  // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,3356  // the next service-worker restore decrypts the new-key Slot A with the stale3357  // key, fails tryDecryptSlotResult, and silently drops the user to a lock3358  // screen. Re-escrow the new key so restore survives a password change.3359  if (stale()) {3360    await lock();3361    return false;3362  }3363  await saveSessionKey(newKey, "real");33643365  // Regenerate noise for slot B when duress is not in use. Runs after the3366  // key swap so a failure here is a disposable noise regeneration, not a3367  // lockout.3368  if (!duressConfigured) {3369    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3370  }3371  await clearPinUnlockStorage();3372  await stampLocalVaultMutation();33733374  return true;3375}