vault.ts

124.3 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";16// Static, not `await import()`: runtime import() throws in an MV3 service17// worker. Neither module imports vault.ts back, so there is no cycle to break.18import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21  rootKeyToWords,22  wordsToRootKeyMaterial,23  deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26  AccentColorName,27  Canary,28  ManualSummonRecord,29  PasskeyData,30  SavedIdentity,31  StoredVault,32  VaultEntry,33  VaultHistory,34  VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import {39  clearRecoveryConfirmPending,40  setRecoveryConfirmPending,41} from "./recovery-pending";42import {43  clearPairSecrets,44  dropSessionCache,45  reconcilePairSecrets,46  setSessionAllowed,47} from "./pair-secrets";4849type EntryDraft = Omit<50  VaultEntry,51  "id" | "createdAt" | "modifiedAt" | "deletedAt"52>;5354// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B55// either holds the decoy vault (when duress is configured) or random noise56// that looks identical at rest. No external indicator is stored — duress57// configuration is recorded only inside the encrypted payload of slot A.58const SLOT_A = "peach_vault_a";59const SLOT_B = "peach_vault_b";6061// Plaintext is padded to the next multiple of this size before encryption.62// This coarsens ciphertext-length leakage into 1 KiB buckets.63const PAD_BLOCK = 1024;6465// How many past passwords to retain per entry. Generous enough to be useful as66// a recovery aid while bounding the secret material we hold (and the ciphertext67// growth) for an entry whose password is rotated frequently.68const PASSWORD_HISTORY_MAX = 50;6970// When duress is configured, BOTH slots are padded to this fixed ceiling so71// their ciphertext lengths are identical and cannot reveal that a hidden decoy72// vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably73// holds a very large vault (thousands of entries incl. passkeys). The decoy74// slot can't be re-encrypted on every real-vault save (the real session has no75// decoy key), so a fixed ceiling — rather than size-matching on each write — is76// what keeps the two slots equal. A real vault whose plaintext exceeds this77// would grow past the ceiling and re-expose the bit; that is an extreme,78// documented edge well beyond normal use.79const DURESS_SLOT_SIZE = 262_144;8081let derivedKey: Uint8Array | null = null;82// 32-byte random root key, separate from the derivedKey used for vault encryption.83// The root key is wrapped (encrypted) under the recovery key derived from the84// 12-word phrase. When the user changes their password, only the wrapping changes;85// the root key and recovery words remain stable.86let rootKey: Uint8Array | null = null;87let entries: VaultEntry[] = [];88// Tombstones outlive their full entry records so a peer that was offline89// during a purge cannot reintroduce an old ciphertext envelope. This map is90// serialized only inside the encrypted vault payload.91let tombstones = new Map<string, number>();92let domainIndex: Map<string, VaultEntry[]> = new Map();93let vaultMode: "real" | "decoy" = "real";94// Cached post-unlock view of whether the real vault records that duress is95// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock96// this stays false because we never see slot A's plaintext.97let duressConfigured = false;9899// Set to a corruption reason during the last unlock() attempt when both slots100// decrypted but failed schema validation. Consumed and cleared by101// consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted102// distinctly from wrong-password (M7/COR-007).103//104// NOTE: module-level state — if vault.ts is ever moved to a worker context,105// this must be replaced with a structured return value from unlock() or a106// postMessage channel. The signal is invisible across module-instance107// boundaries.108let lastUnlockFailureReason: string | null = null;109110export function consumeLastUnlockFailureReason(): string | null {111  const r = lastUnlockFailureReason;112  lastUnlockFailureReason = null;113  return r;114}115116// ---------------------------------------------------------------------------117// Settings (persisted inside the encrypted vault store as a sidecar key)118// ---------------------------------------------------------------------------119120export type TextSize = "small" | "medium" | "large";121122export interface VaultSettings {123  accentColor?: AccentColorName;124  theme?: "dark" | "light";125  autoLockMinutes?: number;126  clipboardClearSeconds?: number;127  lockOnClose?: boolean;128  breachMonitoring?: boolean;129  passkeyProviderEnabled?: boolean;130  trashRetentionDays?: number;131  manualSummonDomains?: Record<string, ManualSummonRecord>;132  manualSummonSalt?: string;133  declinedCardFingerprints?: string[];134135  /** Metadata is bounded because it remains outside the encrypted vault blob. */136  metadataVersion?: 1;137  /**138   * Text size scale for body copy and entry titles. Stops at "large" by139   * design — XL clips dense screens (Vault, Health) inside the fixed140   * 330×480 popup surface.141   */142  textSize?: TextSize;143  /**144   * When true, all entrance/exit transitions and ambient animations are145   * collapsed to instant. Honoured alongside the user's OS-level146   * `prefers-reduced-motion`.147   */148  reduceMotion?: boolean;149  /** Whether this vault has a local PIN unlock wrapper configured. */150  pinEnabled?: boolean;151  /** Whether the lock screen should open on PIN instead of the password. */152  pinPreferred?: boolean;153  /**154   * User overrides for keyboard shortcuts. Keys are ShortcutAction names155   * (see src/shared/shortcuts.ts). Missing actions fall through to156   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the157   * canonical lookup. Storing only the deltas keeps the vault payload158   * small and lets default updates land for users who never rebind.159   */160  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;161}162163interface StoredPinWrap {164  version: 1;165  salt: string;166  nonce: string;167  ciphertext: string;168  tag: string;169}170171const PIN_WRAP_STORAGE = "peach_pin_wrap";172const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";173const PIN_MIN_LENGTH = 4;174const PIN_MAX_LENGTH = 12;175const PIN_MAX_ATTEMPTS = 3;176177let settings: VaultSettings = {};178const CARD_DECLINE_MAX = 256;179/** Hex length of a keyed card-decline digest. Legacy records are 16 hex. */180const CARD_DECLINE_DIGEST_HEX = 32;181182async function metadataDigest(value: string, key: string): Promise<string> {183  const bytes = new TextEncoder().encode(`${key}:${value}`);184  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));185  return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");186}187188let inMemoryPinWrap: StoredPinWrap | null = null;189let inMemoryPinFailedAttempts = 0;190let sessionPinStorageFailed = false;191192export function getSettings(): VaultSettings {193  return settings;194}195196export async function ensureSettingsLoaded(): Promise<VaultSettings> {197  await loadSettings();198  return settings;199}200201export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {202  await loadSettings();203  // LV-7: cheap insurance against prototype pollution if a future caller ever204  // feeds parsed external JSON here (native spread keeps __proto__ an own-205  // property today, but Object.assign-style consumers and JSON revivers have206  // been burned before).207  const safe = Object.fromEntries(208    Object.entries(patch).filter(209      ([k]) => !["__proto__", "constructor", "prototype"].includes(k),210    ),211  ) as Partial<VaultSettings>;212  settings = { ...settings, ...safe };213  await persistSettings();214}215216function isValidPin(pin: string): boolean {217  return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);218}219220/** Set up a local PIN wrapper after proving the current master password. */221export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {222  if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;223  if (!(await verifyMainPassword(currentPassword))) return false;224225  const salt = generateSalt();226  const pinKey = await deriveKey(pin, salt);227  try {228    const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");229    await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });230    await updateSettings({ pinEnabled: true });231    return true;232  } finally {233    zeroKey(pinKey);234  }235}236237async function clearPinUnlockStorage(): Promise<void> {238  inMemoryPinWrap = null;239  inMemoryPinFailedAttempts = 0;240  sessionPinStorageFailed = false;241  if (chrome.storage.session) {242    await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);243  }244  await updateSettings({ pinEnabled: false });245}246247/** Remove the PIN wrapper after proving the current master password. */248export async function disablePinUnlock(currentPassword: string): Promise<boolean> {249  if (!derivedKey || vaultMode !== "real") return false;250  if (!(await verifyMainPassword(currentPassword))) return false;251  await clearPinUnlockStorage();252  return true;253}254255/** Unlock the real vault from its locally stored PIN-wrapped derived key. */256export async function unlockWithPin(pin: string): Promise<boolean> {257  if (!isValidPin(pin)) return false;258  await loadSettings();259  if (!settings.pinEnabled) return false;260261  const { stored, failedAttempts } = await loadPinState();262  if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;263  if (264    !stored ||265    stored.version !== 1 ||266    !stored.salt ||267    !stored.nonce ||268    !stored.ciphertext ||269    !stored.tag270  ) return false;271272  const pinKey = await deriveKey(pin, unhex(stored.salt));273  let unwrappedKey: Uint8Array | null = null;274  try {275    const masterKeyHex = await decrypt(276      pinKey,277      stored.nonce,278      stored.ciphertext,279      stored.tag,280      "pin_wrap_v1",281    );282    if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;283    unwrappedKey = unhex(masterKeyHex);284    const result = await unlockWithKey(masterKeyHex);285    if (!result.ok) return false;286    await resetPinFailedAttempts();287    return true;288  } catch {289    await recordPinFailure(failedAttempts + 1);290    return false;291  } finally {292    zeroKey(pinKey);293    if (unwrappedKey) zeroKey(unwrappedKey);294  }295}296297async function savePinWrap(stored: StoredPinWrap): Promise<void> {298  inMemoryPinWrap = stored;299  inMemoryPinFailedAttempts = 0;300  sessionPinStorageFailed = !chrome.storage.session;301  if (!chrome.storage.session) return;302  try {303    await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });304    sessionPinStorageFailed = false;305  } catch {306    // Firefox fallback: keep the wrapper only in this background context.307    sessionPinStorageFailed = true;308  }309}310311async function loadPinState(): Promise<{312  stored: StoredPinWrap | undefined;313  failedAttempts: number;314}> {315  if (!chrome.storage.session) {316    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };317  }318  try {319    const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);320    return {321      stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??322        (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),323      failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"324        ? result[PIN_ATTEMPTS_STORAGE]325        : 0,326    };327  } catch {328    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };329  }330}331332async function recordPinFailure(failedAttempts: number): Promise<void> {333  inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);334  if (!chrome.storage.session) return;335  try {336    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });337  } catch { /* in-memory counter remains the fallback */ }338}339340async function resetPinFailedAttempts(): Promise<void> {341  inMemoryPinFailedAttempts = 0;342  if (!chrome.storage.session) return;343  try {344    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });345  } catch { /* in-memory counter remains the fallback */ }346}347348export async function getPinUnlockStatus(): Promise<{349  configured: boolean;350  available: boolean;351  attemptsRemaining: number;352}> {353  await loadSettings();354  if (!settings.pinEnabled) {355    return { configured: false, available: false, attemptsRemaining: 0 };356  }357  const { stored, failedAttempts } = await loadPinState();358  const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;359  return {360    configured: true,361    available,362    attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,363  };364}365366async function persistSettings(): Promise<void> {367  try {368    await chrome.storage.local.set({369      peach_settings: {370        ...settings,371        manualSummonDomains: undefined,372        manualSummonSalt: undefined,373        cardFingerprintSalt: undefined,374        declinedCardFingerprints: settings.declinedCardFingerprints ?? [],375        metadataVersion: 1,376      },377    });378  } catch (err) {379    throw mapStorageError(err);380  }381}382383function mapStorageError(err: unknown): Error {384  const message = err instanceof Error ? err.message : String(err);385  if (/QUOTA_BYTES|quota/i.test(message)) {386    return new Error("STORAGE_QUOTA_EXCEEDED");387  }388  return err instanceof Error ? err : new Error(message);389}390391async function loadSettings(): Promise<void> {392  const result = await chrome.storage.local.get("peach_settings");393  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};394}395396// Fire-and-forget sync trigger after a committed vault write. A failure here397// must never fail the write that caused it.398function fireSyncTrigger(reason: "vault_change"): void {399  void Promise.resolve()400    .then(() => triggerSyncIfReachable({ reason }))401    .catch(() => {402      /* no sync runner registered, or nothing reachable — non-fatal */403    });404}405406let lockedSyncRefreshHook: (() => Promise<void>) | null = null;407let lockedSyncUnlockHook: (() => Promise<void>) | null = null;408409export function setLockedSyncHooks(hooks: {410  refresh: () => Promise<void>;411  afterUnlock: () => Promise<void>;412}): void {413  lockedSyncRefreshHook = hooks.refresh;414  lockedSyncUnlockHook = hooks.afterUnlock;415}416417async function refreshLockedSyncState(): Promise<void> {418  if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;419  await lockedSyncRefreshHook().catch((error) => {420    console.warn("[Peach sync] locked spool refresh failed:", error);421  });422}423424// ---------------------------------------------------------------------------425// HIBP breach check (k-anonymity model)426// ---------------------------------------------------------------------------427428export async function checkBreach(password: string): Promise<number> {429  // HIBP k-anonymity API is keyed on SHA-1 prefixes — this is dictated by430  // the public API, not a security choice. Do not migrate to SHA-256 here.431  const encoder = new TextEncoder();432  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));433  const hashHex = Array.from(new Uint8Array(hashBuffer))434    .map((b) => b.toString(16).padStart(2, "0"))435    .join("")436    .toUpperCase();437438  const prefix = hashHex.slice(0, 5);439  const suffix = hashHex.slice(5);440441  try {442    // PCH-02-007: request HIBP response padding so response length does not443    // reveal how many suffixes a queried prefix returned.444    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {445      headers: { "Add-Padding": "true" },446    });447    if (!res.ok) return 0;448    const text = await res.text();449    const match = text.split("\r\n").find((l) => l.startsWith(suffix));450    return match ? parseInt(match.split(":")[1], 10) : 0;451  } catch {452    return 0; // Network unavailable — don't block453  }454}455456export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {457  // Unreachable from the message layer, which gates on isUnlocked first.458  const key = await cardDeclineKey();459  if (!key) return;460  const fingerprint = await cardFingerprint(last4, expMonth, expYear);461  await loadSettings();462  settings = {463    ...settings,464    declinedCardFingerprints: [465      // Legacy records predate the keyed digest and are silently dropped here:466      // they are 16 hex characters rather than 32, and are no longer accepted.467      ...(settings.declinedCardFingerprints ?? []).filter(468        (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,469      ),470      await metadataDigest(fingerprint, key),471    ].slice(-CARD_DECLINE_MAX),472  };473  await persistSettings();474}475476export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {477  await loadSettings();478  const key = await cardDeclineKey();479  if (!key) return false;480  const fingerprint = await cardFingerprint(last4, expMonth, expYear);481  const candidate = await metadataDigest(fingerprint, key);482  return (settings.declinedCardFingerprints ?? []).some(483    (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,484  );485}486487/**488 * Keying material for the declined-card digest, derived from the vault root489 * key so it is available only while unlocked and never written to storage.490 *491 * PB-26-108: the previous scheme stored a random salt beside the records. A492 * salt defeats precomputed tables, not an attacker holding the file — the493 * candidate space here is ~10^4 last-four values x 12 months x a few years, so494 * a stored salt left the card's last four and expiry recoverable offline in495 * seconds. `peach_settings` is unencrypted and directly readable by a content496 * script on Firefox, so the key has to live outside storage entirely.497 */498async function cardDeclineKey(): Promise<string | null> {499  const root = getRootKeyHex();500  if (!root) return null;501  const digest = new Uint8Array(502    await crypto.subtle.digest(503      "SHA-256",504      new TextEncoder().encode(`peach-card-decline-v1:${root}`),505    ),506  );507  return hex(digest);508}509510async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {511  const encoder = new TextEncoder();512  const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));513  return Array.from(new Uint8Array(buf).slice(0, 8))514    .map((b) => b.toString(16).padStart(2, "0"))515    .join("");516}517518function hex(buf: Uint8Array): string {519  return Array.from(buf)520    .map((b) => b.toString(16).padStart(2, "0"))521    .join("");522}523524function unhex(s: string): Uint8Array {525  const out = new Uint8Array(s.length / 2);526  for (let i = 0; i < s.length; i += 2) {527    out[i / 2] = parseInt(s.slice(i, i + 2), 16);528  }529  return out;530}531532// ---------------------------------------------------------------------------533// Root key generation and recovery word wrapping534// ---------------------------------------------------------------------------535536/**537 * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key538 * derived from the recovery word material via HKDF-SHA256. The wrapped blob539 * fields are stored in slot A alongside the password-wrapped ciphertext. The540 * root key itself is kept in memory for the session so getRecoveryWords() can541 * encode it without re-deriving.542 *543 * SECURITY MODEL (PCH-01-005): the recovery KEK is derived from the 12-word544 * material, which encodes only rootKey[0..16] (recovery-words.ts). The KEK is thus545 * derived from a PREFIX of the very plaintext it wraps, and effective recovery546 * security is 128-bit (standard for a 12-word phrase), NOT 256-bit. This is safe —547 * unwrapping requires the words, and the GCM ciphertext leaks nothing without them —548 * but it is intentional, not an oversight. A future escrow version wanting 256-bit549 * recovery must derive the words from INDEPENDENT entropy, not from the root key.550 */551async function generateAndWrapRootKey(): Promise<{552  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey553  recoverySalt: string;554  wrappedRootKey: string;555  wrappedNonce: string;556  wrappedTag: string;557}> {558  const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));559560  // Derive the recovery key from the BIP39 words encoding of the root key.561  const words = await rootKeyToWords(rootKeyBytes);562  const wordMaterial = await wordsToRootKeyMaterial(words);563  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));564  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);565566  // Wrap the root key with the recovery key using AES-256-GCM directly.567  // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.568  const nonce = crypto.getRandomValues(new Uint8Array(12));569  const encoded = new TextEncoder().encode(hex(rootKeyBytes));570  const raw = await crypto.subtle.encrypt(571    {572      name: "AES-GCM",573      iv: nonce,574      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,575      tagLength: 128,576    },577    recoveryKey,578    encoded579  );580  // WebCrypto returns ciphertext || auth tag concatenated; split them.581  const rawU8 = new Uint8Array(raw);582  const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));583  const tag = hex(rawU8.slice(rawU8.length - 16));584585  return {586    rawRootKey: hex(rootKeyBytes),587    recoverySalt: hex(recoverySalt),588    wrappedRootKey: ciphertext,589    wrappedNonce: hex(nonce),590    wrappedTag: tag,591  };592}593594async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{595  recoverySalt: string;596  wrappedRootKey: string;597  wrappedNonce: string;598  wrappedTag: string;599}> {600  if (rootKeyBytes.length !== 32) {601    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");602  }603604  const words = await rootKeyToWords(rootKeyBytes);605  const wordMaterial = await wordsToRootKeyMaterial(words);606  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));607  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);608  const nonce = crypto.getRandomValues(new Uint8Array(12));609  const encoded = new TextEncoder().encode(hex(rootKeyBytes));610  const raw = await crypto.subtle.encrypt(611    {612      name: "AES-GCM",613      iv: nonce,614      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,615      tagLength: 128,616    },617    recoveryKey,618    encoded,619  );620  const rawU8 = new Uint8Array(raw);621  return {622    recoverySalt: hex(recoverySalt),623    wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),624    wrappedNonce: hex(nonce),625    wrappedTag: hex(rawU8.slice(rawU8.length - 16)),626  };627}628629/**630 * Unwrap the root key using recovery words. Called during recovery flow when631 * the user provides their 12-word phrase and a new password.632 * Exported for use by the RECOVER_WITH_WORDS message handler.633 */634export async function unwrapRootKeyForRecovery(635  wrappedRootKey: string,636  wrappedNonce: string,637  wrappedTag: string,638  recoverySaltHex: string,639  words: string[]640): Promise<void> {641  const wordMaterial = await wordsToRootKeyMaterial(words);642  const recoverySalt = unhex(recoverySaltHex);643  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);644645  // Decrypt and cache the root key. The root key bytes were hex-encoded before646  // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt647  // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.648  // Each component is stored separately in the vault slot; decode them individually649  // so the nonce is always exactly the 12 bytes from wrappedNonce.650  const nonceRaw = unhex(wrappedNonce);651  const ciphertextRaw = unhex(wrappedRootKey);652  const tagRaw = unhex(wrappedTag);653  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).654  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.655  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;656  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);657  combined.set(ciphertextRaw, 0);658  combined.set(tagRaw, ciphertextRaw.length);659  const raw = await crypto.subtle.decrypt(660    { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },661    recoveryKey,662    combined663  );664  const decryptedHex = new TextDecoder().decode(raw);665  rootKey = unhex(decryptedHex);666}667668/**669 * Re-wrap the cached root key under a new derived key (from the user's new670 * password). Called after password change and after recovery re-key so the671 * same recovery words continue to work with the updated password — and so672 * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.673 *674 * recovery_* fields are intentionally not touched: those wrap rootKey under675 * the recovery words, which do not change when the password changes.676 */677export async function rewrapRootKeyForPassword(678  newDerivedKey: Uint8Array,679): Promise<void> {680  if (!rootKey) throw new Error("root key not set");681  const slotA = await loadSlot(SLOT_A);682  if (!slotA) throw new Error("missing slot A");683  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);684  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });685}686687/**688 * Change the vault encryption to a new derived key (already derived from689 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.690 * Caller is responsible for key swap and snapshot.691 */692async function reencryptVaultForNewKey(693  newKey: Uint8Array,694  slotA: StoredVault695): Promise<void> {696  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);697  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");698  await writeSlot(SLOT_A, {699    ...slotA,700    nonce,701    ciphertext,702    tag,703  });704}705706/**707 * Full password change with an already-derived key (used after recovery).708 * Unlike changePassword(), this skips re-verification of current password709 * since recovery flow has already validated the user's identity via words.710 */711export async function changePasswordWithNewKey(712  newSalt: Uint8Array,713  newKey: Uint8Array714): Promise<void> {715  const epoch = vaultEpoch;716  const throwIfStale = () => {717    if (epoch === vaultEpoch) return;718    zeroKey(newKey);719    throw new Error("vault_locked");720  };721  const slotA = await loadSlot(SLOT_A);722  throwIfStale();723  if (!slotA) throw new Error("missing slot A");724  // reencryptVaultForNewKey must receive the updated salt so that subsequent725  // UNLOCK calls with the new password can re-derive the correct key.726  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });727  throwIfStale();728  if (derivedKey) zeroKey(derivedKey);729  derivedKey = newKey;730  // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow731  // reaches this path, so a post-recovery SW restart would otherwise lock out732  // a user who just reset their password.733  await saveSessionKey(newKey, "real", epoch);734  throwIfStale();735  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works736  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow737  // should have populated it — fall back silently rather than blowing up the738  // recovery path; the escrow is already present on every supported vault.739  if (rootKey) {740    const slotAfter = await loadSlot(SLOT_A);741    throwIfStale();742    if (slotAfter) {743      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);744      throwIfStale();745      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });746      throwIfStale();747    }748  }749  if (!duressConfigured) {750    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));751    throwIfStale();752  }753  await clearPinUnlockStorage();754  throwIfStale();755  await stampLocalVaultMutation();756  throwIfStale();757}758759// ---------------------------------------------------------------------------760// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive761// a new key after password reset without re-importing the crypto module.762// ---------------------------------------------------------------------------763764export { deriveKey, generateSalt };765766export async function loadSlotA(): Promise<StoredVault | null> {767  return loadSlot(SLOT_A);768}769770/**771 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields772 * (a second wrapping of the root key under the password-derived key). This773 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without774 * the user re-entering their recovery phrase: the same root key bytes —775 * and therefore the same 12 words — survive every unlock.776 *777 * Returns null when the escrow is absent or decryption fails.778 */779async function deriveRootKeyFromDerivedKey(780  derivedKeyBytes: Uint8Array,781  slotA: StoredVault,782): Promise<Uint8Array | null> {783  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {784    return null;785  }786  try {787    const rootHex = await decrypt(788      derivedKeyBytes,789      slotA.pw_root_nonce,790      slotA.pw_root_ciphertext,791      slotA.pw_root_tag,792      "pw_root_v1",793    );794    return unhex(rootHex);795  } catch {796    return null;797  }798}799800// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the801// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.802async function wrapRootKeyForDerivedKey(803  derivedKeyBytes: Uint8Array,804  rootKeyBytes: Uint8Array,805): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {806  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");807  return {808    pw_root_ciphertext: ciphertext,809    pw_root_nonce: nonce,810    pw_root_tag: tag,811  };812}813814/** Returns the 12 recovery words for the current session's root key. */815export async function getRecoveryWords(): Promise<string[]> {816  if (!rootKey) {817    throw new Error("recovery not set up");818  }819  return rootKeyToWords(rootKey);820}821822async function loadSlot(key: string): Promise<StoredVault | null> {823  const result = await chrome.storage.local.get(key);824  return (result as Record<string, StoredVault | undefined>)[key] ?? null;825}826827async function writeSlot(key: string, vault: StoredVault): Promise<void> {828  try {829    await chrome.storage.local.set({ [key]: vault });830  } catch (err) {831    throw mapStorageError(err);832  }833}834835/**836 * Roll back a vault that was created by [createVaultFromRootKey] but837 * couldn't be paired (e.g. the subsequent license activation, identity838 * derivation, or paired-device upsert failed). Without this, a failed839 * receiver session leaves behind an empty vault — the user has to840 * re-pair to get a working setup, but the empty vault sits in the way.841 *842 * The call is idempotent: removing keys that aren't there is a no-op.843 */844export async function rollbackAdoptedVault(): Promise<void> {845  await lock();846  try {847    await chrome.storage.local.remove([848      SLOT_A,849      SLOT_B,850      "peach_last_modified_at",851    ]);852  } catch (err) {853    throw mapStorageError(err);854  }855}856857async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {858  const raw = await chrome.storage.local.get("peach_last_modified_at");859  const previous = Number(860    (raw as Record<string, unknown>).peach_last_modified_at,861  );862  const next = Math.max(863    Date.now(),864    Number.isFinite(previous) ? previous + 1 : 0,865  );866  try {867    await chrome.storage.local.set({868      [SLOT_A]: slotA,869      [SLOT_B]: slotB,870      peach_last_modified_at: String(next),871    });872  } catch (err) {873    throw mapStorageError(err);874  }875}876877// Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the878// sync layer's last-write-wins merge against wall-clock regressions (NTP step,879// manual clock change, DST glitch): a backwards Date.now() must never produce880// an entry timestamp that's <= the entry's existing modifiedAt, or a genuine881// local edit would lose to a stale remote copy. The returned value is always882// strictly greater than both the entry's current modifiedAt and the previous883// call's result (SEV-2-E).884let lastEntryTimestamp = 0;885function monotonicEntryTimestamp(currentModifiedAt?: number): number {886  const base = Math.max(887    Date.now(),888    (currentModifiedAt ?? 0) + 1,889    lastEntryTimestamp + 1,890  );891  lastEntryTimestamp = base;892  return base;893}894895async function stampLocalVaultMutation(): Promise<void> {896  const raw = await chrome.storage.local.get("peach_last_modified_at");897  const previous = Number(898    (raw as Record<string, unknown>).peach_last_modified_at,899  );900  const next = Math.max(901    Date.now(),902    Number.isFinite(previous) ? previous + 1 : 0,903  );904  await chrome.storage.local.set({905    peach_last_modified_at: String(next),906  });907}908909// ---------------------------------------------------------------------------910// Padding + noise helpers911// ---------------------------------------------------------------------------912913/**914 * Pads the plaintext string with trailing spaces so that the encoded byte915 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing916 * whitespace after a complete document, so the padding is transparent on917 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.918 */919function padPlaintext(json: string, minBytes = 0): string {920  const encoded = new TextEncoder().encode(json);921  const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);922  // minBytes lets the duress path force a fixed ceiling so both slots match.923  const target = Math.max(bucket, minBytes);924  const padLen = target - encoded.length;925  return json + " ".repeat(padLen);926}927928/**929 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The930 * ciphertext length matches the given byte count. Because GCM ciphertext is931 * pseudo-random, this blob is indistinguishable at rest from a real932 * encrypted vault without the key.933 *934 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto935 * spec hard limit). Chunk the fill so vaults larger than that — common once936 * the user has dozens of entries or any passkey — don't throw.937 */938const CSPRNG_CHUNK = 65_536;939940function fillRandom(buf: Uint8Array): Uint8Array {941  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {942    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);943    const chunk = new Uint8Array(end - offset);944    crypto.getRandomValues(chunk);945    buf.set(chunk, offset);946  }947  return buf;948}949950function generateNoiseBlob(ciphertextBytes: number): StoredVault {951  const salt = crypto.getRandomValues(new Uint8Array(16));952  const nonce = crypto.getRandomValues(new Uint8Array(12));953  const cipher = fillRandom(new Uint8Array(ciphertextBytes));954  const tag = crypto.getRandomValues(new Uint8Array(16));955  return {956    version: 1,957    salt: hex(salt),958    nonce: hex(nonce),959    ciphertext: hex(cipher),960    tag: hex(tag),961  };962}963964// ---------------------------------------------------------------------------965// Encrypted plaintext envelope966// ---------------------------------------------------------------------------967968type VaultPayload = {969  entries: VaultEntry[];970  tombstones?: Record<string, number>;971  duressConfigured?: boolean;972};973974type DecryptedPayload = {975  entries: VaultEntry[];976  tombstones: Map<string, number>;977  duressConfigured: boolean;978};979980function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {981  const envelope: VaultPayload = {982    entries,983    tombstones: Object.fromEntries(tombstones),984    duressConfigured: duress,985  };986  return padPlaintext(JSON.stringify(envelope), minBytes);987}988989function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {990  const result = new Map<string, number>();991  if (raw && typeof raw === "object" && !Array.isArray(raw)) {992    for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {993      if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {994        result.set(id, timestamp);995      }996    }997  }998  // Older payloads had no ledger. Rebuild the durable portion from any999  // soft-deleted entries before they are eligible for a later purge.1000  for (const entry of allEntries) {1001    if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1002      continue;1003    }1004    if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1005      result.set(entry.id, entry.deletedAt);1006    }1007  }1008  return result;1009}10101011function reconcileTombstones(): void {1012  const byId = new Map(entries.map((entry) => [entry.id, entry]));1013  for (const [id] of tombstones) {1014    if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1015  }1016  for (const entry of entries) {1017    if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1018      if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1019        tombstones.set(entry.id, entry.deletedAt);1020      }1021    }1022  }1023}10241025function decodePayload(plain: string): DecryptedPayload {1026  const parsed = JSON.parse(plain) as VaultPayload;1027  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1028    throw new Error("invalid vault payload");1029  }1030  const normalized = parsed.entries.map(normalizeEntry);1031  return {1032    entries: normalized,1033    tombstones: decodeTombstones(parsed.tombstones, normalized),1034    duressConfigured: parsed.duressConfigured === true,1035  };1036}10371038/**1039 * Consolidate duplicate passkey records after registration.1040 *1041 * Multiple registrations for one RP can leave duplicate passkey records. This1042 * pass collapses the duplicates:1043 *1044 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.1045 *   2. Within each group, the most recently-active credential wins (prefer1046 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).1047 *   3. If a NON-passkey login entry exists for that RP (matched by url or1048 *      additionalUrls hostname), attach the winning passkey to it and1049 *      soft-delete every passkey-bearing entry in the group.1050 *   4. Otherwise, soft-delete every loser in the group, keeping the winner1051 *      as a passkey-only entry.1052 *1053 * Soft delete (deletedAt) rather than splice — the trash flow keeps the1054 * record around for retention and the user can restore if the heuristic1055 * picked wrong. The `merged` flag goes back to decodePayload so the1056 * unlock path persists the consolidated shape immediately.1057 */1058function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1059  let merged = false;1060  const now = Date.now();10611062  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1063  const byRpId = new Map<string, VaultEntry[]>();1064  for (const e of candidates) {1065    const rpId = e.passkey!.rpId;1066    const list = byRpId.get(rpId);1067    if (list) list.push(e);1068    else byRpId.set(rpId, [e]);1069  }10701071  for (const [rpId, group] of byRpId) {1072    if (group.length === 0) continue;1073    group.sort((a, b) => {1074      const aT =1075        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1076      const bT =1077        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1078      return bT - aT;1079    });1080    const winner = group[0]!;10811082    // Look for a NON-passkey login entry matching this RP. Use the URL1083    // hostname rather than a substring search so a "login.foo.com" entry1084    // doesn't accidentally absorb a passkey for "foo.com".1085    const loginTarget = entries.find((e) => {1086      if (e.deletedAt || e.passkey || e.type !== "login") return false;1087      if (e.url && extractDomain(e.url) === rpId) return true;1088      for (const extra of e.additionalUrls ?? []) {1089        if (extractDomain(extra) === rpId) return true;1090      }1091      return false;1092    });10931094    if (loginTarget) {1095      loginTarget.passkey = winner.passkey;1096      // A passkey entry may also carry the site's TOTP seed. Preserve it when1097      // the passkey is consolidated onto an existing login instead of1098      // soft-deleting the only record that held the 2FA configuration.1099      if (!loginTarget.totp && winner.totp) {1100        loginTarget.totp = winner.totp;1101        loginTarget.totpAlgorithm = winner.totpAlgorithm;1102        loginTarget.totpDigits = winner.totpDigits;1103        loginTarget.totpPeriod = winner.totpPeriod;1104      }1105      loginTarget.modifiedAt = now;1106      for (const dup of group) {1107        if (!dup.deletedAt) {1108          dup.deletedAt = now;1109          dup.modifiedAt = now;1110        }1111      }1112      merged = true;1113    } else if (group.length > 1) {1114      for (let i = 1; i < group.length; i += 1) {1115        const dup = group[i]!;1116        if (!dup.deletedAt) {1117          dup.deletedAt = now;1118          dup.modifiedAt = now;1119        }1120      }1121      merged = true;1122    }1123  }11241125  return { merged };1126}11271128// Validates that a `StoredVault` record on disk has the field shape we expect1129// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only1130// authenticates the static VERSION_BYTE AAD, not the `version` field on the1131// outer record — a corrupted `version: 42` slot still decrypts cleanly. This1132// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a1133// history-restore path instead of falsely reporting "wrong password".1134function hasValidVaultSchema(v: unknown): v is StoredVault {1135  if (typeof v !== "object" || v === null) return false;1136  const r = v as Record<string, unknown>;1137  return (1138    r.version === 1 &&1139    typeof r.salt === "string" &&1140    typeof r.nonce === "string" &&1141    typeof r.ciphertext === "string" &&1142    typeof r.tag === "string"1143  );1144}11451146export type TryDecryptResult =1147  | { status: "ok"; payload: DecryptedPayload }1148  | { status: "wrong_key" }1149  | { status: "corrupted"; reason: string };11501151// Exported solely for `unlockFromCodex` below, which needs to verify a1152// supplied passphrase against a slotA blob *without* committing anything to1153// storage. Keep this internal otherwise — callers crossing the messages.ts1154// boundary should always go through `unlock` / `verifyMainPassword` so the1155// constant-time invariants in Projects.md §3.4 are not bypassed.1156export async function tryDecryptSlot(1157  vault: StoredVault,1158  key: Uint8Array,1159  aadTag?: string,1160): Promise<DecryptedPayload | null> {1161  const res = await tryDecryptSlotResult(vault, key, aadTag);1162  return res.status === "ok" ? res.payload : null;1163}11641165export async function tryDecryptSlotResult(1166  vault: StoredVault,1167  key: Uint8Array,1168  aadTag?: string,1169): Promise<TryDecryptResult> {1170  if (!hasValidVaultSchema(vault)) {1171    return { status: "corrupted", reason: "invalid_slot_schema" };1172  }1173  let plain: string;1174  try {1175    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1176  } catch {1177    return { status: "wrong_key" };1178  }1179  try {1180    const parsed = JSON.parse(plain) as unknown;1181    if (typeof parsed !== "object" || parsed === null) {1182      return { status: "corrupted", reason: "invalid_payload_shape" };1183    }1184    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1185      return { status: "corrupted", reason: "invalid_payload_shape" };1186    }1187    return { status: "ok", payload: decodePayload(plain) };1188  } catch {1189    return { status: "corrupted", reason: "invalid_payload_json" };1190  }1191}11921193// ---------------------------------------------------------------------------1194// Core operations1195// ---------------------------------------------------------------------------11961197function buildDomainIndexSync(all: VaultEntry[]): void {1198  domainIndex = new Map();11991200  for (const entry of all) {1201    if (entry.deletedAt) continue;1202    if (entry.type === "note" || entry.type === "card") continue;12031204    // An entry can match multiple sites via `additionalUrls`. Index each1205    // hostname so `queryDomain` returns the entry regardless of which alias1206    // the user is currently visiting. Dedupe per-entry so a single entry1207    // never appears twice in one bucket.1208    const domains = new Set<string>();1209    const primary = extractDomain(entry.url ?? "");1210    if (primary) domains.add(primary);1211    for (const extra of entry.additionalUrls ?? []) {1212      const d = extractDomain(extra);1213      if (d) domains.add(d);1214    }1215    if (domains.size === 0) continue;12161217    for (const domain of domains) {1218      const bucket = domainIndex.get(domain) ?? [];1219      bucket.push(entry);1220      domainIndex.set(domain, bucket);1221    }1222  }1223}12241225export async function createVault(password: string): Promise<void> {1226  const salt = generateSalt();1227  derivedKey = await deriveKey(password, salt);1228  entries = [];1229  tombstones = new Map();1230  vaultMode = "real";1231  duressConfigured = false;1232  buildDomainIndexSync(entries);12331234  // Generate and wrap the root key for recovery word escrow.1235  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1236    await generateAndWrapRootKey();1237  rootKey = unhex(rawRootKey);12381239  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1240  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");12411242  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate1243  // it without recovery words (H2/COR-006).1244  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12451246  const slotA: StoredVault = {1247    version: 1,1248    salt: hex(salt),1249    nonce,1250    ciphertext,1251    tag,1252    recovery_salt: recoverySalt,1253    recovery_wrapped_root_key: wrappedRootKey,1254    recovery_nonce: wrappedNonce,1255    recovery_tag: wrappedTag,1256    ...pwRoot,1257  };1258  await writeSlot(SLOT_A, slotA);12591260  // Matching-size noise blob for slot B, so a fresh install shows two1261  // similarly-shaped records regardless of whether duress is ever set up.1262  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1263  await stampLocalVaultMutation();12641265  touchAutoLock();1266}12671268/**1269 * Creates a local vault from an existing vault root key received through the1270 * SAS-gated adoption protocol. The password is local to this device; it wraps1271 * the adopted root key at rest and does not need to match the source device.1272 */1273export async function createVaultFromRootKey(1274  password: string,1275  rootKeyHex: string,1276): Promise<{ vaultId: string }> {1277  if (typeof password !== "string" || password.length === 0) {1278    throw new Error("ADOPT_INVALID_PASSWORD");1279  }1280  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1281    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1282  }12831284  const rootKeyBytes = unhex(rootKeyHex);1285  if (rootKeyBytes.length !== 32) {1286    rootKeyBytes.fill(0);1287    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1288  }12891290  let nextDerivedKey: Uint8Array | null = null;1291  let sessionRootKey: Uint8Array | null = null;1292  try {1293    if ((await loadSlot(SLOT_A)) !== null) {1294      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1295    }12961297    const nextEntries: VaultEntry[] = [];1298    const nextDuressConfigured = false;1299    const vaultId = await computeVaultId(rootKeyBytes);1300    const salt = generateSalt();1301    nextDerivedKey = await deriveKey(password, salt);1302    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);13031304    sessionRootKey = new Uint8Array(rootKeyBytes);1305    const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1306    const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1307    const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1308    const slotA: StoredVault = {1309      version: 1,1310      salt: hex(salt),1311      nonce,1312      ciphertext,1313      tag,1314      recovery_salt: recovery.recoverySalt,1315      recovery_wrapped_root_key: recovery.wrappedRootKey,1316      recovery_nonce: recovery.wrappedNonce,1317      recovery_tag: recovery.wrappedTag,1318      ...pwRoot,1319    };1320    const slotB = generateNoiseBlob(ciphertext.length / 2);1321    await commitCreatedVaultSlots(slotA, slotB);13221323    const previousDerivedKey = derivedKey;1324    const previousRootKey = rootKey;1325    derivedKey = nextDerivedKey;1326    rootKey = sessionRootKey;1327    nextDerivedKey = null;1328    sessionRootKey = null;1329    entries = nextEntries;1330    tombstones = new Map();1331    vaultMode = "real";1332    duressConfigured = nextDuressConfigured;1333    buildDomainIndexSync(entries);1334    if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1335    if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1336    touchAutoLock();13371338    return { vaultId };1339  } finally {1340    rootKeyBytes.fill(0);1341    if (nextDerivedKey) zeroKey(nextDerivedKey);1342    if (sessionRootKey) sessionRootKey.fill(0);1343  }1344}13451346export async function hasVault(): Promise<boolean> {1347  return (await loadSlot(SLOT_A)) !== null;1348}13491350/**1351 * Returns whether the real vault has recorded that a duress passphrase is1352 * configured. Reliable only after a real-mode unlock — before unlock or in1353 * decoy mode, returns false because we cannot read slot A's plaintext.1354 */1355export async function isDuressEnabled(): Promise<boolean> {1356  return vaultMode === "real" && duressConfigured;1357}13581359export async function setupDuress(duressPassword: string): Promise<void> {1360  if (!derivedKey || vaultMode !== "real") {1361    throw new Error("vault must be unlocked in real mode");1362  }1363  // Encrypt the decoy payload (empty entries) under the duress key. Pad to1364  // at least the current slot A ciphertext size so the two slots look the1365  // same at rest at setup time.1366  const slotA = await loadSlot(SLOT_A);1367  if (!slotA) throw new Error("missing slot A");13681369  const decoySalt = generateSalt();1370  const decoyKey = await deriveKey(duressPassword, decoySalt);1371  try {1372    // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1373    // same ceiling by the persistSlotA() call below (duressConfigured is now1374    // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1375    // lengths cannot reveal that duress is configured (audit MEDIUM).1376    const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1377    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1378    await writeSlot(SLOT_B, {1379      version: 1,1380      salt: hex(decoySalt),1381      nonce,1382      ciphertext,1383      tag,1384    });1385  } finally {1386    zeroKey(decoyKey);1387  }13881389  // Re-encrypt slot A with the duressConfigured flag set to true. No external1390  // flag is written — the bit lives inside the encrypted envelope.1391  duressConfigured = true;1392  await persistSlotA();1393}13941395export async function disableDuress(): Promise<void> {1396  if (!derivedKey || vaultMode !== "real") {1397    throw new Error("vault must be unlocked in real mode");1398  }1399  // Overwrite slot B with fresh noise that matches slot A's size. The slot1400  // remains occupied so its mere existence does not signal that duress was1401  // ever configured.1402  const slotA = await loadSlot(SLOT_A);1403  if (slotA) {1404    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1405  }14061407  duressConfigured = false;1408  await persistSlotA();1409}14101411// L14/COR-015: this function intentionally checks slot A (real vault) only.1412// A duress-mode user attempting changePassword is rejected earlier by the1413// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1414// distinction here is by design — duress users cannot rotate the real1415// password by guessing it, and the asymmetry in error timing is acceptable1416// because duress mode is opt-in and the user knows their own decoy key.1417export async function verifyMainPassword(password: string): Promise<boolean> {1418  const slotA = await loadSlot(SLOT_A);1419  if (!slotA) return false;1420  const key = await deriveKey(password, unhex(slotA.salt));1421  try {1422    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1423  } finally {1424    zeroKey(key);1425  }1426}14271428export async function unlock(password: string): Promise<boolean> {1429  return attemptUnlockWithDerive(password, deriveKey);1430}14311432async function attemptUnlockWithDerive(1433  password: string,1434  derive: typeof deriveKey,1435): Promise<boolean> {1436  const epoch = vaultEpoch;1437  const stale = () => epoch !== vaultEpoch;1438  // Load both current-format slots in parallel. Both exist on a supported1439  // install; the1440  // parallel load and the parallel derivation below keep wall-clock time1441  // constant regardless of which slot's passphrase (real or decoy) the1442  // caller supplied, so a timing side channel cannot distinguish the1443  // duress path from the real-unlock path (DUR-002).1444  const [slotA, slotB] = await Promise.all([1445    loadSlot(SLOT_A),1446    loadSlot(SLOT_B),1447  ]);14481449  // Derive both keys in parallel, unconditionally.1450  const [keyA, keyB] = await Promise.all([1451    slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1452    slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1453  ]);14541455  // Attempt both decrypts in parallel. The Result variant lets us distinguish1456  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1457  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1458  const [resultA, resultB] = await Promise.all([1459    keyA && slotA1460      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1461      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1462    keyB && slotB1463      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1464      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1465  ]);1466  const payloadA = resultA.status === "ok" ? resultA.payload : null;1467  const payloadB = resultB.status === "ok" ? resultB.payload : null;14681469  if (stale()) {1470    if (keyA) zeroKey(keyA);1471    if (keyB) zeroKey(keyB);1472    return false;1473  }14741475  if (payloadA && keyA) {1476    if (stale()) {1477      zeroKey(keyA);1478      if (keyB) zeroKey(keyB);1479      return false;1480    }1481    derivedKey = keyA;1482    vaultMode = "real";1483    entries = payloadA.entries;1484    tombstones = payloadA.tombstones;1485    duressConfigured = payloadA.duressConfigured;1486    buildDomainIndexSync(entries);1487    await loadSettings();1488    if (stale()) {1489      await lock();1490      return false;1491    }1492    touchAutoLock();1493    if (keyB) zeroKey(keyB);14941495    // Current vaults always carry a password-wrapped root key. Older vaults1496    // without this escrow are outside the supported storage format.1497    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1498    if (!rootKey) {1499      await lock();1500      return false;1501    }15021503    // PB-26-213: adopt legacy plaintext pair secrets into the encrypted1504    // record and refresh the trusted-session operational copy.1505    await reconcilePairSecrets(rootKey);1506    if (stale()) {1507      await lock();1508      return false;1509    }1510    await lockedSyncUnlockHook?.().catch((error) => {1511      console.warn("[Peach sync] locked inbox drain failed:", error);1512    });1513    if (stale()) {1514      await lock();1515      return false;1516    }1517    await resetPinFailedAttempts();1518    if (stale()) {1519      await lock();1520      return false;1521    }1522    await saveSessionKey(keyA, "real", epoch);1523    if (stale()) return false;1524    return true;1525  }15261527  if (payloadB && keyB) {1528    if (stale()) {1529      zeroKey(keyB);1530      if (keyA) zeroKey(keyA);1531      return false;1532    }1533    derivedKey = keyB;1534    vaultMode = "decoy";1535    entries = payloadB.entries;1536    tombstones = payloadB.tombstones;1537    duressConfigured = false;1538    buildDomainIndexSync(entries);1539    await loadSettings();1540    touchAutoLock();1541    if (keyA) zeroKey(keyA);1542    // PB-26-213: a duress session must never serve the real pair-secret map.1543    await dropSessionCache();1544    if (stale()) {1545      await lock();1546      return false;1547    }1548    await saveSessionKey(keyB, "decoy", epoch);1549    if (stale()) return false;1550    return true;1551  }15521553  if (keyA) zeroKey(keyA);1554  if (keyB) zeroKey(keyB);1555  // Surface corruption (good AES tag, broken schema) distinctly from1556  // wrong-password so the popup can offer history-restore (M7/COR-007).1557  if (resultA.status === "corrupted") {1558    lastUnlockFailureReason = resultA.reason;1559  } else if (resultB.status === "corrupted") {1560    lastUnlockFailureReason = resultB.reason;1561  }1562  return false;1563}15641565// ---------------------------------------------------------------------------1566// MV3 service worker session key — survives SW restarts within a browser session1567// ---------------------------------------------------------------------------15681569// chrome.storage.session is cleared on browser close; survives SW restart.1570// Firefox does not implement it (persistent background script, no restart risk).1571const SESSION_KEY_STORAGE = "peach_session_dk";15721573// Order writes and removals so lock's awaited removal is a barrier after1574// every escrow write that started before it.1575let sessionKeyMutation = Promise.resolve();15761577function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1578  const task = sessionKeyMutation.then(mutation, mutation);1579  sessionKeyMutation = task.catch(() => undefined);1580  return task;1581}15821583// PB-26-203: the session-key escrow may only operate where startup positively1584// established trusted-only session storage. Defaults to available so direct1585// vault imports (tests, non-entry bootstraps) keep working; entry.ts applies1586// the real hardening result before the first restore.1587let sessionEscrowAvailable = true;15881589/**1590 * Apply the storage-hardening result at startup. When the trusted-only1591 * session state cannot be established, any stored session key is removed and1592 * the escrow stays off: the user unlocks again after a worker restart1593 * instead of trusting an unproven boundary.1594 */1595export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1596  setSessionAllowed(sessionEscrow);1597  if (sessionEscrow) return;1598  sessionEscrowAvailable = false;1599  await clearSessionKey();1600}16011602async function saveSessionKey(1603  key: Uint8Array,1604  mode: "real" | "decoy",1605  expectedEpoch = vaultEpoch,1606): Promise<void> {1607  const session = chrome.storage.session;1608  if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1609  const stored = { keyHex: hex(key), mode };1610  await mutateSessionKey(async () => {1611    if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1612    await session.set({ [SESSION_KEY_STORAGE]: stored });1613  });1614}16151616async function clearSessionKey(): Promise<void> {1617  const session = chrome.storage.session;1618  if (!session) return;1619  await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1620}16211622// A lock that only holds while the service worker stays alive is no lock at1623// all: a cold worker's restoreFromSession() would find the surviving session1624// key and silently re-unlock ~a minute after the user locked. So lock()1625// bumps this epoch synchronously at entry and zeroes keys/indexes before any1626// await; a restore that started earlier sees the mismatch after its next1627// await and aborts instead of publishing.1628let vaultEpoch = 0;16291630// One in-flight restore shared by concurrent callers: storage is read once1631// and the locked-sync unlock hook runs once.1632let restoreInFlight: Promise<boolean> | null = null;16331634/**1635 * On SW restart, re-derive all in-memory vault state from the session key1636 * stored in chrome.storage.session (set at unlock time, cleared by lock();1637 * also cleared on browser close or when restoration fails).1638 * Returns true if the vault was restored, false if no session key was found.1639 * No-ops if the vault is already unlocked.1640 */1641export async function restoreFromSession(): Promise<boolean> {1642  if (derivedKey !== null) return true;1643  if (restoreInFlight) return restoreInFlight;1644  const task = restoreFromSessionInner();1645  restoreInFlight = task;1646  try {1647    return await task;1648  } finally {1649    restoreInFlight = null;1650  }1651}16521653async function restoreFromSessionInner(): Promise<boolean> {1654  const epoch = vaultEpoch;1655  const stale = () => epoch !== vaultEpoch;1656  if (derivedKey !== null) return true;1657  if (!sessionEscrowAvailable) return false;1658  if (!chrome.storage.session) return false;1659  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1660  if (stale()) return false;1661  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1662  if (!entry?.keyHex) return false;16631664  const key = unhex(entry.keyHex);1665  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1666  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1667  const slot = await loadSlot(slotKey);1668  if (stale()) { zeroKey(key); return false; }1669  if (!slot) { await clearSessionKey(); return false; }16701671  const result = await tryDecryptSlotResult(slot, key, aadTag);1672  if (stale()) { zeroKey(key); return false; }1673  if (result.status !== "ok") { await clearSessionKey(); return false; }16741675  // Decrypted payload stays in locals until the final epoch-checked commit1676  // below; nothing observable is published before then.1677  const payload = result.payload;1678  await loadSettings();1679  if (stale()) { zeroKey(key); return false; }16801681  // Rehydrate the root key used by sync identity derivation from the password1682  // escrow stored on the current vault format.1683  let freshRoot: Uint8Array | null = null;1684  if (entry.mode === "real") {1685    freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1686    if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1687    if (!freshRoot) {1688      await lock();1689      return false;1690    }1691  }16921693  if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1694  if (freshRoot) {1695    // PB-26-213: encrypted pair-secret record + session copy, before publish.1696    await reconcilePairSecrets(freshRoot);1697    if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1698  } else {1699    // PB-26-213: a duress restore must never serve the real pair-secret map.1700    await dropSessionCache();1701    if (stale()) { zeroKey(key); return false; }1702  }1703  derivedKey = key;1704  vaultMode = entry.mode;1705  entries = payload.entries;1706  tombstones = payload.tombstones;1707  duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1708  if (freshRoot) rootKey = freshRoot;1709  buildDomainIndexSync(entries);1710  touchAutoLock();17111712  await lockedSyncUnlockHook?.().catch((error) => {1713    console.warn("[Peach sync] locked inbox drain failed:", error);1714  });1715  if (stale()) return false;17161717  return true;1718}17191720export async function lock(): Promise<void> {1721  // A user-initiated lock must be durable across MV3 service-worker restarts.1722  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1723  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1724  // If the session key survived lock(), that restore would silently re-unlock1725  // the vault ~a minute after the user locked it — a lock that only held1726  // while the SW happened to stay alive. The background-sync convenience the1727  // session key once bought (sync runner re-waking to a restored vault) is1728  // not worth an unlocked vault the user never asked to unlock; when locked,1729  // sync-runner correctly reports VAULT_LOCKED instead.1730  vaultEpoch++;1731  if (derivedKey) {1732    zeroKey(derivedKey);1733    derivedKey = null;1734  }1735  if (rootKey) {1736    rootKey.fill(0);1737    rootKey = null;1738  }17391740  entries = [];1741  tombstones = new Map();1742  domainIndex = new Map();1743  vaultMode = "real";1744  duressConfigured = false;1745  clearAutoLock();1746  await clearSessionKey();1747  await clearRecoveryConfirmPending();1748}17491750export function isUnlocked(): boolean {1751  return derivedKey !== null;1752}17531754// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1755// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1756// always derives from scratch via Argon2id.1757export async function unlockWithKey(1758  masterKeyHex: string,1759): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1760  const epoch = vaultEpoch;1761  const stale = () => epoch !== vaultEpoch;1762  let masterKeyBytes: Uint8Array | null = null;1763  try {1764    masterKeyBytes = unhex(masterKeyHex);1765    const stored = await loadSlot(SLOT_A);1766    if (!stored) return { ok: false, error: "invalid_key" };1767    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1768    if (!payload || stale()) {1769      zeroKey(masterKeyBytes);1770      return { ok: false, error: "invalid_key" };1771    }17721773    derivedKey = masterKeyBytes;1774    vaultMode = "real";1775    entries = payload.entries;1776    tombstones = payload.tombstones;1777    duressConfigured = payload.duressConfigured;1778    buildDomainIndexSync(entries);1779    await loadSettings();1780    touchAutoLock();17811782    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1783    if (!rootKey) {1784      await lock();1785      return { ok: false, error: "invalid_key" };1786    }1787    await reconcilePairSecrets(rootKey);1788    if (stale()) {1789      await lock();1790      return { ok: false, error: "invalid_key" };1791    }1792    await saveSessionKey(masterKeyBytes, "real", epoch);1793    if (stale()) return { ok: false, error: "invalid_key" };1794    return { ok: true, entries };1795  } catch {1796    if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1797    return { ok: false, error: "invalid_key" };1798  }1799}18001801// --- Adoption duress-slot helpers (passthrough, no decrypt) ---18021803/**1804 * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1805 * duress is configured and slot B exists, so the inline snapshot can ship it1806 * to the receiver. Returns null otherwise. Never decrypts.1807 */1808export async function exportDuressSlotForAdoption(): Promise<string | null> {1809  if (!(await isDuressEnabled())) return null;1810  const slotB = await loadSlot(SLOT_B);1811  if (!slotB) return null;1812  return JSON.stringify(slotB);1813}18141815/**1816 * Adoption RECEIVER helper: installs a duress slot received from a source1817 * device. Validates the StoredVault shape (same guard as unlockFromCodex)1818 * and writes it to slot B. Throws on invalid shape so the caller can treat1819 * it as a non-fatal snapshot failure. Never decrypts.1820 */1821export async function installAdoptedDuressSlot(json: string): Promise<void> {1822  let parsed: StoredVault;1823  try {1824    const raw = JSON.parse(json) as unknown;1825    if (!hasValidVaultSchema(raw)) {1826      throw new Error("invalid_duress_slot_shape");1827    }1828    parsed = raw;1829  } catch (err) {1830    if (err instanceof SyntaxError) {1831      throw new Error("invalid_duress_slot_shape");1832    }1833    throw err;1834  }1835  await writeSlot(SLOT_B, parsed);1836}18371838/**1839 * Restore the vault from a parsed Peach Codex payload.1840 *1841 * The supplied passphrase is verified against the Codex's slotA *before* any1842 * storage mutation — wrong passphrases return without touching disk. On1843 * success, the current vault state is snapshotted (reason: "restore") so the1844 * user can revert via Settings -> Vault History, then slotA/slotB are written1845 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A1846 * regular `unlock()` follows so the rest of the SW sees the standard1847 * post-unlock state.1848 *1849 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex1850 * printed by a Pro user must always be restorable, even on a free device.1851 *1852 * Timing is not held constant here (in contrast to `unlock`): the Codex flow1853 * is an explicit user action with a known passphrase, not a passive lock1854 * screen, so the duress-vs-real timing side channel does not apply.1855 */1856export async function unlockFromCodex(params: {1857  slotA: string; // JSON string of StoredVault for slot A1858  slotB: string | null; // JSON string of StoredVault for slot B, or null1859  timestamp: number;1860  masterPassphrase: string;1861}): Promise<1862  | { ok: true; entryCount: number }1863  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1864> {1865  let parsedSlotA: StoredVault;1866  let parsedSlotB: StoredVault | null = null;1867  try {1868    const rawA = JSON.parse(params.slotA) as unknown;1869    if (!hasValidVaultSchema(rawA)) {1870      return { ok: false, reason: "invalid_slot_format" };1871    }1872    parsedSlotA = rawA;1873    if (params.slotB !== null) {1874      const rawB = JSON.parse(params.slotB) as unknown;1875      if (!hasValidVaultSchema(rawB)) {1876        return { ok: false, reason: "invalid_slot_format" };1877      }1878      parsedSlotB = rawB;1879    }1880  } catch {1881    return { ok: false, reason: "invalid_slot_format" };1882  }18831884  // Verify the passphrase against the supplied slotA WITHOUT writing anything.1885  // If this fails the on-disk vault is left completely untouched.1886  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1887  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1888  if (!trialPayload) {1889    zeroKey(trialKey);1890    return { ok: false, reason: "wrong_passphrase" };1891  }1892  // Drop the trial key; `unlock()` below derives its own copy. Holding two1893  // copies of the master key in memory at once would be a needless surface.1894  zeroKey(trialKey);18951896  // Snapshot the about-to-be-overwritten on-disk state so the user can revert1897  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.1898  await takeVaultSnapshot("restore");18991900  await writeSlot(SLOT_A, parsedSlotA);1901  if (parsedSlotB) {1902    await writeSlot(SLOT_B, parsedSlotB);1903  } else {1904    // Slot B must always exist with ciphertext length matching slot A1905    // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't1906    // configured); regenerate noise to satisfy the invariant.1907    await writeSlot(1908      SLOT_B,1909      generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1910    );1911  }1912  await chrome.storage.local.set({1913    peach_last_modified_at: String(params.timestamp),1914  });19151916  // Standard unlock path so domain index, settings, auto-lock, and1917  // recovery-key state all populate exactly as a normal unlock would.1918  const ok = await unlock(params.masterPassphrase);1919  if (!ok) {1920    // Should be unreachable — we just verified the passphrase against the1921    // same slotA we wrote. Treat as wrong_passphrase to keep the contract1922    // simple if some race or storage anomaly intrudes.1923    return { ok: false, reason: "wrong_passphrase" };1924  }1925  return { ok: true, entryCount: getEntries().length };1926}19271928// Returns the current in-memory derived key as a hex string. Only the PIN1929// unlock path needs this value, and it reads module state directly; this1930// accessor exists so unlockWithKey's root-key rehydration is testable.1931export /* @testable */ function getDerivedKeyHex(): string {1932  if (!derivedKey) throw new Error("vault_not_unlocked");1933  return hex(derivedKey);1934}19351936/**1937 * Returns the vault's stable root key as hex, or null when locked / not yet1938 * populated. The root key is random per-vault, generated at creation, escrowed1939 * under the recovery words, and re-wrapped (not regenerated) on every password1940 * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.1941 *1942 * This is the correct input for sync identity derivation (vault_id, the Noise1943 * NK static key, the Ed25519 manifest signing key, and per-entry envelope1944 * keys): paired devices keep the same identity and can still decrypt each1945 * other's entries after either side rotates its master password (SEV-2-B).1946 *1947 * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED1948 * instead of crashing; rootKey is populated on every supported real-mode1949 * unlock via deriveRootKeyFromDerivedKey.1950 */1951export function getRootKeyHex(): string | null {1952  return rootKey ? hex(rootKey) : null;1953}19541955export function getEntries(): VaultEntry[] {1956  if (!derivedKey) throw new Error("vault_locked");1957  return entries.filter((entry) => !entry.deletedAt);1958}19591960const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;19611962export function getIdentities(): SavedIdentity[] {1963  if (!derivedKey) throw new Error("vault_locked");1964  const active = entries.filter((e) => !e.deletedAt && e.type === "login");19651966  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1967  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();19681969  for (const entry of active) {1970    const domain = extractDomain(entry.url ?? "") ?? "";1971    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;19721973    function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1974      const existing = map.get(v);1975      if (!existing || modifiedAt > existing.lastUsed) {1976        map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1977      } else {1978        map.set(v, { ...existing, count: existing.count + 1 });1979      }1980    }19811982    const uVal = entry.username?.trim();1983    if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);19841985    // Also count the dedicated email field — entries created via the inline1986    // popup new-entry form store email here rather than in username.1987    const eVal = entry.email?.trim();1988    if (eVal) tally(emailMap, eVal);1989  }19901991  const toIdentity = (type: "email" | "username") =>1992    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1993      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1994    });19951996  return [1997    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1998    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1999  ];2000}20012002export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2003  if (!derivedKey) throw new Error("vault_locked");20042005  const now = Date.now();2006  const added: VaultEntry[] = [];2007  for (const draft of drafts) {2008    try {2009      const sanitized = sanitizeEntryDraft(draft);2010      const nextEntry: VaultEntry = {2011        ...sanitized,2012        id: crypto.randomUUID(),2013        createdAt: now,2014        modifiedAt: monotonicEntryTimestamp(),2015      };2016      entries.push(nextEntry);2017      added.push(nextEntry);2018    } catch {2019      // Skip invalid drafts silently — caller can see diff between input and output length2020    }2021  }20222023  if (added.length > 0) {2024    await persistEntries();2025  }20262027  return added;2028}20292030/**2031 * Restore a set of entries in full fidelity — preserves id, createdAt,2032 * modifiedAt, and every other field unlike `batchAddEntries` which2033 * assigns fresh identity. Used by restore paths where the backup is the2034 * authoritative source of truth. `mode` controls how entries already in2035 * the vault are treated.2036 */2037export async function restoreEntries(2038  incoming: VaultEntry[],2039  mode: "replace" | "merge"2040): Promise<{ added: number; updated: number; skipped: number }> {2041  if (!derivedKey) throw new Error("vault_locked");20422043  // A replace with zero incoming entries is almost certainly a caller bug2044  // (e.g. an import that silently dropped everything). Refuse rather than2045  // wipe the vault.2046  if (mode === "replace" && incoming.length === 0) {2047    throw new Error("refuse to replace with empty entry set");2048  }20492050  let added = 0;2051  let updated = 0;2052  let skipped = 0;20532054  if (mode === "replace") {2055    entries = [];2056    for (const entry of incoming) {2057      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2058      if (!normalized) {2059        skipped += 1;2060        continue;2061      }2062      entries.push(normalized);2063      if (normalized.deletedAt) {2064        tombstones.set(2065          normalized.id,2066          Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2067        );2068      } else {2069        tombstones.delete(normalized.id);2070      }2071      added += 1;2072    }2073  } else {2074    const byId = new Map<string, VaultEntry>();2075    for (const e of entries) byId.set(e.id, e);20762077    for (const entry of incoming) {2078      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2079      if (!normalized) {2080        skipped += 1;2081        continue;2082      }2083      const existing = byId.get(normalized.id);2084      if (!existing) {2085        entries.push(normalized);2086        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2087        else tombstones.delete(normalized.id);2088        added += 1;2089      } else if (normalized.modifiedAt > existing.modifiedAt) {2090        const idx = entries.findIndex((e) => e.id === existing.id);2091        if (idx !== -1) {2092          entries[idx] = normalized;2093          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2094          else tombstones.delete(normalized.id);2095          updated += 1;2096        }2097      } else {2098        skipped += 1;2099      }2100    }2101  }21022103  await persistEntries("import");2104  return { added, updated, skipped };2105}21062107/**2108 * A Peach entry id is a UUID. A restored or imported file carries whatever id2109 * its author used, and no reader bounds it, so one oversized id would ride2110 * into every future sync frame and be rejected there — a permanent, opaque2111 * failure on both platforms, caused by data that was already local.2112 *2113 * The action is mint, not reject: this is the last point where an id is still2114 * ours to choose, so the worst outcome is one duplicate if the same vault is2115 * later restored beside a device still holding the old id. Rejecting would2116 * lose the user's import.2117 *2118 * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries2119 * also calls. Sync has to adopt a peer's id verbatim — that is the only thing2120 * keeping two devices agreed on which entry they are discussing — so reminting2121 * there would fork identity on every device, on every round.2122 */2123const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;21242125function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2126  if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2127  return { ...entry, id: crypto.randomUUID() };2128}21292130/**2131 * Validate + normalise a VaultEntry coming from a backup, import, or sync2132 * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies2133 * only known VaultEntry fields (unknown/attacker keys are dropped), and2134 * nested `passkey`/`canary` shapes are rebuilt field-by-field, never2135 * spread. Returns null for values the live vault shouldn't accept.2136 */2137function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2138  if (!entry || typeof entry !== "object") return null;2139  const title = typeof entry.title === "string" ? entry.title.trim() : "";2140  if (!title) return null;2141  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2142  const now = Date.now();2143  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2144  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;21452146  // normalizeEntry whitelists every known VaultEntry field before the entry2147  // reaches the live vault.2148  const normalized = normalizeEntry({2149    ...entry,2150    id,2151    title,2152    type: entry.type ?? "login",2153    createdAt,2154    modifiedAt,2155  });21562157  if (normalized.passkey) {2158    normalized.passkey = sanitizePasskey(normalized.passkey);2159  }2160  if (normalized.canary) {2161    normalized.canary = sanitizeCanary(normalized.canary);2162  }2163  // PCH-01-016: bound tags on the restore path too, so both import funnels agree.2164  normalized.tags = sanitizeTags(normalized.tags);21652166  // Login entries from backup must carry a password (or be passkey-only,2167  // in which case the passkey sub-object replaces the password requirement).2168  if (2169    normalized.type === "login" &&2170    !normalized.passkey &&2171    typeof normalized.password !== "string"2172  ) {2173    return null;2174  }2175  if (entry.passkey && !normalized.passkey) {2176    // Entry claimed a passkey but the shape didn't validate — refuse the2177    // whole entry rather than silently importing a credential-less husk.2178    return null;2179  }21802181  return normalized;2182}21832184/**2185 * Rebuild a PasskeyData object from untrusted input, field by field.2186 * Returns undefined when required fields are missing/mistyped — a rogue2187 * passkey credential must not activate via import (MV-6 attack scenario).2188 */2189function sanitizePasskey(pk: unknown): PasskeyData | undefined {2190  if (!pk || typeof pk !== "object") return undefined;2191  const r = pk as Record<string, unknown>;2192  if (2193    typeof r.rpId !== "string" || !r.rpId ||2194    typeof r.credentialId !== "string" || !r.credentialId ||2195    typeof r.publicKeyCose !== "string" ||2196    typeof r.userHandle !== "string" ||2197    typeof r.userName !== "string"2198  ) {2199    return undefined;2200  }2201  return {2202    rpId: r.rpId,2203    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2204    credentialId: r.credentialId,2205    publicKeyCose: r.publicKeyCose,2206    privateKeyJwk:2207      r.privateKeyJwk && typeof r.privateKeyJwk === "object"2208        ? (r.privateKeyJwk as JsonWebKey)2209        : undefined,2210    userHandle: r.userHandle,2211    userName: r.userName,2212    userDisplayName:2213      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2214    algorithm: r.algorithm === -8 ? -8 : -7,2215    signCount: typeof r.signCount === "number" ? r.signCount : 0,2216    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2217    backupEligible: r.backupEligible === true,2218    backupState: r.backupState === true,2219    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2220    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2221    transports: Array.isArray(r.transports)2222      ? r.transports.filter((t): t is string => typeof t === "string")2223      : undefined,2224    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2225  };2226}22272228/**2229 * Rebuild a Canary object from untrusted input. The webhook must be a2230 * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing2231 * exfiltration endpoint that the canary subsystem will POST entry titles2232 * to (MV-6 attack scenario). Invalid → the canary is dropped, not the2233 * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire2234 * time (isAllowedCanaryWebhookUrl), so this import-time check is the2235 * outer of two gates.2236 */2237function sanitizeCanary(c: unknown): Canary | undefined {2238  if (!c || typeof c !== "object") return undefined;2239  const r = c as Record<string, unknown>;2240  if (typeof r.webhookUrl !== "string") return undefined;2241  let url: URL;2242  try {2243    url = new URL(r.webhookUrl);2244  } catch {2245    return undefined;2246  }2247  if (url.protocol !== "https:") return undefined;2248  return {2249    webhookUrl: url.toString(),2250    label: typeof r.label === "string" ? r.label : undefined,2251    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2252    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2253  };2254}22552256/**2257 * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted.2258 * Keep only non-empty strings, cap each at 256 chars, and cap the count at 2552259 * (the same per-entry ceiling the export codec enforces, peachPayload.ts).2260 * Non-array or all-invalid input collapses to undefined.2261 */2262function sanitizeTags(tags: unknown): string[] | undefined {2263  if (!Array.isArray(tags)) return undefined;2264  const out: string[] = [];2265  for (const t of tags) {2266    if (typeof t !== "string") continue;2267    const trimmed = t.trim();2268    if (!trimmed) continue;2269    out.push(trimmed.slice(0, 256));2270    if (out.length >= 255) break;2271  }2272  return out.length > 0 ? out : undefined;2273}22742275// Soft-deletes every non-deleted entry in one call. The literal string2276// argument is a tripwire against accidental calls — any caller that would2277// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this2278// to a boolean.2279export async function clearVaultEntries(2280  confirmation: "REPLACE_ALL"2281): Promise<void> {2282  if (confirmation !== "REPLACE_ALL") {2283    throw new Error("clearVaultEntries requires explicit confirmation");2284  }2285  if (!derivedKey) throw new Error("vault_locked");2286  for (const entry of entries) {2287    if (!entry.deletedAt) {2288      const stamp = monotonicEntryTimestamp(Math.max(2289        entry.modifiedAt,2290        tombstones.get(entry.id) ?? 0,2291      ));2292      entry.deletedAt = stamp;2293      entry.modifiedAt = stamp;2294      tombstones.set(entry.id, stamp);2295    }2296  }2297  await persistEntries();2298}22992300export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2301  if (!derivedKey) throw new Error("vault_locked");23022303  const sanitized = sanitizeEntryDraft(entry);2304  const now = Date.now();2305  const stamp = monotonicEntryTimestamp();2306  const nextEntry: VaultEntry = {2307    ...sanitized,2308    id: crypto.randomUUID(),2309    createdAt: now,2310    modifiedAt: stamp,2311    // Stamp passwordChangedAt at creation so age tracking is accurate from2312    // day one. Non-login entries carry the same stamp harmlessly.2313    passwordChangedAt: sanitized.password ? stamp : undefined,2314  };23152316  // Clone-and-replace so a failed persist rolls back the in-memory state2317  // (COR-008). Without this, a quota-full or storage error leaves the2318  // module-level `entries` ahead of what's actually on disk.2319  const rollback = entries;2320  entries = [...entries, nextEntry];2321  try {2322    await persistEntries();2323  } catch (err) {2324    entries = rollback;2325    throw err;2326  }23272328  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)2329  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2330    checkBreach(nextEntry.password).then(async (count) => {2331      if (count > 0) {2332        const idx = entries.findIndex((e) => e.id === nextEntry.id);2333        if (idx !== -1) {2334          entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2335          await persistEntries();2336        }2337      }2338    });2339  }23402341  fireSyncTrigger("vault_change");23422343  return nextEntry;2344}23452346/**2347 * Run the passkey-merge pass on the live in-memory entries and persist if2348 * anything changed. Called immediately after any passkey registration so that2349 * a same-domain TOTP-only entry already in the vault gets consolidated right2350 * away instead of waiting until the next lock/unlock cycle.2351 */2352export async function consolidatePasskeyEntries(): Promise<void> {2353  if (!derivedKey) return;2354  const { merged } = mergePasskeyOnlyIntoLogins(entries);2355  if (merged) {2356    await persistEntries();2357  }2358}23592360export async function updateEntry(2361  id: string,2362  patch: Partial<VaultEntry>2363): Promise<void> {2364  if (!derivedKey) throw new Error("vault_locked");23652366  const idx = entries.findIndex((entry) => entry.id === id);2367  if (idx === -1 || entries[idx].deletedAt) {2368    throw new Error("entry_not_found");2369  }23702371  const current = entries[idx];2372  const sanitizedPatch = sanitizeEntryPatch(patch);2373  // Refresh passwordChangedAt only when the password value actually changes —2374  // a metadata-only update (title, tags, etc.) must not reset the age clock2375  // that drives the Pro health rotation recommendation.2376  const passwordChanged =2377    "password" in patch &&2378    typeof sanitizedPatch.password === "string" &&2379    sanitizedPatch.password !== current.password;2380  const stamp = monotonicEntryTimestamp(current.modifiedAt);2381  // When the password actually changes, push the outgoing one onto the history2382  // (newest first) so the user can reveal/copy/restore it later. We record the2383  // age it carried (passwordChangedAt, falling back to createdAt) rather than2384  // `now`, so the list reflects when each password was last in effect. Only2385  // capture a non-empty prior value — clearing a password leaves no secret to2386  // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)2387  // to avoid noise.2388  const priorPassword = current.password;2389  const shouldCapture =2390    passwordChanged &&2391    typeof priorPassword === "string" &&2392    priorPassword.length > 0 &&2393    current.passwordHistory?.[0]?.password !== priorPassword;2394  const nextHistory = shouldCapture2395    ? [2396        { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2397        ...(current.passwordHistory ?? []),2398      ].slice(0, PASSWORD_HISTORY_MAX)2399    : current.passwordHistory;2400  entries[idx] = {2401    ...current,2402    ...sanitizedPatch,2403    modifiedAt: stamp,2404    ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2405  };2406240724082409  // Card entries must retain their card number2410  if (entries[idx].type === "card" && !entries[idx].cardNumber) {2411    entries[idx] = current;2412    throw new Error("card number is required for card entries");2413  }24142415  // Roll the in-memory entry back to its pre-edit state if the disk write2416  // fails (e.g. storage quota). Without this the module-level `entries` array2417  // would hold an edit that never landed on disk, diverging from the vault2418  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.2419  try {2420    await persistEntries();2421  } catch (err) {2422    entries[idx] = current;2423    throw err;2424  }24252426  // Breach check when password changes: clear immediately so old breach doesn't linger,2427  // then async-check the new password and only re-set if it's also found.2428  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2429    entries[idx] = { ...entries[idx], breach: undefined };2430    await persistEntries();2431    const password = entries[idx].password;2432    if (password) {2433      checkBreach(password).then(async (count) => {2434        const i = entries.findIndex((e) => e.id === id);2435        if (i !== -1 && count > 0) {2436          entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2437          await persistEntries();2438        }2439      });2440    }2441  }24422443  fireSyncTrigger("vault_change");2444}24452446export async function deleteEntry(id: string): Promise<void> {2447  if (!derivedKey) throw new Error("vault_locked");24482449  const entry = entries.find((item) => item.id === id);2450  if (!entry || entry.deletedAt) {2451    throw new Error("entry_not_found");2452  }24532454  // Tombstone timestamp must be strictly greater than the entry's last2455  // modification so the deletion wins LWW against any in-flight edit, even2456  // under clock skew (SEV-2-E).2457  const previousModifiedAt = entry.modifiedAt;2458  const previousTombstone = tombstones.get(id);2459  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2460  entry.deletedAt = stamp;2461  entry.modifiedAt = stamp;2462  tombstones.set(id, stamp);2463  // Roll the tombstone back if the disk write fails, so a quota error doesn't2464  // leave the entry deleted in memory but alive on disk (SEV-3-A).2465  try {2466    await persistEntries();2467  } catch (err) {2468    delete entry.deletedAt;2469    entry.modifiedAt = previousModifiedAt;2470    if (previousTombstone === undefined) tombstones.delete(id);2471    else tombstones.set(id, previousTombstone);2472    throw err;2473  }2474  fireSyncTrigger("vault_change");2475}24762477export function getDeletedEntries(): VaultEntry[] {2478  if (!derivedKey) return [];2479  return entries.filter((e) => !!e.deletedAt);2480}24812482/** Return all durable deletion timestamps, including purged full entries. */2483export function getTombstoneManifest(): Record<string, number> {2484  if (!derivedKey) return {};2485  return Object.fromEntries(tombstones);2486}24872488/**2489 * Returns the vault's monotonic version — the `peach_last_modified_at` value2490 * stamped on every user-initiated write. Remote (sync) writes do NOT advance2491 * this version so a sync round never cascades into a second outbound sync.2492 */2493export async function getVaultVersion(): Promise<number> {2494  try {2495    const raw = await chrome.storage.local.get("peach_last_modified_at");2496    const v = (raw as Record<string, unknown>).peach_last_modified_at;2497    if (typeof v === "number") return v;2498    if (typeof v === "string" && v !== "") {2499      const n = Number.parseInt(v, 10);2500      return Number.isFinite(n) ? n : 0;2501    }2502    return 0;2503  } catch {2504    return 0;2505  }2506}25072508/**2509 * Apply a batch of remote entries + tombstones from a sync peer.2510 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when2511 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and2512 * persists with reason `sync_received` — does NOT fire the sync dispatcher, so2513 * applying a sync round cannot cascade into another outbound sync.2514 *2515 * `applied` counts entries that actually changed local state (new add,2516 * overwrite-by-newer, or new tombstone). `received` is the raw payload size2517 * the caller can surface in the sync result UI.2518 */2519export async function applyMergedRemoteEntries(2520  remoteEntries: VaultEntry[],2521  remoteTombstones: Record<string, number>,2522): Promise<{ received: number; applied: number; conflicts: number }> {2523  // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices2524  // concurrently edit different fields of the same entry, the one with the2525  // newer modifiedAt wins the whole entry and the other device's field edits2526  // are dropped silently. This is the deliberate trade-off for a simple,2527  // conflict-free merge — per-field CRDT merge is out of scope. Guidance for2528  // users: edit a given entry on one device at a time.2529  if (!derivedKey) throw new Error("vault_locked");25302531  const received = remoteEntries.length + Object.keys(remoteTombstones).length;2532  let applied = 0;2533  let conflicts = 0;25342535  const byId = new Map<string, VaultEntry>();2536  for (const e of entries) byId.set(e.id, e);25372538  const mergeNow = Date.now();2539  const remoteTombstoneTimes = new Map<string, number>();2540  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2541    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2542      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2543    }2544  }25452546  for (const remote of remoteEntries) {2547    const sanitized = sanitizeRestoredEntry(remote);2548    if (!sanitized) continue;2549    // HV-1a: a far-future remote modifiedAt must not permanently pin a2550    // malicious value against honest local edits. Clamp BEFORE any2551    // comparison against local timestamps.2552    sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2553    if (typeof sanitized.deletedAt === "number") {2554      sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2555    }2556    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2557    const localTombstoneTs = tombstones.get(sanitized.id);2558    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2559    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2560      continue;2561    }2562    const local = byId.get(sanitized.id);2563    if (!local) {2564      entries.push(sanitized);2565      byId.set(sanitized.id, sanitized);2566      tombstones.delete(sanitized.id);2567      applied += 1;2568    } else if (sanitized.modifiedAt > local.modifiedAt) {2569      const idx = entries.findIndex((e) => e.id === local.id);2570      if (idx !== -1) {2571        entries[idx] = sanitized;2572        byId.set(sanitized.id, sanitized);2573        tombstones.delete(sanitized.id);2574        applied += 1;2575        if (!local.deletedAt) conflicts += 1;2576      }2577    }2578  }25792580  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2581    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2582    // HV-1a/1c: clamp the tombstone timestamp before every comparison.2583    const deletedAt = remoteTombstoneTimes.get(id)!;2584    const knownTombstone = tombstones.get(id);2585    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2586    const local = byId.get(id);2587    if (!local) {2588      tombstones.set(id, deletedAt);2589      applied += 1;2590      continue;2591    }2592    if (local.deletedAt && local.deletedAt >= deletedAt) continue;2593    if (deletedAt < local.modifiedAt) continue;2594    const idx = entries.findIndex((e) => e.id === id);2595    if (idx !== -1) {2596      entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2597      tombstones.set(id, deletedAt);2598      applied += 1;2599    }2600  }26012602  if (applied > 0) {2603    await persistEntries("sync_received");2604    buildDomainIndexSync(entries);2605  }2606  return { received, applied, conflicts };2607}26082609export async function restoreEntry(id: string): Promise<void> {2610  if (!derivedKey) throw new Error("vault_locked");2611  const entry = entries.find((e) => e.id === id && e.deletedAt);2612  if (!entry) throw new Error("deleted entry not found");2613  const previousDeletedAt = entry.deletedAt;2614  const previousModifiedAt = entry.modifiedAt;2615  const previousTombstone = tombstones.get(id);2616  delete entry.deletedAt;2617  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2618    entry.modifiedAt,2619    previousTombstone ?? 0,2620  ));2621  tombstones.delete(id);2622  try {2623    await persistEntries();2624  } catch (err) {2625    entry.deletedAt = previousDeletedAt;2626    entry.modifiedAt = previousModifiedAt;2627    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2628    throw err;2629  }2630  buildDomainIndexSync(entries);2631}26322633export async function purgeEntry(id: string): Promise<void> {2634  if (!derivedKey) throw new Error("vault_locked");2635  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2636  if (idx === -1) throw new Error("deleted entry not found");2637  const entry = entries[idx]!;2638  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2639  entries.splice(idx, 1);2640  await persistEntries();2641}26422643/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */2644export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2645  if (!derivedKey || retentionMs === 0) return 0;2646  const cutoff = Date.now() - retentionMs;2647  const before = entries.length;2648  for (const entry of entries) {2649    if (entry.deletedAt && entry.deletedAt < cutoff) {2650      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2651    }2652  }2653  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2654  const purged = before - entries.length;2655  if (purged > 0) await persistEntries();2656  return purged;2657}26582659export function queryDomain(domain: string): VaultEntry[] {2660  return domainIndex.get(domain) ?? [];2661}26622663export function getEntryById(id: string): VaultEntry | undefined {2664  return entries.find((entry) => entry.id === id && !entry.deletedAt);2665}26662667async function persistSlotA(2668  reason: VaultSnapshot["reason"] = "local_save",2669): Promise<StoredVault> {2670  if (!derivedKey) throw new Error("vault_locked");2671  const epoch = vaultEpoch;2672  const throwIfStale = () => {2673    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2674  };26752676  const slotA = await loadSlot(SLOT_A);2677  throwIfStale();2678  if (!slotA) throw new Error("missing slot A");26792680  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures2681  // are swallowed by takeVaultSnapshot — history must never block a save.2682  await takeVaultSnapshot(reason);2683  throwIfStale();26842685  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2686  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2687  throwIfStale();26882689  // Preserve existing recovery escrow fields.2690  const nextSlotA: StoredVault = {2691    ...slotA,2692    nonce,2693    ciphertext,2694    tag,2695    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot2696  };26972698  // Slot A (the real vault) is now committed. Wrap every later write so a2699  // mid-persist failure (e.g. a storage error after slot A landed) restores the2700  // prior slot A before rethrowing. Without this, a "failed" save that already2701  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/2702  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces2703  // as a duplicate on the next unlock (observed under storage-quota pressure).2704  let slotACommitted = false;2705  try {2706    throwIfStale();2707    await writeSlot(SLOT_A, nextSlotA);2708    slotACommitted = true;2709    throwIfStale();27102711    // When duress is not configured, keep slot B size-matched with slot A by2712    // regenerating its noise. When duress IS configured, leave slot B alone —2713    // we don't have the decoy key to re-encrypt it. This is a known trade-off.2714    if (!duressConfigured) {2715      throwIfStale();2716      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2717      throwIfStale();2718    }2719    // Only advance the vault version for locally-originated writes. Entries2720    // applied from a sync peer ("sync_received") must NOT bump the counter —2721    // otherwise applying a remote round makes us look newer than the peer and2722    // triggers a redundant outbound sync, defeating the no-op optimisation2723    // and risking a ping-pong between two devices (SEV-2-C).2724    if (reason !== "sync_received") {2725      throwIfStale();2726      await stampLocalVaultMutation();2727      throwIfStale();2728    }2729  } catch (err) {2730    // Best-effort restore — the prior slot A is smaller-or-equal, so this write2731    // fits even when the failure was a quota overflow.2732    if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2733    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2734    throw err;2735  }27362737  return nextSlotA;2738}27392740async function persistSlotB(): Promise<void> {2741  if (!derivedKey) throw new Error("vault_locked");27422743  const slotB = await loadSlot(SLOT_B);2744  if (!slotB) throw new Error("missing slot B");27452746  // Decoy plaintext always reports duressConfigured=false — the decoy user2747  // sees a clean slate. persistSlotB only runs in decoy mode, which only2748  // exists once duress is configured, so pad to the fixed duress ceiling to2749  // keep slot B's length equal to slot A's (audit MEDIUM — deniability).2750  const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2751  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");27522753  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2754  // Symmetric with persistSlotA: if the version stamp fails after the decoy2755  // slot committed, restore the prior slot B so a failed decoy-mode save can't2756  // leave disk ahead of the in-memory rollback.2757  try {2758    await stampLocalVaultMutation();2759  } catch (err) {2760    await writeSlot(SLOT_B, slotB).catch(() => {});2761    throw err;2762  }2763}27642765async function persistEntries(2766  reason: VaultSnapshot["reason"] = "local_save",2767): Promise<void> {2768  if (!derivedKey) throw new Error("vault_locked");27692770  // Reconcile direct soft-delete callers while restoring the previous map if2771  // storage rejects the save.2772  const previousTombstones = tombstones;2773  tombstones = new Map(tombstones);2774  reconcileTombstones();2775  try {2776    if (vaultMode === "decoy") {2777      await persistSlotB();2778    } else {2779      await persistSlotA(reason);2780    }2781  } catch (err) {2782    tombstones = previousTombstones;2783    throw err;2784  }2785  buildDomainIndexSync(entries);2786  await refreshLockedSyncState();2787}27882789// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage2790// boundary. Schemeless values (e.g. "github.com") are inert and kept — they2791// pre-date this guard in existing data. Unparseable values are kept: the2792// reviewed render paths never execute them (favicons via _favicon/, domains2793// via tldts); this is defense-in-depth against future sinks.2794function allowHttpUrl(url: string | undefined): string {2795  const trimmed = url?.trim() ?? "";2796  if (!trimmed) return "";2797  try {2798    const parsed = new URL(trimmed);2799    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2800  } catch {2801    // No scheme to reject.2802  }2803  return trimmed;2804}28052806function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2807  if (!Array.isArray(values)) return undefined;2808  const seen = new Set<string>();2809  const cleaned: string[] = [];2810  for (const value of values) {2811    const trimmed = typeof value === "string" ? value.trim() : "";2812    if (!trimmed) continue;2813    if (!allowHttpUrl(trimmed)) continue;2814    const key = trimmed.toLowerCase();2815    if (seen.has(key)) continue;2816    seen.add(key);2817    cleaned.push(trimmed);2818  }2819  return cleaned.length > 0 ? cleaned : undefined;2820}28212822function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2823  const title = entry.title.trim();2824  const type: VaultEntry["type"] = entry.type ?? "login";28252826  if (!title) throw new Error("title is required");28272828  if (entry.passkey) {2829    const p = entry.passkey;2830    if (2831      !p.rpId ||2832      !p.credentialId ||2833      !p.publicKeyCose ||2834      !p.userHandle ||2835      !p.userName ||2836      (p.algorithm !== -7 && p.algorithm !== -8)2837    ) {2838      throw new Error("invalid passkey credential");2839    }2840  }2841  if (type === "card") {2842    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2843      throw new Error("cardholder name and card number are required");2844    }2845  }28462847  return {2848    type,2849    title,2850    username: type === "login" ? entry.username?.trim() ?? "" : undefined,2851    email: type === "login" ? entry.email?.trim() || undefined : undefined,2852    password: type === "login" ? entry.password : undefined,2853    url: type === "login" ? allowHttpUrl(entry.url) : undefined,2854    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2855    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2856    // PCH-01-019: preserve non-default TOTP params through batch-add/import so2857    // they are not silently stripped (they fall back to SHA-1/6/30 when absent).2858    totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2859    totpDigits: type === "login" ? entry.totpDigits : undefined,2860    totpPeriod: type === "login" ? entry.totpPeriod : undefined,2861    notes: entry.notes?.trim() || undefined,2862    breach: type === "login" ? entry.breach : undefined,2863    // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags2864    // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here.2865    canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2866    tags: sanitizeTags(entry.tags),2867    passkey: entry.passkey,2868    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2869    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2870    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2871    expYear: type === "card" ? entry.expYear?.trim() : undefined,2872    cvc: type === "card" ? entry.cvc?.trim() : undefined,2873    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2874    network: type === "card" ? entry.network : undefined,2875    last4: type === "card" ? entry.last4?.trim() : undefined,2876  };2877}28782879function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2880  const nextPatch: Partial<VaultEntry> = {};28812882  if ("type" in patch) {2883    nextPatch.type = patch.type;2884  }28852886  if ("title" in patch) {2887    const title = patch.title?.trim() ?? "";2888    if (!title) throw new Error("title is required");2889    nextPatch.title = title;2890  }28912892  if ("url" in patch) {2893    nextPatch.url = allowHttpUrl(patch.url);2894  }28952896  if ("additionalUrls" in patch) {2897    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2898    const seen = new Set<string>();2899    const cleaned: string[] = [];2900    for (const value of incoming) {2901      const trimmed = typeof value === "string" ? value.trim() : "";2902      if (!trimmed || !allowHttpUrl(trimmed)) continue;2903      const key = trimmed.toLowerCase();2904      if (seen.has(key)) continue;2905      seen.add(key);2906      cleaned.push(trimmed);2907    }2908    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2909  }29102911  if ("username" in patch) {2912    nextPatch.username = patch.username?.trim() ?? "";2913  }29142915  if ("email" in patch) {2916    nextPatch.email = patch.email?.trim() || undefined;2917  }29182919  if ("password" in patch) {2920    const password = patch.password ?? "";2921    // Allow clearing a password (e.g. on passkey-only entries)2922    nextPatch.password = password || undefined;2923  }29242925  if ("totp" in patch) {2926    nextPatch.totp = patch.totp?.trim() || undefined;2927  }29282929  if ("totpAlgorithm" in patch) {2930    nextPatch.totpAlgorithm = patch.totpAlgorithm;2931  }29322933  if ("totpDigits" in patch) {2934    nextPatch.totpDigits = patch.totpDigits;2935  }29362937  if ("totpPeriod" in patch) {2938    nextPatch.totpPeriod = patch.totpPeriod;2939  }29402941  if ("notes" in patch) {2942    nextPatch.notes = patch.notes?.trim() || undefined;2943  }29442945  if ("breach" in patch) {2946    nextPatch.breach = patch.breach;2947  }29482949  if ("canary" in patch) {2950    // PCH-02-001: route update patches through the same canary whitelist used2951    // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.2952    nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2953  }29542955  if ("passkey" in patch) {2956    // A patch with `passkey: undefined` removes the credential from the entry2957    // (used by "remove passkey" without deleting the whole entry).2958    if (patch.passkey === undefined || patch.passkey === null) {2959      nextPatch.passkey = undefined;2960    } else {2961      const sanitized = sanitizePasskey(patch.passkey);2962      if (!sanitized) throw new Error("invalid passkey credential");2963      nextPatch.passkey = sanitized;2964    }2965  }29662967  if ("tags" in patch) {2968    // PCH-02-001: bound tags exactly like both import funnels.2969    nextPatch.tags = sanitizeTags(patch.tags);2970  }29712972  if ("cardholderName" in patch) {2973    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2974  }2975  if ("cardNumber" in patch) {2976    const n = patch.cardNumber ?? "";2977    nextPatch.cardNumber = n || undefined;2978  }2979  if ("expMonth" in patch) {2980    nextPatch.expMonth = patch.expMonth?.trim() || undefined;2981  }2982  if ("expYear" in patch) {2983    nextPatch.expYear = patch.expYear?.trim() || undefined;2984  }2985  if ("cvc" in patch) {2986    nextPatch.cvc = patch.cvc?.trim() || undefined;2987  }2988  if ("cardNickname" in patch) {2989    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2990  }2991  if ("network" in patch) {2992    nextPatch.network = patch.network;2993  }2994  if ("last4" in patch) {2995    nextPatch.last4 = patch.last4?.trim() || undefined;2996  }29972998  return nextPatch;2999}30003001function normalizeEntry(entry: VaultEntry): VaultEntry {3002  const base: VaultEntry = {3003    id: entry.id,3004    type: entry.type,3005    title: entry.title,3006    username: entry.username,3007    email: entry.email,3008    password: entry.password,3009    url: entry.url,3010    additionalUrls:3011      "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3012        ? entry.additionalUrls3013        : undefined,3014    totp: entry.totp,3015    // PCH-01-019: carry TOTP params through the normalize/restore path too.3016    totpAlgorithm: entry.totpAlgorithm,3017    totpDigits: entry.totpDigits,3018    totpPeriod: entry.totpPeriod,3019    notes: entry.notes,3020    breach: entry.breach,3021    // Preserve the password-age clock across lock/unlock. Without this the3022    // field is dropped on every decode and the advanced-health "aged password"3023    // signal silently resets to createdAt on each unlock (SEV-2-A).3024    passwordChangedAt:3025      "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3026    // Preserve past-password history across lock/unlock. normalizeEntry is a3027    // field whitelist, so an unlisted field is silently dropped on every decode3028    // (same class as the passwordChangedAt SEV-2-A above).3029    passwordHistory:3030      "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3031        ? entry.passwordHistory3032        : undefined,3033    canary: entry.canary,3034    passkey: entry.passkey,3035    createdAt: entry.createdAt,3036    modifiedAt: entry.modifiedAt,3037    deletedAt: entry.deletedAt,3038    tags: entry.tags,3039    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3040    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3041    expMonth: "expMonth" in entry ? entry.expMonth : undefined,3042    expYear: "expYear" in entry ? entry.expYear : undefined,3043    cvc: "cvc" in entry ? entry.cvc : undefined,3044    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3045    network: "network" in entry ? entry.network : undefined,3046    last4: "last4" in entry ? entry.last4 : undefined,3047  };30483049  return base;3050}305130523053// ---------------------------------------------------------------------------3054// Deduplication3055// ---------------------------------------------------------------------------30563057function entryDupeKey(e: VaultEntry): string {3058  if (e.type === "note") return `note|${e.title.toLowerCase()}`;3059  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins3060  // on different sites that happen to share a username are still distinct;3061  // two passkey credentials for the same RP+user are duplicates regardless3062  // of any login fields they may also carry.3063  if (e.passkey) {3064    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3065  }3066  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3067}30683069/** Returns the number of duplicate entries (those that would be removed). */3070export function countDuplicates(): number {3071  const best = new Map<string, VaultEntry>();3072  let dupeCount = 0;3073  for (const e of entries.filter((e) => !e.deletedAt)) {3074    const key = entryDupeKey(e);3075    const existing = best.get(key);3076    if (!existing) {3077      best.set(key, e);3078    } else {3079      dupeCount++;3080      // Keep the more recently modified one in the map3081      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3082        best.set(key, e);3083      }3084    }3085  }3086  return dupeCount;3087}30883089/**3090 * Soft-deletes duplicate entries, keeping the most recently modified copy of3091 * each group. Returns the number of entries moved to trash.3092 */3093export async function deduplicateEntries(): Promise<number> {3094  if (!derivedKey) throw new Error("vault_locked");3095  // Dedup reshapes slot B permanently under the decoy key, which is3096  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.3097  if (vaultMode === "decoy") {3098    throw new Error("deduplicate disabled in decoy mode");3099  }31003101  const best = new Map<string, VaultEntry>();3102  for (const e of entries.filter((en) => !en.deletedAt)) {3103    const key = entryDupeKey(e);3104    const existing = best.get(key);3105    if (!existing) {3106      best.set(key, e);3107    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3108      best.set(key, e);3109    }3110  }31113112  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3113  let removed = 0;3114  entries = entries.map((e) => {3115    if (!e.deletedAt && !keepIds.has(e.id)) {3116      removed++;3117      const deletedAt = monotonicEntryTimestamp(Math.max(3118        e.modifiedAt,3119        tombstones.get(e.id) ?? 0,3120      ));3121      tombstones.set(e.id, deletedAt);3122      return { ...e, deletedAt };3123    }3124    return e;3125  });31263127  if (removed > 0) await persistEntries();3128  return removed;3129}31303131// ---------------------------------------------------------------------------3132// Vault history — snapshot BEFORE every vault write so the saved blob is3133// what is about to be lost, not what was just written. Capped at 5. The3134// stored blobs are AES-256-GCM ciphertext under the master password, so3135// history adds zero security surface.3136// ---------------------------------------------------------------------------31373138const HISTORY_KEY = "peach_vault_history";3139const HISTORY_MAX = 5;31403141// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the3142// plaintext snapshot field cannot reveal exact vault size or single-entry change3143// deltas. Buckets widen as the vault grows so absolute leakage stays roughly3144// proportional rather than exact.3145function bucketEntryCount(n: number): number {3146  if (n <= 0) return 0;3147  if (n <= 10) return 10;3148  if (n <= 25) return 25;3149  if (n <= 50) return 50;3150  if (n <= 100) return 100;3151  if (n <= 250) return 250;3152  if (n <= 500) return 500;3153  return Math.ceil(n / 500) * 500;3154}31553156/**3157 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE3158 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost3159 * state. Identical-to-most-recent blobs are skipped to avoid filling history3160 * with no-op writes.3161 */3162export async function takeVaultSnapshot(3163  reason: VaultSnapshot["reason"],3164): Promise<void> {3165  try {3166    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3167    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3168    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];31693170    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,3171    // pre-createVault). createVault skips snapshots intentionally — there is3172    // no prior state to preserve.3173    if (!slotA) return;31743175    const vault_a = JSON.stringify(slotA);3176    const vault_b = JSON.stringify(slotB ?? slotA);31773178    // Best-effort entry count: only meaningful when the in-memory state is3179    // unlocked AND in real mode. Decoy mode would leak the decoy entry3180    // count; locked mode has no entries to count. Both fall through to null.3181    // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage3182    // .local sits next to the AES-GCM blobs in plaintext, so an exact count would3183    // leak vault size and per-write deltas (the metadata padding hides this).3184    let entryCount: number | null = null;3185    if (derivedKey && vaultMode === "real") {3186      try {3187        entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3188      } catch {3189        entryCount = null;3190      }3191    }31923193    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3194    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3195      ? existingRaw3196      : { snapshots: [] };31973198    // Deduplication: if the most recent snapshot has byte-identical blobs,3199    // skip — saves the same vault N times in a row would otherwise burn the3200    // entire history window with no recovery value.3201    const latest = history.snapshots[0];3202    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3203      return;3204    }32053206    const snapshot: VaultSnapshot = {3207      id: crypto.randomUUID(),3208      savedAt: Date.now(),3209      reason,3210      vault_a,3211      vault_b,3212      entryCount,3213    };32143215    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3216    await chrome.storage.local.set({ [HISTORY_KEY]: history });3217  } catch (err) {3218    // Non-fatal — history must never block a save. A failed snapshot just3219    // means the user loses one rung of the safety ladder for this write.3220    // M4/QUA-006: log so the failure is visible; if it looks like a quota3221    // problem, notify the popup so the user can prune history themselves.3222    console.warn('[Peach] takeVaultSnapshot failed:', err);3223    if (String(err).includes('QUOTA') || String(err).includes('quota')) {3224      try {3225        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3226      } catch {3227        // popup may be closed; surfaced via console.warn above3228      }3229    }3230  }3231}32323233/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */3234export async function clearVaultHistory(): Promise<void> {3235  try {3236    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3237  } catch {3238    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().3239  }3240}32413242/** Read all snapshots, newest first, for the Settings vault history view. */3243export async function getVaultHistory(): Promise<VaultSnapshot[]> {3244  const raw = await chrome.storage.local.get(HISTORY_KEY);3245  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3246  if (!history || !Array.isArray(history.snapshots)) return [];3247  return history.snapshots;3248}32493250/**3251 * Replace the on-disk vault with the chosen snapshot's blobs and lock the3252 * vault. The current state is itself snapshotted with reason 'restore' first3253 * so the restore is reversible. After this returns, the user must unlock3254 * with the password that matches the restored blob.3255 */3256export async function restoreVaultSnapshot(3257  snapshotId: string,3258): Promise<{ ok: true } | { ok: false; error: string }> {3259  const history = await getVaultHistory();3260  const snapshot = history.find((s) => s.id === snapshotId);3261  if (!snapshot) return { ok: false, error: "snapshot_not_found" };32623263  // Snapshot the about-to-be-replaced current state so the restore is3264  // reversible. takeVaultSnapshot is no-op when the most recent entry is3265  // byte-identical, so a restore-of-a-restore stays clean.3266  await takeVaultSnapshot("restore");32673268  let restoredA: StoredVault;3269  let restoredB: StoredVault;3270  try {3271    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3272    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3273  } catch {3274    return { ok: false, error: "snapshot_corrupt" };3275  }32763277  await writeSlot(SLOT_A, restoredA);3278  await writeSlot(SLOT_B, restoredB);3279  await stampLocalVaultMutation();32803281  // Lock so the user must prove they can decrypt the restored blob. If they3282  // restored a pre-password-change snapshot they discover it immediately at3283  // the lock screen rather than silently holding an inaccessible vault.3284  await lock();32853286  return { ok: true };3287}32883289/**3290 * Rotate the vault's sync identity by generating a FRESH ROOT KEY and3291 * re-wrapping everything under it (MV-8). Because every sync credential —3292 * vault_id, the Noise static keypair, the Ed25519 manifest signing key,3293 * and the per-entry envelope keys — derives from the root key, rotation3294 * makes a lost/stolen device's retained key material useless. All paired3295 * devices must re-pair afterwards.3296 *3297 * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from3298 * the root key, so rotation INVALIDATES the old recovery phrase. The flow3299 * sets peach_recovery_pending so the popup re-presents the new words for3300 * confirmation, same as the initial recovery-word confirmation path.3301 */3302export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3303  if (!derivedKey || vaultMode !== "real") {3304    return { ok: false, error: "vault_locked" };3305  }3306  const slotA = await loadSlot(SLOT_A);3307  if (!slotA) return { ok: false, error: "missing_slot_a" };33083309  // 1. Fresh root key + recovery escrow (new words).3310  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3311    await generateAndWrapRootKey();33123313  // 2. Re-wrap the new root key under the current password-derived key.3314  const newRootKey = unhex(rawRootKey);3315  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);33163317  // 3. Snapshot, then single atomic slot write — both escrow wrappings of3318  //    the new root key land together (same transactional rule as3319  //    changePassword). Entries are untouched: they are sealed under the3320  //    password-derived key, not the root key.3321  await takeVaultSnapshot("local_save");3322  const nextSlotA: StoredVault = {3323    ...slotA,3324    recovery_salt: recoverySalt,3325    recovery_wrapped_root_key: wrappedRootKey,3326    recovery_nonce: wrappedNonce,3327    recovery_tag: wrappedTag,3328    ...pwRoot,3329  };3330  await writeSlot(SLOT_A, nextSlotA);33313332  // 4. Swap in memory only after disk committed.3333  if (rootKey) zeroKey(rootKey);3334  rootKey = newRootKey;33353336  // 5. Old peers' identities derive from the dead root key — drop them all,3337  // with their rendezvous secrets (PB-26-213: rotation clears the map, so3338  // there is nothing to rewrap under the new key).3339  await clearPairedDevices();3340  await clearPairSecrets();33413342  // 6. Force re-presentation of the NEW recovery words on next popup view.3343  await setRecoveryConfirmPending(); // same flag the create-vault flow uses33443345  await stampLocalVaultMutation();3346  return { ok: true };3347}33483349/**3350 * Change the master password. Re-encrypts slot A with a fresh salt + new3351 * derived key. Slot B (duress or noise) is left untouched when duress is3352 * configured — it uses an independent key derived from the duress password —3353 * and regenerated as noise when duress is not configured.3354 */3355export async function changePassword(3356  currentPassword: string,3357  newPassword: string3358): Promise<boolean> {3359  const epoch = vaultEpoch;3360  const stale = () => epoch !== vaultEpoch;3361  if (!derivedKey || vaultMode !== "real") return false;33623363  // Verify current password before allowing any change3364  const valid = await verifyMainPassword(currentPassword);3365  if (!valid) return false;33663367  const slotA = await loadSlot(SLOT_A);3368  if (!slotA) return false;3369  if (!rootKey) return false;33703371  // Derive fresh key from new password + new random salt3372  const newSalt = generateSalt();3373  const newKey = await deriveKey(newPassword, newSalt);3374  if (stale()) {3375    zeroKey(newKey);3376    return false;3377  }33783379  // Re-encrypt the in-memory entries with the new key3380  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3381  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");33823383  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey3384  // under recovery words and remain untouched — recovery phrase keeps working3385  // across password changes (H4/COR-002).3386  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);33873388  const finalSlotA: StoredVault = {3389    ...slotA,3390    salt: hex(newSalt),3391    nonce,3392    ciphertext,3393    tag,3394    ...pwRoot,3395  };33963397  // Snapshot the OLD-key blob before overwriting it. If the user later3398  // restores from this snapshot they will need the OLD password — the3399  // post-restore lock surfaces that immediately.3400  await takeVaultSnapshot("local_save");34013402  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in3403  // one storage transaction. An SW kill before this point leaves the old3404  // slot intact; after this point everything tied to the new password is3405  // already on disk together (M6/COR-004).3406  await writeSlot(SLOT_A, finalSlotA);3407  if (stale()) {3408    zeroKey(newKey);3409    return false;3410  }34113412  // Swap the in-memory key synchronously before any further await. If we3413  // threw between the Slot A write and this swap, the next persistEntries()3414  // would overwrite Slot A with old-key ciphertext, locking the user out.3415  zeroKey(derivedKey);3416  derivedKey = newKey;34173418  // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,3419  // the next service-worker restore decrypts the new-key Slot A with the stale3420  // key, fails tryDecryptSlotResult, and silently drops the user to a lock3421  // screen. Re-escrow the new key so restore survives a password change.3422  if (stale()) {3423    await lock();3424    return false;3425  }3426  await saveSessionKey(newKey, "real", epoch);3427  if (stale()) return false;34283429  // Regenerate noise for slot B when duress is not in use. Runs after the3430  // key swap so a failure here is a disposable noise regeneration, not a3431  // lockout.3432  if (!duressConfigured) {3433    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3434  }3435  await clearPinUnlockStorage();3436  await stampLocalVaultMutation();34373438  return true;3439}