vault.ts

123.7 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";16// Static, not `await import()`: runtime import() throws in an MV3 service17// worker. Neither module imports vault.ts back, so there is no cycle to break.18import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21  rootKeyToWords,22  wordsToRootKeyMaterial,23  deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26  AccentColorName,27  Canary,28  ManualSummonRecord,29  PasskeyData,30  SavedIdentity,31  StoredVault,32  VaultEntry,33  VaultHistory,34  VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import {39  clearRecoveryConfirmPending,40  setRecoveryConfirmPending,41} from "./recovery-pending";42import {43  clearPairSecrets,44  dropSessionCache,45  reconcilePairSecrets,46  setSessionAllowed,47} from "./pair-secrets";4849type EntryDraft = Omit<50  VaultEntry,51  "id" | "createdAt" | "modifiedAt" | "deletedAt"52>;5354// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B55// either holds the decoy vault (when duress is configured) or random noise56// that looks identical at rest. No external indicator is stored — duress57// configuration is recorded only inside the encrypted payload of slot A.58const SLOT_A = "peach_vault_a";59const SLOT_B = "peach_vault_b";6061// Plaintext is padded to the next multiple of this size before encryption.62// This coarsens ciphertext-length leakage into 1 KiB buckets.63const PAD_BLOCK = 1024;6465// How many past passwords to retain per entry. Generous enough to be useful as66// a recovery aid while bounding the secret material we hold (and the ciphertext67// growth) for an entry whose password is rotated frequently.68const PASSWORD_HISTORY_MAX = 50;6970// When duress is configured, BOTH slots are padded to this fixed ceiling so71// their ciphertext lengths are identical and cannot reveal that a hidden decoy72// vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably73// holds a very large vault (thousands of entries incl. passkeys). The decoy74// slot can't be re-encrypted on every real-vault save (the real session has no75// decoy key), so a fixed ceiling — rather than size-matching on each write — is76// what keeps the two slots equal. A real vault whose plaintext exceeds this77// would grow past the ceiling and re-expose the bit; that is an extreme,78// documented edge well beyond normal use.79const DURESS_SLOT_SIZE = 262_144;8081let derivedKey: Uint8Array | null = null;82// 32-byte random root key, separate from the derivedKey used for vault encryption.83// The root key is wrapped (encrypted) under the recovery key derived from the84// 12-word phrase. When the user changes their password, only the wrapping changes;85// the root key and recovery words remain stable.86let rootKey: Uint8Array | null = null;87let entries: VaultEntry[] = [];88// Tombstones outlive their full entry records so a peer that was offline89// during a purge cannot reintroduce an old ciphertext envelope. This map is90// serialized only inside the encrypted vault payload.91let tombstones = new Map<string, number>();92let domainIndex: Map<string, VaultEntry[]> = new Map();93let vaultMode: "real" | "decoy" = "real";94// Cached post-unlock view of whether the real vault records that duress is95// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock96// this stays false because we never see slot A's plaintext.97let duressConfigured = false;9899// Set to a corruption reason during the last unlock() attempt when both slots100// decrypted but failed schema validation. Consumed and cleared by101// consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted102// distinctly from wrong-password (M7/COR-007).103//104// NOTE: module-level state — if vault.ts is ever moved to a worker context,105// this must be replaced with a structured return value from unlock() or a106// postMessage channel. The signal is invisible across module-instance107// boundaries.108let lastUnlockFailureReason: string | null = null;109110export function consumeLastUnlockFailureReason(): string | null {111  const r = lastUnlockFailureReason;112  lastUnlockFailureReason = null;113  return r;114}115116// ---------------------------------------------------------------------------117// Settings (persisted inside the encrypted vault store as a sidecar key)118// ---------------------------------------------------------------------------119120export type TextSize = "small" | "medium" | "large";121122export interface VaultSettings {123  accentColor?: AccentColorName;124  theme?: "dark" | "light";125  autoLockMinutes?: number;126  clipboardClearSeconds?: number;127  lockOnClose?: boolean;128  breachMonitoring?: boolean;129  passkeyProviderEnabled?: boolean;130  trashRetentionDays?: number;131  manualSummonDomains?: Record<string, ManualSummonRecord>;132  manualSummonSalt?: string;133  declinedCardFingerprints?: string[];134135  /** Metadata is bounded because it remains outside the encrypted vault blob. */136  metadataVersion?: 1;137  /**138   * Text size scale for body copy and entry titles. Stops at "large" by139   * design — XL clips dense screens (Vault, Health) inside the fixed140   * 330×480 popup surface.141   */142  textSize?: TextSize;143  /**144   * When true, all entrance/exit transitions and ambient animations are145   * collapsed to instant. Honoured alongside the user's OS-level146   * `prefers-reduced-motion`.147   */148  reduceMotion?: boolean;149  /** Whether this vault has a local PIN unlock wrapper configured. */150  pinEnabled?: boolean;151  /** Whether the lock screen should open on PIN instead of the password. */152  pinPreferred?: boolean;153  /**154   * User overrides for keyboard shortcuts. Keys are ShortcutAction names155   * (see src/shared/shortcuts.ts). Missing actions fall through to156   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the157   * canonical lookup. Storing only the deltas keeps the vault payload158   * small and lets default updates land for users who never rebind.159   */160  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;161}162163interface StoredPinWrap {164  version: 1;165  salt: string;166  nonce: string;167  ciphertext: string;168  tag: string;169}170171const PIN_WRAP_STORAGE = "peach_pin_wrap";172const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";173const PIN_MIN_LENGTH = 4;174const PIN_MAX_LENGTH = 12;175const PIN_MAX_ATTEMPTS = 3;176177let settings: VaultSettings = {};178const CARD_DECLINE_MAX = 256;179/** Hex length of a keyed card-decline digest. Legacy records are 16 hex. */180const CARD_DECLINE_DIGEST_HEX = 32;181182async function metadataDigest(value: string, key: string): Promise<string> {183  const bytes = new TextEncoder().encode(`${key}:${value}`);184  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));185  return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");186}187188let inMemoryPinWrap: StoredPinWrap | null = null;189let inMemoryPinFailedAttempts = 0;190let sessionPinStorageFailed = false;191192export function getSettings(): VaultSettings {193  return settings;194}195196export async function ensureSettingsLoaded(): Promise<VaultSettings> {197  await loadSettings();198  return settings;199}200201export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {202  await loadSettings();203  // LV-7: cheap insurance against prototype pollution if a future caller ever204  // feeds parsed external JSON here (native spread keeps __proto__ an own-205  // property today, but Object.assign-style consumers and JSON revivers have206  // been burned before).207  const safe = Object.fromEntries(208    Object.entries(patch).filter(209      ([k]) => !["__proto__", "constructor", "prototype"].includes(k),210    ),211  ) as Partial<VaultSettings>;212  settings = { ...settings, ...safe };213  await persistSettings();214}215216function isValidPin(pin: string): boolean {217  return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);218}219220/** Set up a local PIN wrapper after proving the current master password. */221export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {222  if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;223  if (!(await verifyMainPassword(currentPassword))) return false;224225  const salt = generateSalt();226  const pinKey = await deriveKey(pin, salt);227  try {228    const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");229    await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });230    await updateSettings({ pinEnabled: true });231    return true;232  } finally {233    zeroKey(pinKey);234  }235}236237async function clearPinUnlockStorage(): Promise<void> {238  inMemoryPinWrap = null;239  inMemoryPinFailedAttempts = 0;240  sessionPinStorageFailed = false;241  if (chrome.storage.session) {242    await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);243  }244  await updateSettings({ pinEnabled: false });245}246247/** Remove the PIN wrapper after proving the current master password. */248export async function disablePinUnlock(currentPassword: string): Promise<boolean> {249  if (!derivedKey || vaultMode !== "real") return false;250  if (!(await verifyMainPassword(currentPassword))) return false;251  await clearPinUnlockStorage();252  return true;253}254255/** Unlock the real vault from its locally stored PIN-wrapped derived key. */256export async function unlockWithPin(pin: string): Promise<boolean> {257  if (!isValidPin(pin)) return false;258  await loadSettings();259  if (!settings.pinEnabled) return false;260261  const { stored, failedAttempts } = await loadPinState();262  if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;263  if (264    !stored ||265    stored.version !== 1 ||266    !stored.salt ||267    !stored.nonce ||268    !stored.ciphertext ||269    !stored.tag270  ) return false;271272  const pinKey = await deriveKey(pin, unhex(stored.salt));273  let unwrappedKey: Uint8Array | null = null;274  try {275    const masterKeyHex = await decrypt(276      pinKey,277      stored.nonce,278      stored.ciphertext,279      stored.tag,280      "pin_wrap_v1",281    );282    if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;283    unwrappedKey = unhex(masterKeyHex);284    const result = await unlockWithKey(masterKeyHex);285    if (!result.ok) return false;286    await resetPinFailedAttempts();287    return true;288  } catch {289    await recordPinFailure(failedAttempts + 1);290    return false;291  } finally {292    zeroKey(pinKey);293    if (unwrappedKey) zeroKey(unwrappedKey);294  }295}296297async function savePinWrap(stored: StoredPinWrap): Promise<void> {298  inMemoryPinWrap = stored;299  inMemoryPinFailedAttempts = 0;300  sessionPinStorageFailed = !chrome.storage.session;301  if (!chrome.storage.session) return;302  try {303    await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });304    sessionPinStorageFailed = false;305  } catch {306    // Firefox fallback: keep the wrapper only in this background context.307    sessionPinStorageFailed = true;308  }309}310311async function loadPinState(): Promise<{312  stored: StoredPinWrap | undefined;313  failedAttempts: number;314}> {315  if (!chrome.storage.session) {316    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };317  }318  try {319    const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);320    return {321      stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??322        (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),323      failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"324        ? result[PIN_ATTEMPTS_STORAGE]325        : 0,326    };327  } catch {328    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };329  }330}331332async function recordPinFailure(failedAttempts: number): Promise<void> {333  inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);334  if (!chrome.storage.session) return;335  try {336    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });337  } catch { /* in-memory counter remains the fallback */ }338}339340async function resetPinFailedAttempts(): Promise<void> {341  inMemoryPinFailedAttempts = 0;342  if (!chrome.storage.session) return;343  try {344    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });345  } catch { /* in-memory counter remains the fallback */ }346}347348export async function getPinUnlockStatus(): Promise<{349  configured: boolean;350  available: boolean;351  attemptsRemaining: number;352}> {353  await loadSettings();354  if (!settings.pinEnabled) {355    return { configured: false, available: false, attemptsRemaining: 0 };356  }357  const { stored, failedAttempts } = await loadPinState();358  const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;359  return {360    configured: true,361    available,362    attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,363  };364}365366async function persistSettings(): Promise<void> {367  try {368    await chrome.storage.local.set({369      peach_settings: {370        ...settings,371        manualSummonDomains: undefined,372        manualSummonSalt: undefined,373        cardFingerprintSalt: undefined,374        declinedCardFingerprints: settings.declinedCardFingerprints ?? [],375        metadataVersion: 1,376      },377    });378  } catch (err) {379    throw mapStorageError(err);380  }381}382383function mapStorageError(err: unknown): Error {384  const message = err instanceof Error ? err.message : String(err);385  if (/QUOTA_BYTES|quota/i.test(message)) {386    return new Error("STORAGE_QUOTA_EXCEEDED");387  }388  return err instanceof Error ? err : new Error(message);389}390391async function loadSettings(): Promise<void> {392  const result = await chrome.storage.local.get("peach_settings");393  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};394}395396// Fire-and-forget sync trigger after a committed vault write. A failure here397// must never fail the write that caused it.398function fireSyncTrigger(reason: "vault_change"): void {399  void Promise.resolve()400    .then(() => triggerSyncIfReachable({ reason }))401    .catch(() => {402      /* no sync runner registered, or nothing reachable — non-fatal */403    });404}405406let lockedSyncRefreshHook: (() => Promise<void>) | null = null;407let lockedSyncUnlockHook: (() => Promise<void>) | null = null;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  _newSalt: Uint8Array680): Promise<void> {681  if (!rootKey) throw new Error("root key not set");682  const slotA = await loadSlot(SLOT_A);683  if (!slotA) throw new Error("missing slot A");684  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);685  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });686}687688/**689 * Change the vault encryption to a new derived key (already derived from690 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.691 * Caller is responsible for key swap and snapshot.692 */693async function reencryptVaultForNewKey(694  newKey: Uint8Array,695  slotA: StoredVault696): Promise<void> {697  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);698  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");699  await writeSlot(SLOT_A, {700    ...slotA,701    nonce,702    ciphertext,703    tag,704  });705}706707/**708 * Full password change with an already-derived key (used after recovery).709 * Unlike changePassword(), this skips re-verification of current password710 * since recovery flow has already validated the user's identity via words.711 */712export async function changePasswordWithNewKey(713  newSalt: Uint8Array,714  newKey: Uint8Array715): Promise<void> {716  const epoch = vaultEpoch;717  const throwIfStale = () => {718    if (epoch === vaultEpoch) return;719    zeroKey(newKey);720    throw new Error("vault_locked");721  };722  const slotA = await loadSlot(SLOT_A);723  throwIfStale();724  if (!slotA) throw new Error("missing slot A");725  // reencryptVaultForNewKey must receive the updated salt so that subsequent726  // UNLOCK calls with the new password can re-derive the correct key.727  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });728  throwIfStale();729  if (derivedKey) zeroKey(derivedKey);730  derivedKey = newKey;731  // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow732  // reaches this path, so a post-recovery SW restart would otherwise lock out733  // a user who just reset their password.734  await saveSessionKey(newKey, "real", epoch);735  throwIfStale();736  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works737  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow738  // should have populated it — fall back silently rather than blowing up the739  // recovery path; the escrow is already present on every supported vault.740  if (rootKey) {741    const slotAfter = await loadSlot(SLOT_A);742    throwIfStale();743    if (slotAfter) {744      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);745      throwIfStale();746      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });747      throwIfStale();748    }749  }750  if (!duressConfigured) {751    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));752    throwIfStale();753  }754  await clearPinUnlockStorage();755  throwIfStale();756  await stampLocalVaultMutation();757  throwIfStale();758}759760// ---------------------------------------------------------------------------761// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive762// a new key after password reset without re-importing the crypto module.763// ---------------------------------------------------------------------------764765export { deriveKey, generateSalt };766767export async function loadSlotA(): Promise<StoredVault | null> {768  return loadSlot(SLOT_A);769}770771/**772 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields773 * (a second wrapping of the root key under the password-derived key). This774 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without775 * the user re-entering their recovery phrase: the same root key bytes —776 * and therefore the same 12 words — survive every unlock.777 *778 * Returns null when the escrow is absent or decryption fails.779 */780async function deriveRootKeyFromDerivedKey(781  derivedKeyBytes: Uint8Array,782  slotA: StoredVault,783): Promise<Uint8Array | null> {784  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {785    return null;786  }787  try {788    const rootHex = await decrypt(789      derivedKeyBytes,790      slotA.pw_root_nonce,791      slotA.pw_root_ciphertext,792      slotA.pw_root_tag,793      "pw_root_v1",794    );795    return unhex(rootHex);796  } catch {797    return null;798  }799}800801// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the802// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.803async function wrapRootKeyForDerivedKey(804  derivedKeyBytes: Uint8Array,805  rootKeyBytes: Uint8Array,806): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {807  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");808  return {809    pw_root_ciphertext: ciphertext,810    pw_root_nonce: nonce,811    pw_root_tag: tag,812  };813}814815/** Returns the 12 recovery words for the current session's root key. */816export async function getRecoveryWords(): Promise<string[]> {817  if (!rootKey) {818    throw new Error("recovery not set up");819  }820  return rootKeyToWords(rootKey);821}822823async function loadSlot(key: string): Promise<StoredVault | null> {824  const result = await chrome.storage.local.get(key);825  return (result as Record<string, StoredVault | undefined>)[key] ?? null;826}827828async function writeSlot(key: string, vault: StoredVault): Promise<void> {829  try {830    await chrome.storage.local.set({ [key]: vault });831  } catch (err) {832    throw mapStorageError(err);833  }834}835836/**837 * Roll back a vault that was created by [createVaultFromRootKey] but838 * couldn't be paired (e.g. the subsequent license activation, identity839 * derivation, or paired-device upsert failed). Without this, a failed840 * receiver session leaves behind an empty vault — the user has to841 * re-pair to get a working setup, but the empty vault sits in the way.842 *843 * The call is idempotent: removing keys that aren't there is a no-op.844 */845export async function rollbackAdoptedVault(): Promise<void> {846  await lock();847  try {848    await chrome.storage.local.remove([849      SLOT_A,850      SLOT_B,851      "peach_last_modified_at",852    ]);853  } catch (err) {854    throw mapStorageError(err);855  }856}857858async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {859  const raw = await chrome.storage.local.get("peach_last_modified_at");860  const previous = Number(861    (raw as Record<string, unknown>).peach_last_modified_at,862  );863  const next = Math.max(864    Date.now(),865    Number.isFinite(previous) ? previous + 1 : 0,866  );867  try {868    await chrome.storage.local.set({869      [SLOT_A]: slotA,870      [SLOT_B]: slotB,871      peach_last_modified_at: String(next),872    });873  } catch (err) {874    throw mapStorageError(err);875  }876}877878// Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the879// sync layer's last-write-wins merge against wall-clock regressions (NTP step,880// manual clock change, DST glitch): a backwards Date.now() must never produce881// an entry timestamp that's <= the entry's existing modifiedAt, or a genuine882// local edit would lose to a stale remote copy. The returned value is always883// strictly greater than both the entry's current modifiedAt and the previous884// call's result (SEV-2-E).885let lastEntryTimestamp = 0;886function monotonicEntryTimestamp(currentModifiedAt?: number): number {887  const base = Math.max(888    Date.now(),889    (currentModifiedAt ?? 0) + 1,890    lastEntryTimestamp + 1,891  );892  lastEntryTimestamp = base;893  return base;894}895896async function stampLocalVaultMutation(): Promise<void> {897  const raw = await chrome.storage.local.get("peach_last_modified_at");898  const previous = Number(899    (raw as Record<string, unknown>).peach_last_modified_at,900  );901  const next = Math.max(902    Date.now(),903    Number.isFinite(previous) ? previous + 1 : 0,904  );905  await chrome.storage.local.set({906    peach_last_modified_at: String(next),907  });908}909910// ---------------------------------------------------------------------------911// Padding + noise helpers912// ---------------------------------------------------------------------------913914/**915 * Pads the plaintext string with trailing spaces so that the encoded byte916 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing917 * whitespace after a complete document, so the padding is transparent on918 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.919 */920function padPlaintext(json: string, minBytes = 0): string {921  const encoded = new TextEncoder().encode(json);922  const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);923  // minBytes lets the duress path force a fixed ceiling so both slots match.924  const target = Math.max(bucket, minBytes);925  const padLen = target - encoded.length;926  return json + " ".repeat(padLen);927}928929/**930 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The931 * ciphertext length matches the given byte count. Because GCM ciphertext is932 * pseudo-random, this blob is indistinguishable at rest from a real933 * encrypted vault without the key.934 *935 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto936 * spec hard limit). Chunk the fill so vaults larger than that — common once937 * the user has dozens of entries or any passkey — don't throw.938 */939const CSPRNG_CHUNK = 65_536;940941function fillRandom(buf: Uint8Array): Uint8Array {942  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {943    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);944    const chunk = new Uint8Array(end - offset);945    crypto.getRandomValues(chunk);946    buf.set(chunk, offset);947  }948  return buf;949}950951function generateNoiseBlob(ciphertextBytes: number): StoredVault {952  const salt = crypto.getRandomValues(new Uint8Array(16));953  const nonce = crypto.getRandomValues(new Uint8Array(12));954  const cipher = fillRandom(new Uint8Array(ciphertextBytes));955  const tag = crypto.getRandomValues(new Uint8Array(16));956  return {957    version: 1,958    salt: hex(salt),959    nonce: hex(nonce),960    ciphertext: hex(cipher),961    tag: hex(tag),962  };963}964965// ---------------------------------------------------------------------------966// Encrypted plaintext envelope967// ---------------------------------------------------------------------------968969type VaultPayload = {970  entries: VaultEntry[];971  tombstones?: Record<string, number>;972  duressConfigured?: boolean;973};974975type DecryptedPayload = {976  entries: VaultEntry[];977  tombstones: Map<string, number>;978  duressConfigured: boolean;979};980981function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {982  const envelope: VaultPayload = {983    entries,984    tombstones: Object.fromEntries(tombstones),985    duressConfigured: duress,986  };987  return padPlaintext(JSON.stringify(envelope), minBytes);988}989990function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {991  const result = new Map<string, number>();992  if (raw && typeof raw === "object" && !Array.isArray(raw)) {993    for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {994      if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {995        result.set(id, timestamp);996      }997    }998  }999  // Older payloads had no ledger. Rebuild the durable portion from any1000  // soft-deleted entries before they are eligible for a later purge.1001  for (const entry of allEntries) {1002    if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1003      continue;1004    }1005    if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1006      result.set(entry.id, entry.deletedAt);1007    }1008  }1009  return result;1010}10111012function reconcileTombstones(): void {1013  const byId = new Map(entries.map((entry) => [entry.id, entry]));1014  for (const [id] of tombstones) {1015    if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1016  }1017  for (const entry of entries) {1018    if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1019      if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1020        tombstones.set(entry.id, entry.deletedAt);1021      }1022    }1023  }1024}10251026function decodePayload(plain: string): DecryptedPayload {1027  const parsed = JSON.parse(plain) as VaultPayload;1028  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1029    throw new Error("invalid vault payload");1030  }1031  const normalized = parsed.entries.map(normalizeEntry);1032  return {1033    entries: normalized,1034    tombstones: decodeTombstones(parsed.tombstones, normalized),1035    duressConfigured: parsed.duressConfigured === true,1036  };1037}10381039/**1040 * Consolidate duplicate passkey records after registration.1041 *1042 * Multiple registrations for one RP can leave duplicate passkey records. This1043 * pass collapses the duplicates:1044 *1045 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.1046 *   2. Within each group, the most recently-active credential wins (prefer1047 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).1048 *   3. If a NON-passkey login entry exists for that RP (matched by url or1049 *      additionalUrls hostname), attach the winning passkey to it and1050 *      soft-delete every passkey-bearing entry in the group.1051 *   4. Otherwise, soft-delete every loser in the group, keeping the winner1052 *      as a passkey-only entry.1053 *1054 * Soft delete (deletedAt) rather than splice — the trash flow keeps the1055 * record around for retention and the user can restore if the heuristic1056 * picked wrong. The `merged` flag goes back to decodePayload so the1057 * unlock path persists the consolidated shape immediately.1058 */1059function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1060  let merged = false;1061  const now = Date.now();10621063  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1064  const byRpId = new Map<string, VaultEntry[]>();1065  for (const e of candidates) {1066    const rpId = e.passkey!.rpId;1067    const list = byRpId.get(rpId);1068    if (list) list.push(e);1069    else byRpId.set(rpId, [e]);1070  }10711072  for (const [rpId, group] of byRpId) {1073    if (group.length === 0) continue;1074    group.sort((a, b) => {1075      const aT =1076        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1077      const bT =1078        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1079      return bT - aT;1080    });1081    const winner = group[0]!;10821083    // Look for a NON-passkey login entry matching this RP. Use the URL1084    // hostname rather than a substring search so a "login.foo.com" entry1085    // doesn't accidentally absorb a passkey for "foo.com".1086    const loginTarget = entries.find((e) => {1087      if (e.deletedAt || e.passkey || e.type !== "login") return false;1088      if (e.url && extractDomain(e.url) === rpId) return true;1089      for (const extra of e.additionalUrls ?? []) {1090        if (extractDomain(extra) === rpId) return true;1091      }1092      return false;1093    });10941095    if (loginTarget) {1096      loginTarget.passkey = winner.passkey;1097      // A passkey entry may also carry the site's TOTP seed. Preserve it when1098      // the passkey is consolidated onto an existing login instead of1099      // soft-deleting the only record that held the 2FA configuration.1100      if (!loginTarget.totp && winner.totp) {1101        loginTarget.totp = winner.totp;1102        loginTarget.totpAlgorithm = winner.totpAlgorithm;1103        loginTarget.totpDigits = winner.totpDigits;1104        loginTarget.totpPeriod = winner.totpPeriod;1105      }1106      loginTarget.modifiedAt = now;1107      for (const dup of group) {1108        if (!dup.deletedAt) {1109          dup.deletedAt = now;1110          dup.modifiedAt = now;1111        }1112      }1113      merged = true;1114    } else if (group.length > 1) {1115      for (let i = 1; i < group.length; i += 1) {1116        const dup = group[i]!;1117        if (!dup.deletedAt) {1118          dup.deletedAt = now;1119          dup.modifiedAt = now;1120        }1121      }1122      merged = true;1123    }1124  }11251126  return { merged };1127}11281129// Validates that a `StoredVault` record on disk has the field shape we expect1130// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only1131// authenticates the static VERSION_BYTE AAD, not the `version` field on the1132// outer record — a corrupted `version: 42` slot still decrypts cleanly. This1133// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a1134// history-restore path instead of falsely reporting "wrong password".1135function hasValidVaultSchema(v: unknown): v is StoredVault {1136  if (typeof v !== "object" || v === null) return false;1137  const r = v as Record<string, unknown>;1138  return (1139    r.version === 1 &&1140    typeof r.salt === "string" &&1141    typeof r.nonce === "string" &&1142    typeof r.ciphertext === "string" &&1143    typeof r.tag === "string"1144  );1145}11461147export type TryDecryptResult =1148  | { status: "ok"; payload: DecryptedPayload }1149  | { status: "wrong_key" }1150  | { status: "corrupted"; reason: string };11511152// Exported solely for `unlockFromCodex` below, which needs to verify a1153// supplied passphrase against a slotA blob *without* committing anything to1154// storage. Keep this internal otherwise — callers crossing the messages.ts1155// boundary should always go through `unlock` / `verifyMainPassword` so the1156// constant-time invariants in Projects.md §3.4 are not bypassed.1157export async function tryDecryptSlot(1158  vault: StoredVault,1159  key: Uint8Array,1160  aadTag?: string,1161): Promise<DecryptedPayload | null> {1162  const res = await tryDecryptSlotResult(vault, key, aadTag);1163  return res.status === "ok" ? res.payload : null;1164}11651166export async function tryDecryptSlotResult(1167  vault: StoredVault,1168  key: Uint8Array,1169  aadTag?: string,1170): Promise<TryDecryptResult> {1171  if (!hasValidVaultSchema(vault)) {1172    return { status: "corrupted", reason: "invalid_slot_schema" };1173  }1174  let plain: string;1175  try {1176    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1177  } catch {1178    return { status: "wrong_key" };1179  }1180  try {1181    const parsed = JSON.parse(plain) as unknown;1182    if (typeof parsed !== "object" || parsed === null) {1183      return { status: "corrupted", reason: "invalid_payload_shape" };1184    }1185    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1186      return { status: "corrupted", reason: "invalid_payload_shape" };1187    }1188    return { status: "ok", payload: decodePayload(plain) };1189  } catch {1190    return { status: "corrupted", reason: "invalid_payload_json" };1191  }1192}11931194// ---------------------------------------------------------------------------1195// Core operations1196// ---------------------------------------------------------------------------11971198function buildDomainIndexSync(all: VaultEntry[]): void {1199  domainIndex = new Map();12001201  for (const entry of all) {1202    if (entry.deletedAt) continue;1203    if (entry.type === "note" || entry.type === "card") continue;12041205    // An entry can match multiple sites via `additionalUrls`. Index each1206    // hostname so `queryDomain` returns the entry regardless of which alias1207    // the user is currently visiting. Dedupe per-entry so a single entry1208    // never appears twice in one bucket.1209    const domains = new Set<string>();1210    const primary = extractDomain(entry.url ?? "");1211    if (primary) domains.add(primary);1212    for (const extra of entry.additionalUrls ?? []) {1213      const d = extractDomain(extra);1214      if (d) domains.add(d);1215    }1216    if (domains.size === 0) continue;12171218    for (const domain of domains) {1219      const bucket = domainIndex.get(domain) ?? [];1220      bucket.push(entry);1221      domainIndex.set(domain, bucket);1222    }1223  }1224}12251226export async function createVault(password: string): Promise<void> {1227  const salt = generateSalt();1228  derivedKey = await deriveKey(password, salt);1229  entries = [];1230  tombstones = new Map();1231  vaultMode = "real";1232  duressConfigured = false;1233  buildDomainIndexSync(entries);12341235  // Generate and wrap the root key for recovery word escrow.1236  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1237    await generateAndWrapRootKey();1238  rootKey = unhex(rawRootKey);12391240  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1241  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");12421243  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate1244  // it without recovery words (H2/COR-006).1245  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12461247  const slotA: StoredVault = {1248    version: 1,1249    salt: hex(salt),1250    nonce,1251    ciphertext,1252    tag,1253    recovery_salt: recoverySalt,1254    recovery_wrapped_root_key: wrappedRootKey,1255    recovery_nonce: wrappedNonce,1256    recovery_tag: wrappedTag,1257    ...pwRoot,1258  };1259  await writeSlot(SLOT_A, slotA);12601261  // Matching-size noise blob for slot B, so a fresh install shows two1262  // similarly-shaped records regardless of whether duress is ever set up.1263  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1264  await stampLocalVaultMutation();12651266  touchAutoLock();1267}12681269/**1270 * Creates a local vault from an existing vault root key received through the1271 * SAS-gated adoption protocol. The password is local to this device; it wraps1272 * the adopted root key at rest and does not need to match the source device.1273 */1274export async function createVaultFromRootKey(1275  password: string,1276  rootKeyHex: string,1277): Promise<{ vaultId: string }> {1278  if (typeof password !== "string" || password.length === 0) {1279    throw new Error("ADOPT_INVALID_PASSWORD");1280  }1281  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1282    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1283  }12841285  const rootKeyBytes = unhex(rootKeyHex);1286  if (rootKeyBytes.length !== 32) {1287    rootKeyBytes.fill(0);1288    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1289  }12901291  let nextDerivedKey: Uint8Array | null = null;1292  let sessionRootKey: Uint8Array | null = null;1293  try {1294    if ((await loadSlot(SLOT_A)) !== null) {1295      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1296    }12971298    const nextEntries: VaultEntry[] = [];1299    const nextDuressConfigured = false;1300    const vaultId = await computeVaultId(rootKeyBytes);1301    const salt = generateSalt();1302    nextDerivedKey = await deriveKey(password, salt);1303    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);13041305    sessionRootKey = new Uint8Array(rootKeyBytes);1306    const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1307    const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1308    const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1309    const slotA: StoredVault = {1310      version: 1,1311      salt: hex(salt),1312      nonce,1313      ciphertext,1314      tag,1315      recovery_salt: recovery.recoverySalt,1316      recovery_wrapped_root_key: recovery.wrappedRootKey,1317      recovery_nonce: recovery.wrappedNonce,1318      recovery_tag: recovery.wrappedTag,1319      ...pwRoot,1320    };1321    const slotB = generateNoiseBlob(ciphertext.length / 2);1322    await commitCreatedVaultSlots(slotA, slotB);13231324    const previousDerivedKey = derivedKey;1325    const previousRootKey = rootKey;1326    derivedKey = nextDerivedKey;1327    rootKey = sessionRootKey;1328    nextDerivedKey = null;1329    sessionRootKey = null;1330    entries = nextEntries;1331    tombstones = new Map();1332    vaultMode = "real";1333    duressConfigured = nextDuressConfigured;1334    buildDomainIndexSync(entries);1335    if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1336    if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1337    touchAutoLock();13381339    return { vaultId };1340  } finally {1341    rootKeyBytes.fill(0);1342    if (nextDerivedKey) zeroKey(nextDerivedKey);1343    if (sessionRootKey) sessionRootKey.fill(0);1344  }1345}13461347export async function hasVault(): Promise<boolean> {1348  return (await loadSlot(SLOT_A)) !== null;1349}13501351/**1352 * Returns whether the real vault has recorded that a duress passphrase is1353 * configured. Reliable only after a real-mode unlock — before unlock or in1354 * decoy mode, returns false because we cannot read slot A's plaintext.1355 */1356export async function isDuressEnabled(): Promise<boolean> {1357  return vaultMode === "real" && duressConfigured;1358}13591360export async function setupDuress(duressPassword: string): Promise<void> {1361  if (!derivedKey || vaultMode !== "real") {1362    throw new Error("vault must be unlocked in real mode");1363  }1364  // Encrypt the decoy payload (empty entries) under the duress key. Pad to1365  // at least the current slot A ciphertext size so the two slots look the1366  // same at rest at setup time.1367  const slotA = await loadSlot(SLOT_A);1368  if (!slotA) throw new Error("missing slot A");13691370  const decoySalt = generateSalt();1371  const decoyKey = await deriveKey(duressPassword, decoySalt);1372  try {1373    // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1374    // same ceiling by the persistSlotA() call below (duressConfigured is now1375    // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1376    // lengths cannot reveal that duress is configured (audit MEDIUM).1377    const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1378    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1379    await writeSlot(SLOT_B, {1380      version: 1,1381      salt: hex(decoySalt),1382      nonce,1383      ciphertext,1384      tag,1385    });1386  } finally {1387    zeroKey(decoyKey);1388  }13891390  // Re-encrypt slot A with the duressConfigured flag set to true. No external1391  // flag is written — the bit lives inside the encrypted envelope.1392  duressConfigured = true;1393  await persistSlotA();1394}13951396export async function disableDuress(): Promise<void> {1397  if (!derivedKey || vaultMode !== "real") {1398    throw new Error("vault must be unlocked in real mode");1399  }1400  // Overwrite slot B with fresh noise that matches slot A's size. The slot1401  // remains occupied so its mere existence does not signal that duress was1402  // ever configured.1403  const slotA = await loadSlot(SLOT_A);1404  if (slotA) {1405    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1406  }14071408  duressConfigured = false;1409  await persistSlotA();1410}14111412// L14/COR-015: this function intentionally checks slot A (real vault) only.1413// A duress-mode user attempting changePassword is rejected earlier by the1414// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1415// distinction here is by design — duress users cannot rotate the real1416// password by guessing it, and the asymmetry in error timing is acceptable1417// because duress mode is opt-in and the user knows their own decoy key.1418export async function verifyMainPassword(password: string): Promise<boolean> {1419  const slotA = await loadSlot(SLOT_A);1420  if (!slotA) return false;1421  const key = await deriveKey(password, unhex(slotA.salt));1422  try {1423    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1424  } finally {1425    zeroKey(key);1426  }1427}14281429export async function unlock(password: string): Promise<boolean> {1430  return attemptUnlockWithDerive(password, deriveKey);1431}14321433async function attemptUnlockWithDerive(1434  password: string,1435  derive: typeof deriveKey,1436): Promise<boolean> {1437  const epoch = vaultEpoch;1438  const stale = () => epoch !== vaultEpoch;1439  // Load both current-format slots in parallel. Both exist on a supported1440  // install; the1441  // parallel load and the parallel derivation below keep wall-clock time1442  // constant regardless of which slot's passphrase (real or decoy) the1443  // caller supplied, so a timing side channel cannot distinguish the1444  // duress path from the real-unlock path (DUR-002).1445  const [slotA, slotB] = await Promise.all([1446    loadSlot(SLOT_A),1447    loadSlot(SLOT_B),1448  ]);14491450  // Derive both keys in parallel, unconditionally.1451  const [keyA, keyB] = await Promise.all([1452    slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1453    slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1454  ]);14551456  // Attempt both decrypts in parallel. The Result variant lets us distinguish1457  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1458  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1459  const [resultA, resultB] = await Promise.all([1460    keyA && slotA1461      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1462      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1463    keyB && slotB1464      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1465      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1466  ]);1467  const payloadA = resultA.status === "ok" ? resultA.payload : null;1468  const payloadB = resultB.status === "ok" ? resultB.payload : null;14691470  if (stale()) {1471    if (keyA) zeroKey(keyA);1472    if (keyB) zeroKey(keyB);1473    return false;1474  }14751476  if (payloadA && keyA) {1477    if (stale()) {1478      zeroKey(keyA);1479      if (keyB) zeroKey(keyB);1480      return false;1481    }1482    derivedKey = keyA;1483    vaultMode = "real";1484    entries = payloadA.entries;1485    tombstones = payloadA.tombstones;1486    duressConfigured = payloadA.duressConfigured;1487    buildDomainIndexSync(entries);1488    await loadSettings();1489    if (stale()) {1490      await lock();1491      return false;1492    }1493    touchAutoLock();1494    if (keyB) zeroKey(keyB);14951496    // Current vaults always carry a password-wrapped root key. Older vaults1497    // without this escrow are outside the supported storage format.1498    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1499    if (!rootKey) {1500      await lock();1501      return false;1502    }15031504    // PB-26-213: adopt legacy plaintext pair secrets into the encrypted1505    // record and refresh the trusted-session operational copy.1506    await reconcilePairSecrets(rootKey);1507    if (stale()) {1508      await lock();1509      return false;1510    }1511    await lockedSyncUnlockHook?.().catch((error) => {1512      console.warn("[Peach sync] locked inbox drain failed:", error);1513    });1514    if (stale()) {1515      await lock();1516      return false;1517    }1518    await resetPinFailedAttempts();1519    if (stale()) {1520      await lock();1521      return false;1522    }1523    await saveSessionKey(keyA, "real", epoch);1524    if (stale()) return false;1525    return true;1526  }15271528  if (payloadB && keyB) {1529    if (stale()) {1530      zeroKey(keyB);1531      if (keyA) zeroKey(keyA);1532      return false;1533    }1534    derivedKey = keyB;1535    vaultMode = "decoy";1536    vaultMode = "decoy";1537    entries = payloadB.entries;1538    tombstones = payloadB.tombstones;1539    duressConfigured = false;1540    buildDomainIndexSync(entries);1541    await loadSettings();1542    touchAutoLock();1543    if (keyA) zeroKey(keyA);1544    // PB-26-213: a duress session must never serve the real pair-secret map.1545    await dropSessionCache();1546    if (stale()) {1547      await lock();1548      return false;1549    }1550    await saveSessionKey(keyB, "decoy", epoch);1551    if (stale()) return false;1552    return true;1553  }15541555  if (keyA) zeroKey(keyA);1556  if (keyB) zeroKey(keyB);1557  // Surface corruption (good AES tag, broken schema) distinctly from1558  // wrong-password so the popup can offer history-restore (M7/COR-007).1559  if (resultA.status === "corrupted") {1560    lastUnlockFailureReason = resultA.reason;1561  } else if (resultB.status === "corrupted") {1562    lastUnlockFailureReason = resultB.reason;1563  }1564  return false;1565}15661567// ---------------------------------------------------------------------------1568// MV3 service worker session key — survives SW restarts within a browser session1569// ---------------------------------------------------------------------------15701571// chrome.storage.session is cleared on browser close; survives SW restart.1572// Firefox does not implement it (persistent background script, no restart risk).1573const SESSION_KEY_STORAGE = "peach_session_dk";15741575// Order writes and removals so lock's awaited removal is a barrier after1576// every escrow write that started before it.1577let sessionKeyMutation = Promise.resolve();15781579function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1580  const task = sessionKeyMutation.then(mutation, mutation);1581  sessionKeyMutation = task.catch(() => undefined);1582  return task;1583}15841585// PB-26-203: the session-key escrow may only operate where startup positively1586// established trusted-only session storage. Defaults to available so direct1587// vault imports (tests, non-entry bootstraps) keep working; entry.ts applies1588// the real hardening result before the first restore.1589let sessionEscrowAvailable = true;15901591/**1592 * Apply the storage-hardening result at startup. When the trusted-only1593 * session state cannot be established, any stored session key is removed and1594 * the escrow stays off: the user unlocks again after a worker restart1595 * instead of trusting an unproven boundary.1596 */1597export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1598  setSessionAllowed(sessionEscrow);1599  if (sessionEscrow) return;1600  sessionEscrowAvailable = false;1601  await clearSessionKey();1602}16031604async function saveSessionKey(1605  key: Uint8Array,1606  mode: "real" | "decoy",1607  expectedEpoch = vaultEpoch,1608): Promise<void> {1609  const session = chrome.storage.session;1610  if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1611  const stored = { keyHex: hex(key), mode };1612  await mutateSessionKey(async () => {1613    if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1614    await session.set({ [SESSION_KEY_STORAGE]: stored });1615  });1616}16171618async function clearSessionKey(): Promise<void> {1619  const session = chrome.storage.session;1620  if (!session) return;1621  await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1622}16231624// A lock that only holds while the service worker stays alive is no lock at1625// all: a cold worker's restoreFromSession() would find the surviving session1626// key and silently re-unlock ~a minute after the user locked. So lock()1627// bumps this epoch synchronously at entry and zeroes keys/indexes before any1628// await; a restore that started earlier sees the mismatch after its next1629// await and aborts instead of publishing.1630let vaultEpoch = 0;16311632// One in-flight restore shared by concurrent callers: storage is read once1633// and the locked-sync unlock hook runs once.1634let restoreInFlight: Promise<boolean> | null = null;16351636/**1637 * On SW restart, re-derive all in-memory vault state from the session key1638 * stored in chrome.storage.session (set at unlock time, cleared by lock();1639 * also cleared on browser close or when restoration fails).1640 * Returns true if the vault was restored, false if no session key was found.1641 * No-ops if the vault is already unlocked.1642 */1643export async function restoreFromSession(): Promise<boolean> {1644  if (derivedKey !== null) return true;1645  if (restoreInFlight) return restoreInFlight;1646  const task = restoreFromSessionInner();1647  restoreInFlight = task;1648  try {1649    return await task;1650  } finally {1651    restoreInFlight = null;1652  }1653}16541655async function restoreFromSessionInner(): Promise<boolean> {1656  const epoch = vaultEpoch;1657  const stale = () => epoch !== vaultEpoch;1658  if (derivedKey !== null) return true;1659  if (!sessionEscrowAvailable) return false;1660  if (!chrome.storage.session) return false;1661  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1662  if (stale()) return false;1663  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1664  if (!entry?.keyHex) return false;16651666  const key = unhex(entry.keyHex);1667  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1668  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1669  const slot = await loadSlot(slotKey);1670  if (stale()) { zeroKey(key); return false; }1671  if (!slot) { await clearSessionKey(); return false; }16721673  const result = await tryDecryptSlotResult(slot, key, aadTag);1674  if (stale()) { zeroKey(key); return false; }1675  if (result.status !== "ok") { await clearSessionKey(); return false; }16761677  // Decrypted payload stays in locals until the final epoch-checked commit1678  // below; nothing observable is published before then.1679  const payload = result.payload;1680  await loadSettings();1681  if (stale()) { zeroKey(key); return false; }16821683  // Rehydrate the root key used by sync identity derivation from the password1684  // escrow stored on the current vault format.1685  let freshRoot: Uint8Array | null = null;1686  if (entry.mode === "real") {1687    freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1688    if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1689    if (!freshRoot) {1690      await lock();1691      return false;1692    }1693  }16941695  if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1696  if (freshRoot) {1697    // PB-26-213: encrypted pair-secret record + session copy, before publish.1698    await reconcilePairSecrets(freshRoot);1699    if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1700  } else {1701    // PB-26-213: a duress restore must never serve the real pair-secret map.1702    await dropSessionCache();1703    if (stale()) { zeroKey(key); return false; }1704  }1705  derivedKey = key;1706  vaultMode = entry.mode;1707  entries = payload.entries;1708  tombstones = payload.tombstones;1709  duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1710  if (freshRoot) rootKey = freshRoot;1711  buildDomainIndexSync(entries);1712  touchAutoLock();17131714  await lockedSyncUnlockHook?.().catch((error) => {1715    console.warn("[Peach sync] locked inbox drain failed:", error);1716  });1717  if (stale()) return false;17181719  return true;1720}17211722export async function lock(): Promise<void> {1723  // A user-initiated lock must be durable across MV3 service-worker restarts.1724  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1725  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1726  // If the session key survived lock(), that restore would silently re-unlock1727  // the vault ~a minute after the user locked it — a lock that only held1728  // while the SW happened to stay alive. The background-sync convenience the1729  // session key once bought (sync runner re-waking to a restored vault) is1730  // not worth an unlocked vault the user never asked to unlock; when locked,1731  // sync-runner correctly reports VAULT_LOCKED instead.1732  vaultEpoch++;1733  if (derivedKey) {1734    zeroKey(derivedKey);1735    derivedKey = null;1736  }1737  if (rootKey) {1738    rootKey.fill(0);1739    rootKey = null;1740  }17411742  entries = [];1743  tombstones = new Map();1744  domainIndex = new Map();1745  vaultMode = "real";1746  duressConfigured = false;1747  clearAutoLock();1748  await clearSessionKey();1749  await clearRecoveryConfirmPending();1750}17511752export function isUnlocked(): boolean {1753  return derivedKey !== null;1754}17551756// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1757// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1758// always derives from scratch via Argon2id.1759export async function unlockWithKey(1760  masterKeyHex: string,1761): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1762  const epoch = vaultEpoch;1763  const stale = () => epoch !== vaultEpoch;1764  let masterKeyBytes: Uint8Array | null = null;1765  try {1766    masterKeyBytes = unhex(masterKeyHex);1767    const stored = await loadSlot(SLOT_A);1768    if (!stored) return { ok: false, error: "invalid_key" };1769    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1770    if (!payload || stale()) {1771      zeroKey(masterKeyBytes);1772      return { ok: false, error: "invalid_key" };1773    }17741775    derivedKey = masterKeyBytes;1776    vaultMode = "real";1777    entries = payload.entries;1778    tombstones = payload.tombstones;1779    duressConfigured = payload.duressConfigured;1780    buildDomainIndexSync(entries);1781    await loadSettings();1782    touchAutoLock();17831784    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1785    if (!rootKey) {1786      await lock();1787      return { ok: false, error: "invalid_key" };1788    }1789    await reconcilePairSecrets(rootKey);1790    if (stale()) {1791      await lock();1792      return { ok: false, error: "invalid_key" };1793    }1794    await saveSessionKey(masterKeyBytes, "real", epoch);1795    if (stale()) return { ok: false, error: "invalid_key" };1796    return { ok: true, entries };1797  } catch {1798    if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1799    return { ok: false, error: "invalid_key" };1800  }1801}18021803// --- Adoption duress-slot helpers (passthrough, no decrypt) ---18041805/**1806 * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1807 * duress is configured and slot B exists, so the inline snapshot can ship it1808 * to the receiver. Returns null otherwise. Never decrypts.1809 */1810export async function exportDuressSlotForAdoption(): Promise<string | null> {1811  if (!(await isDuressEnabled())) return null;1812  const slotB = await loadSlot(SLOT_B);1813  if (!slotB) return null;1814  return JSON.stringify(slotB);1815}18161817/**1818 * Adoption RECEIVER helper: installs a duress slot received from a source1819 * device. Validates the StoredVault shape (same guard as unlockFromCodex)1820 * and writes it to slot B. Throws on invalid shape so the caller can treat1821 * it as a non-fatal snapshot failure. Never decrypts.1822 */1823export async function installAdoptedDuressSlot(json: string): Promise<void> {1824  let parsed: StoredVault;1825  try {1826    const raw = JSON.parse(json) as unknown;1827    if (!hasValidVaultSchema(raw)) {1828      throw new Error("invalid_duress_slot_shape");1829    }1830    parsed = raw;1831  } catch (err) {1832    if (err instanceof SyntaxError) {1833      throw new Error("invalid_duress_slot_shape");1834    }1835    throw err;1836  }1837  await writeSlot(SLOT_B, parsed);1838}18391840/**1841 * Restore the vault from a parsed Peach Codex payload.1842 *1843 * The supplied passphrase is verified against the Codex's slotA *before* any1844 * storage mutation — wrong passphrases return without touching disk. On1845 * success, the current vault state is snapshotted (reason: "restore") so the1846 * user can revert via Settings -> Vault History, then slotA/slotB are written1847 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A1848 * regular `unlock()` follows so the rest of the SW sees the standard1849 * post-unlock state.1850 *1851 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex1852 * printed by a Pro user must always be restorable, even on a free device.1853 *1854 * Timing is not held constant here (in contrast to `unlock`): the Codex flow1855 * is an explicit user action with a known passphrase, not a passive lock1856 * screen, so the duress-vs-real timing side channel does not apply.1857 */1858export async function unlockFromCodex(params: {1859  slotA: string; // JSON string of StoredVault for slot A1860  slotB: string | null; // JSON string of StoredVault for slot B, or null1861  timestamp: number;1862  masterPassphrase: string;1863}): Promise<1864  | { ok: true; entryCount: number }1865  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1866> {1867  let parsedSlotA: StoredVault;1868  let parsedSlotB: StoredVault | null = null;1869  try {1870    const rawA = JSON.parse(params.slotA) as unknown;1871    if (!hasValidVaultSchema(rawA)) {1872      return { ok: false, reason: "invalid_slot_format" };1873    }1874    parsedSlotA = rawA;1875    if (params.slotB !== null) {1876      const rawB = JSON.parse(params.slotB) as unknown;1877      if (!hasValidVaultSchema(rawB)) {1878        return { ok: false, reason: "invalid_slot_format" };1879      }1880      parsedSlotB = rawB;1881    }1882  } catch {1883    return { ok: false, reason: "invalid_slot_format" };1884  }18851886  // Verify the passphrase against the supplied slotA WITHOUT writing anything.1887  // If this fails the on-disk vault is left completely untouched.1888  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1889  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1890  if (!trialPayload) {1891    zeroKey(trialKey);1892    return { ok: false, reason: "wrong_passphrase" };1893  }1894  // Drop the trial key; `unlock()` below derives its own copy. Holding two1895  // copies of the master key in memory at once would be a needless surface.1896  zeroKey(trialKey);18971898  // Snapshot the about-to-be-overwritten on-disk state so the user can revert1899  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.1900  await takeVaultSnapshot("restore");19011902  await writeSlot(SLOT_A, parsedSlotA);1903  if (parsedSlotB) {1904    await writeSlot(SLOT_B, parsedSlotB);1905  } else {1906    // Slot B must always exist with ciphertext length matching slot A1907    // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't1908    // configured); regenerate noise to satisfy the invariant.1909    await writeSlot(1910      SLOT_B,1911      generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1912    );1913  }1914  await chrome.storage.local.set({1915    peach_last_modified_at: String(params.timestamp),1916  });19171918  // Standard unlock path so domain index, settings, auto-lock, and1919  // recovery-key state all populate exactly as a normal unlock would.1920  const ok = await unlock(params.masterPassphrase);1921  if (!ok) {1922    // Should be unreachable — we just verified the passphrase against the1923    // same slotA we wrote. Treat as wrong_passphrase to keep the contract1924    // simple if some race or storage anomaly intrudes.1925    return { ok: false, reason: "wrong_passphrase" };1926  }1927  return { ok: true, entryCount: getEntries().length };1928}19291930// Returns the current in-memory derived key as a hex string. Only the PIN1931// unlock path needs this value, and it reads module state directly; this1932// accessor exists so unlockWithKey's root-key rehydration is testable.1933export /* @testable */ function getDerivedKeyHex(): string {1934  if (!derivedKey) throw new Error("vault_not_unlocked");1935  return hex(derivedKey);1936}19371938/**1939 * Returns the vault's stable root key as hex, or null when locked / not yet1940 * populated. The root key is random per-vault, generated at creation, escrowed1941 * under the recovery words, and re-wrapped (not regenerated) on every password1942 * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.1943 *1944 * This is the correct input for sync identity derivation (vault_id, the Noise1945 * NK static key, the Ed25519 manifest signing key, and per-entry envelope1946 * keys): paired devices keep the same identity and can still decrypt each1947 * other's entries after either side rotates its master password (SEV-2-B).1948 *1949 * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED1950 * instead of crashing; rootKey is populated on every supported real-mode1951 * unlock via deriveRootKeyFromDerivedKey.1952 */1953export function getRootKeyHex(): string | null {1954  return rootKey ? hex(rootKey) : null;1955}19561957export function getEntries(): VaultEntry[] {1958  if (!derivedKey) throw new Error("vault_locked");1959  return entries.filter((entry) => !entry.deletedAt);1960}19611962const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;19631964export function getIdentities(): SavedIdentity[] {1965  if (!derivedKey) throw new Error("vault_locked");1966  const active = entries.filter((e) => !e.deletedAt && e.type === "login");19671968  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1969  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();19701971  for (const entry of active) {1972    const domain = extractDomain(entry.url ?? "") ?? "";1973    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;19741975    function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1976      const existing = map.get(v);1977      if (!existing || modifiedAt > existing.lastUsed) {1978        map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1979      } else {1980        map.set(v, { ...existing, count: existing.count + 1 });1981      }1982    }19831984    const uVal = entry.username?.trim();1985    if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);19861987    // Also count the dedicated email field — entries created via the inline1988    // popup new-entry form store email here rather than in username.1989    const eVal = entry.email?.trim();1990    if (eVal) tally(emailMap, eVal);1991  }19921993  const toIdentity = (type: "email" | "username") =>1994    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1995      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1996    });19971998  return [1999    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2000    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2001  ];2002}20032004export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2005  if (!derivedKey) throw new Error("vault_locked");20062007  const now = Date.now();2008  const added: VaultEntry[] = [];2009  for (const draft of drafts) {2010    try {2011      const sanitized = sanitizeEntryDraft(draft);2012      const nextEntry: VaultEntry = {2013        ...sanitized,2014        id: crypto.randomUUID(),2015        createdAt: now,2016        modifiedAt: monotonicEntryTimestamp(),2017      };2018      entries.push(nextEntry);2019      added.push(nextEntry);2020    } catch {2021      // Skip invalid drafts silently — caller can see diff between input and output length2022    }2023  }20242025  if (added.length > 0) {2026    await persistEntries();2027  }20282029  return added;2030}20312032/**2033 * Restore a set of entries in full fidelity — preserves id, createdAt,2034 * modifiedAt, and every other field unlike `batchAddEntries` which2035 * assigns fresh identity. Used by restore paths where the backup is the2036 * authoritative source of truth. `mode` controls how entries already in2037 * the vault are treated.2038 */2039export async function restoreEntries(2040  incoming: VaultEntry[],2041  mode: "replace" | "merge"2042): Promise<{ added: number; updated: number; skipped: number }> {2043  if (!derivedKey) throw new Error("vault_locked");20442045  // A replace with zero incoming entries is almost certainly a caller bug2046  // (e.g. an import that silently dropped everything). Refuse rather than2047  // wipe the vault.2048  if (mode === "replace" && incoming.length === 0) {2049    throw new Error("refuse to replace with empty entry set");2050  }20512052  let added = 0;2053  let updated = 0;2054  let skipped = 0;20552056  if (mode === "replace") {2057    entries = [];2058    for (const entry of incoming) {2059      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2060      if (!normalized) {2061        skipped += 1;2062        continue;2063      }2064      entries.push(normalized);2065      if (normalized.deletedAt) {2066        tombstones.set(2067          normalized.id,2068          Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2069        );2070      } else {2071        tombstones.delete(normalized.id);2072      }2073      added += 1;2074    }2075  } else {2076    const byId = new Map<string, VaultEntry>();2077    for (const e of entries) byId.set(e.id, e);20782079    for (const entry of incoming) {2080      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2081      if (!normalized) {2082        skipped += 1;2083        continue;2084      }2085      const existing = byId.get(normalized.id);2086      if (!existing) {2087        entries.push(normalized);2088        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2089        else tombstones.delete(normalized.id);2090        added += 1;2091      } else if (normalized.modifiedAt > existing.modifiedAt) {2092        const idx = entries.findIndex((e) => e.id === existing.id);2093        if (idx !== -1) {2094          entries[idx] = normalized;2095          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2096          else tombstones.delete(normalized.id);2097          updated += 1;2098        }2099      } else {2100        skipped += 1;2101      }2102    }2103  }21042105  await persistEntries("import");2106  return { added, updated, skipped };2107}21082109/**2110 * A Peach entry id is a UUID. A restored or imported file carries whatever id2111 * its author used, and no reader bounds it, so one oversized id would ride2112 * into every future sync frame and be rejected there — a permanent, opaque2113 * failure on both platforms, caused by data that was already local.2114 *2115 * The action is mint, not reject: this is the last point where an id is still2116 * ours to choose, so the worst outcome is one duplicate if the same vault is2117 * later restored beside a device still holding the old id. Rejecting would2118 * lose the user's import.2119 *2120 * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries2121 * also calls. Sync has to adopt a peer's id verbatim — that is the only thing2122 * keeping two devices agreed on which entry they are discussing — so reminting2123 * there would fork identity on every device, on every round.2124 */2125const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;21262127function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2128  if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2129  return { ...entry, id: crypto.randomUUID() };2130}21312132/**2133 * Validate + normalise a VaultEntry coming from a backup, import, or sync2134 * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies2135 * only known VaultEntry fields (unknown/attacker keys are dropped), and2136 * nested `passkey`/`canary` shapes are rebuilt field-by-field, never2137 * spread. Returns null for values the live vault shouldn't accept.2138 */2139function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2140  if (!entry || typeof entry !== "object") return null;2141  const title = typeof entry.title === "string" ? entry.title.trim() : "";2142  if (!title) return null;2143  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2144  const now = Date.now();2145  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2146  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;21472148  // normalizeEntry whitelists every known VaultEntry field before the entry2149  // reaches the live vault.2150  const normalized = normalizeEntry({2151    ...entry,2152    id,2153    title,2154    type: entry.type ?? "login",2155    createdAt,2156    modifiedAt,2157  });21582159  if (normalized.passkey) {2160    normalized.passkey = sanitizePasskey(normalized.passkey);2161  }2162  if (normalized.canary) {2163    normalized.canary = sanitizeCanary(normalized.canary);2164  }2165  // PCH-01-016: bound tags on the restore path too, so both import funnels agree.2166  normalized.tags = sanitizeTags(normalized.tags);21672168  // Login entries from backup must carry a password (or be passkey-only,2169  // in which case the passkey sub-object replaces the password requirement).2170  if (2171    normalized.type === "login" &&2172    !normalized.passkey &&2173    typeof normalized.password !== "string"2174  ) {2175    return null;2176  }2177  if (entry.passkey && !normalized.passkey) {2178    // Entry claimed a passkey but the shape didn't validate — refuse the2179    // whole entry rather than silently importing a credential-less husk.2180    return null;2181  }21822183  return normalized;2184}21852186/**2187 * Rebuild a PasskeyData object from untrusted input, field by field.2188 * Returns undefined when required fields are missing/mistyped — a rogue2189 * passkey credential must not activate via import (MV-6 attack scenario).2190 */2191function sanitizePasskey(pk: unknown): PasskeyData | undefined {2192  if (!pk || typeof pk !== "object") return undefined;2193  const r = pk as Record<string, unknown>;2194  if (2195    typeof r.rpId !== "string" || !r.rpId ||2196    typeof r.credentialId !== "string" || !r.credentialId ||2197    typeof r.publicKeyCose !== "string" ||2198    typeof r.userHandle !== "string" ||2199    typeof r.userName !== "string"2200  ) {2201    return undefined;2202  }2203  return {2204    rpId: r.rpId,2205    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2206    credentialId: r.credentialId,2207    publicKeyCose: r.publicKeyCose,2208    privateKeyJwk:2209      r.privateKeyJwk && typeof r.privateKeyJwk === "object"2210        ? (r.privateKeyJwk as JsonWebKey)2211        : undefined,2212    userHandle: r.userHandle,2213    userName: r.userName,2214    userDisplayName:2215      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2216    algorithm: r.algorithm === -8 ? -8 : -7,2217    signCount: typeof r.signCount === "number" ? r.signCount : 0,2218    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2219    backupEligible: r.backupEligible === true,2220    backupState: r.backupState === true,2221    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2222    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2223    transports: Array.isArray(r.transports)2224      ? r.transports.filter((t): t is string => typeof t === "string")2225      : undefined,2226    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2227  };2228}22292230/**2231 * Rebuild a Canary object from untrusted input. The webhook must be a2232 * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing2233 * exfiltration endpoint that the canary subsystem will POST entry titles2234 * to (MV-6 attack scenario). Invalid → the canary is dropped, not the2235 * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire2236 * time (isAllowedCanaryWebhookUrl), so this import-time check is the2237 * outer of two gates.2238 */2239function sanitizeCanary(c: unknown): Canary | undefined {2240  if (!c || typeof c !== "object") return undefined;2241  const r = c as Record<string, unknown>;2242  if (typeof r.webhookUrl !== "string") return undefined;2243  let url: URL;2244  try {2245    url = new URL(r.webhookUrl);2246  } catch {2247    return undefined;2248  }2249  if (url.protocol !== "https:") return undefined;2250  return {2251    webhookUrl: url.toString(),2252    label: typeof r.label === "string" ? r.label : undefined,2253    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2254    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2255  };2256}22572258/**2259 * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted.2260 * Keep only non-empty strings, cap each at 256 chars, and cap the count at 2552261 * (the same per-entry ceiling the export codec enforces, peachPayload.ts).2262 * Non-array or all-invalid input collapses to undefined.2263 */2264function sanitizeTags(tags: unknown): string[] | undefined {2265  if (!Array.isArray(tags)) return undefined;2266  const out: string[] = [];2267  for (const t of tags) {2268    if (typeof t !== "string") continue;2269    const trimmed = t.trim();2270    if (!trimmed) continue;2271    out.push(trimmed.slice(0, 256));2272    if (out.length >= 255) break;2273  }2274  return out.length > 0 ? out : undefined;2275}22762277// Soft-deletes every non-deleted entry in one call. The literal string2278// argument is a tripwire against accidental calls — any caller that would2279// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this2280// to a boolean.2281export async function clearVaultEntries(2282  confirmation: "REPLACE_ALL"2283): Promise<void> {2284  if (confirmation !== "REPLACE_ALL") {2285    throw new Error("clearVaultEntries requires explicit confirmation");2286  }2287  if (!derivedKey) throw new Error("vault_locked");2288  for (const entry of entries) {2289    if (!entry.deletedAt) {2290      const stamp = monotonicEntryTimestamp(Math.max(2291        entry.modifiedAt,2292        tombstones.get(entry.id) ?? 0,2293      ));2294      entry.deletedAt = stamp;2295      entry.modifiedAt = stamp;2296      tombstones.set(entry.id, stamp);2297    }2298  }2299  await persistEntries();2300}23012302export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2303  if (!derivedKey) throw new Error("vault_locked");23042305  const sanitized = sanitizeEntryDraft(entry);2306  const now = Date.now();2307  const stamp = monotonicEntryTimestamp();2308  const nextEntry: VaultEntry = {2309    ...sanitized,2310    id: crypto.randomUUID(),2311    createdAt: now,2312    modifiedAt: stamp,2313    // Stamp passwordChangedAt at creation so age tracking is accurate from2314    // day one. Non-login entries carry the same stamp harmlessly.2315    passwordChangedAt: sanitized.password ? stamp : undefined,2316  };23172318  // Clone-and-replace so a failed persist rolls back the in-memory state2319  // (COR-008). Without this, a quota-full or storage error leaves the2320  // module-level `entries` ahead of what's actually on disk.2321  const rollback = entries;2322  entries = [...entries, nextEntry];2323  try {2324    await persistEntries();2325  } catch (err) {2326    entries = rollback;2327    throw err;2328  }23292330  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)2331  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2332    checkBreach(nextEntry.password).then(async (count) => {2333      if (count > 0) {2334        const idx = entries.findIndex((e) => e.id === nextEntry.id);2335        if (idx !== -1) {2336          entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2337          await persistEntries();2338        }2339      }2340    });2341  }23422343  fireSyncTrigger("vault_change");23442345  return nextEntry;2346}23472348/**2349 * Run the passkey-merge pass on the live in-memory entries and persist if2350 * anything changed. Called immediately after any passkey registration so that2351 * a same-domain TOTP-only entry already in the vault gets consolidated right2352 * away instead of waiting until the next lock/unlock cycle.2353 */2354export async function consolidatePasskeyEntries(): Promise<void> {2355  if (!derivedKey) return;2356  const { merged } = mergePasskeyOnlyIntoLogins(entries);2357  if (merged) {2358    await persistEntries();2359  }2360}23612362export async function updateEntry(2363  id: string,2364  patch: Partial<VaultEntry>2365): Promise<void> {2366  if (!derivedKey) throw new Error("vault_locked");23672368  const idx = entries.findIndex((entry) => entry.id === id);2369  if (idx === -1 || entries[idx].deletedAt) {2370    throw new Error("entry_not_found");2371  }23722373  const current = entries[idx];2374  const sanitizedPatch = sanitizeEntryPatch(patch);2375  // Refresh passwordChangedAt only when the password value actually changes —2376  // a metadata-only update (title, tags, etc.) must not reset the age clock2377  // that drives the Pro health rotation recommendation.2378  const passwordChanged =2379    "password" in patch &&2380    typeof sanitizedPatch.password === "string" &&2381    sanitizedPatch.password !== current.password;2382  const stamp = monotonicEntryTimestamp(current.modifiedAt);2383  // When the password actually changes, push the outgoing one onto the history2384  // (newest first) so the user can reveal/copy/restore it later. We record the2385  // age it carried (passwordChangedAt, falling back to createdAt) rather than2386  // `now`, so the list reflects when each password was last in effect. Only2387  // capture a non-empty prior value — clearing a password leaves no secret to2388  // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)2389  // to avoid noise.2390  const priorPassword = current.password;2391  const shouldCapture =2392    passwordChanged &&2393    typeof priorPassword === "string" &&2394    priorPassword.length > 0 &&2395    current.passwordHistory?.[0]?.password !== priorPassword;2396  const nextHistory = shouldCapture2397    ? [2398        { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2399        ...(current.passwordHistory ?? []),2400      ].slice(0, PASSWORD_HISTORY_MAX)2401    : current.passwordHistory;2402  entries[idx] = {2403    ...current,2404    ...sanitizedPatch,2405    modifiedAt: stamp,2406    ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2407  };2408240924102411  // Card entries must retain their card number2412  if (entries[idx].type === "card" && !entries[idx].cardNumber) {2413    entries[idx] = current;2414    throw new Error("card number is required for card entries");2415  }24162417  // Roll the in-memory entry back to its pre-edit state if the disk write2418  // fails (e.g. storage quota). Without this the module-level `entries` array2419  // would hold an edit that never landed on disk, diverging from the vault2420  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.2421  try {2422    await persistEntries();2423  } catch (err) {2424    entries[idx] = current;2425    throw err;2426  }24272428  // Breach check when password changes: clear immediately so old breach doesn't linger,2429  // then async-check the new password and only re-set if it's also found.2430  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2431    entries[idx] = { ...entries[idx], breach: undefined };2432    await persistEntries();2433    const password = entries[idx].password;2434    if (password) {2435      checkBreach(password).then(async (count) => {2436        const i = entries.findIndex((e) => e.id === id);2437        if (i !== -1 && count > 0) {2438          entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2439          await persistEntries();2440        }2441      });2442    }2443  }24442445  fireSyncTrigger("vault_change");2446}24472448export async function deleteEntry(id: string): Promise<void> {2449  if (!derivedKey) throw new Error("vault_locked");24502451  const entry = entries.find((item) => item.id === id);2452  if (!entry || entry.deletedAt) {2453    throw new Error("entry_not_found");2454  }24552456  // Tombstone timestamp must be strictly greater than the entry's last2457  // modification so the deletion wins LWW against any in-flight edit, even2458  // under clock skew (SEV-2-E).2459  const previousModifiedAt = entry.modifiedAt;2460  const previousTombstone = tombstones.get(id);2461  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2462  entry.deletedAt = stamp;2463  entry.modifiedAt = stamp;2464  tombstones.set(id, stamp);2465  // Roll the tombstone back if the disk write fails, so a quota error doesn't2466  // leave the entry deleted in memory but alive on disk (SEV-3-A).2467  try {2468    await persistEntries();2469  } catch (err) {2470    delete entry.deletedAt;2471    entry.modifiedAt = previousModifiedAt;2472    if (previousTombstone === undefined) tombstones.delete(id);2473    else tombstones.set(id, previousTombstone);2474    throw err;2475  }2476  fireSyncTrigger("vault_change");2477}24782479export function getDeletedEntries(): VaultEntry[] {2480  if (!derivedKey) return [];2481  return entries.filter((e) => !!e.deletedAt);2482}24832484/** Return all durable deletion timestamps, including purged full entries. */2485export function getTombstoneManifest(): Record<string, number> {2486  if (!derivedKey) return {};2487  return Object.fromEntries(tombstones);2488}24892490/**2491 * Returns the vault's monotonic version — the `peach_last_modified_at` value2492 * stamped on every user-initiated write. Remote (sync) writes do NOT advance2493 * this version so a sync round never cascades into a second outbound sync.2494 */2495export async function getVaultVersion(): Promise<number> {2496  try {2497    const raw = await chrome.storage.local.get("peach_last_modified_at");2498    const v = (raw as Record<string, unknown>).peach_last_modified_at;2499    if (typeof v === "number") return v;2500    if (typeof v === "string" && v !== "") {2501      const n = Number.parseInt(v, 10);2502      return Number.isFinite(n) ? n : 0;2503    }2504    return 0;2505  } catch {2506    return 0;2507  }2508}25092510/**2511 * Apply a batch of remote entries + tombstones from a sync peer.2512 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when2513 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and2514 * persists with reason `sync_received` — does NOT fire the sync dispatcher, so2515 * applying a sync round cannot cascade into another outbound sync.2516 *2517 * `applied` counts entries that actually changed local state (new add,2518 * overwrite-by-newer, or new tombstone). `received` is the raw payload size2519 * the caller can surface in the sync result UI.2520 */2521export async function applyMergedRemoteEntries(2522  remoteEntries: VaultEntry[],2523  remoteTombstones: Record<string, number>,2524  _context: { deviceName?: string } = {},2525): Promise<{ received: number; applied: number; conflicts: number }> {2526  // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices2527  // concurrently edit different fields of the same entry, the one with the2528  // newer modifiedAt wins the whole entry and the other device's field edits2529  // are dropped silently. This is the deliberate trade-off for a simple,2530  // conflict-free merge — per-field CRDT merge is out of scope. Guidance for2531  // users: edit a given entry on one device at a time.2532  if (!derivedKey) throw new Error("vault_locked");25332534  const received = remoteEntries.length + Object.keys(remoteTombstones).length;2535  let applied = 0;2536  let conflicts = 0;25372538  const byId = new Map<string, VaultEntry>();2539  for (const e of entries) byId.set(e.id, e);25402541  const mergeNow = Date.now();2542  const remoteTombstoneTimes = new Map<string, number>();2543  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2544    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2545      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2546    }2547  }25482549  for (const remote of remoteEntries) {2550    const sanitized = sanitizeRestoredEntry(remote);2551    if (!sanitized) continue;2552    // HV-1a: a far-future remote modifiedAt must not permanently pin a2553    // malicious value against honest local edits. Clamp BEFORE any2554    // comparison against local timestamps.2555    sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2556    if (typeof sanitized.deletedAt === "number") {2557      sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2558    }2559    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2560    const localTombstoneTs = tombstones.get(sanitized.id);2561    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2562    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2563      continue;2564    }2565    const local = byId.get(sanitized.id);2566    if (!local) {2567      entries.push(sanitized);2568      byId.set(sanitized.id, sanitized);2569      tombstones.delete(sanitized.id);2570      applied += 1;2571    } else if (sanitized.modifiedAt > local.modifiedAt) {2572      const idx = entries.findIndex((e) => e.id === local.id);2573      if (idx !== -1) {2574        entries[idx] = sanitized;2575        byId.set(sanitized.id, sanitized);2576        tombstones.delete(sanitized.id);2577        applied += 1;2578        if (!local.deletedAt) conflicts += 1;2579      }2580    }2581  }25822583  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2584    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2585    // HV-1a/1c: clamp the tombstone timestamp before every comparison.2586    const deletedAt = remoteTombstoneTimes.get(id)!;2587    const knownTombstone = tombstones.get(id);2588    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2589    const local = byId.get(id);2590    if (!local) {2591      tombstones.set(id, deletedAt);2592      applied += 1;2593      continue;2594    }2595    if (local.deletedAt && local.deletedAt >= deletedAt) continue;2596    if (deletedAt < local.modifiedAt) continue;2597    const idx = entries.findIndex((e) => e.id === id);2598    if (idx !== -1) {2599      entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2600      tombstones.set(id, deletedAt);2601      applied += 1;2602    }2603  }26042605  if (applied > 0) {2606    await persistEntries("sync_received");2607    buildDomainIndexSync(entries);2608  }2609  return { received, applied, conflicts };2610}26112612export async function restoreEntry(id: string): Promise<void> {2613  if (!derivedKey) throw new Error("vault_locked");2614  const entry = entries.find((e) => e.id === id && e.deletedAt);2615  if (!entry) throw new Error("deleted entry not found");2616  const previousDeletedAt = entry.deletedAt;2617  const previousModifiedAt = entry.modifiedAt;2618  const previousTombstone = tombstones.get(id);2619  delete entry.deletedAt;2620  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2621    entry.modifiedAt,2622    previousTombstone ?? 0,2623  ));2624  tombstones.delete(id);2625  try {2626    await persistEntries();2627  } catch (err) {2628    entry.deletedAt = previousDeletedAt;2629    entry.modifiedAt = previousModifiedAt;2630    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2631    throw err;2632  }2633  buildDomainIndexSync(entries);2634}26352636export async function purgeEntry(id: string): Promise<void> {2637  if (!derivedKey) throw new Error("vault_locked");2638  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2639  if (idx === -1) throw new Error("deleted entry not found");2640  const entry = entries[idx]!;2641  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2642  entries.splice(idx, 1);2643  await persistEntries();2644}26452646/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */2647export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2648  if (!derivedKey || retentionMs === 0) return 0;2649  const cutoff = Date.now() - retentionMs;2650  const before = entries.length;2651  for (const entry of entries) {2652    if (entry.deletedAt && entry.deletedAt < cutoff) {2653      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2654    }2655  }2656  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2657  const purged = before - entries.length;2658  if (purged > 0) await persistEntries();2659  return purged;2660}26612662export function queryDomain(domain: string): VaultEntry[] {2663  return domainIndex.get(domain) ?? [];2664}26652666export function getEntryById(id: string): VaultEntry | undefined {2667  return entries.find((entry) => entry.id === id && !entry.deletedAt);2668}26692670async function persistSlotA(2671  reason: VaultSnapshot["reason"] = "local_save",2672): Promise<StoredVault> {2673  if (!derivedKey) throw new Error("vault_locked");26742675  const slotA = await loadSlot(SLOT_A);2676  if (!slotA) throw new Error("missing slot A");26772678  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures2679  // are swallowed by takeVaultSnapshot — history must never block a save.2680  await takeVaultSnapshot(reason);26812682  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2683  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");26842685  // Preserve existing recovery escrow fields.2686  const nextSlotA: StoredVault = {2687    ...slotA,2688    nonce,2689    ciphertext,2690    tag,2691    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot2692  };2693  await writeSlot(SLOT_A, nextSlotA);26942695  // Slot A (the real vault) is now committed. Wrap every later write so a2696  // mid-persist failure (e.g. a storage error after slot A landed) restores the2697  // prior slot A before rethrowing. Without this, a "failed" save that already2698  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/2699  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces2700  // as a duplicate on the next unlock (observed under storage-quota pressure).2701  try {2702    // When duress is not configured, keep slot B size-matched with slot A by2703    // regenerating its noise. When duress IS configured, leave slot B alone —2704    // we don't have the decoy key to re-encrypt it. This is a known trade-off.2705    if (!duressConfigured) {2706      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2707    }2708    // Only advance the vault version for locally-originated writes. Entries2709    // applied from a sync peer ("sync_received") must NOT bump the counter —2710    // otherwise applying a remote round makes us look newer than the peer and2711    // triggers a redundant outbound sync, defeating the no-op optimisation2712    // and risking a ping-pong between two devices (SEV-2-C).2713    if (reason !== "sync_received") {2714      await stampLocalVaultMutation();2715    }2716  } catch (err) {2717    // Best-effort restore — the prior slot A is smaller-or-equal, so this write2718    // fits even when the failure was a quota overflow.2719    await writeSlot(SLOT_A, slotA).catch(() => {});2720    throw err;2721  }27222723  return nextSlotA;2724}27252726async function persistSlotB(): Promise<void> {2727  if (!derivedKey) throw new Error("vault_locked");27282729  const slotB = await loadSlot(SLOT_B);2730  if (!slotB) throw new Error("missing slot B");27312732  // Decoy plaintext always reports duressConfigured=false — the decoy user2733  // sees a clean slate. persistSlotB only runs in decoy mode, which only2734  // exists once duress is configured, so pad to the fixed duress ceiling to2735  // keep slot B's length equal to slot A's (audit MEDIUM — deniability).2736  const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2737  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");27382739  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2740  // Symmetric with persistSlotA: if the version stamp fails after the decoy2741  // slot committed, restore the prior slot B so a failed decoy-mode save can't2742  // leave disk ahead of the in-memory rollback.2743  try {2744    await stampLocalVaultMutation();2745  } catch (err) {2746    await writeSlot(SLOT_B, slotB).catch(() => {});2747    throw err;2748  }2749}27502751async function persistEntries(2752  reason: VaultSnapshot["reason"] = "local_save",2753): Promise<void> {2754  if (!derivedKey) throw new Error("vault_locked");27552756  // Reconcile direct soft-delete callers while restoring the previous map if2757  // storage rejects the save.2758  const previousTombstones = tombstones;2759  tombstones = new Map(tombstones);2760  reconcileTombstones();2761  try {2762    if (vaultMode === "decoy") {2763      await persistSlotB();2764    } else {2765      await persistSlotA(reason);2766    }2767  } catch (err) {2768    tombstones = previousTombstones;2769    throw err;2770  }2771  buildDomainIndexSync(entries);2772  await refreshLockedSyncState();2773}27742775// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage2776// boundary. Schemeless values (e.g. "github.com") are inert and kept — they2777// pre-date this guard in existing data. Unparseable values are kept: the2778// reviewed render paths never execute them (favicons via _favicon/, domains2779// via tldts); this is defense-in-depth against future sinks.2780function allowHttpUrl(url: string | undefined): string {2781  const trimmed = url?.trim() ?? "";2782  if (!trimmed) return "";2783  try {2784    const parsed = new URL(trimmed);2785    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2786  } catch {2787    // No scheme to reject.2788  }2789  return trimmed;2790}27912792function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2793  if (!Array.isArray(values)) return undefined;2794  const seen = new Set<string>();2795  const cleaned: string[] = [];2796  for (const value of values) {2797    const trimmed = typeof value === "string" ? value.trim() : "";2798    if (!trimmed) continue;2799    if (!allowHttpUrl(trimmed)) continue;2800    const key = trimmed.toLowerCase();2801    if (seen.has(key)) continue;2802    seen.add(key);2803    cleaned.push(trimmed);2804  }2805  return cleaned.length > 0 ? cleaned : undefined;2806}28072808function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2809  const title = entry.title.trim();2810  const type: VaultEntry["type"] = entry.type ?? "login";28112812  if (!title) throw new Error("title is required");28132814  if (entry.passkey) {2815    const p = entry.passkey;2816    if (2817      !p.rpId ||2818      !p.credentialId ||2819      !p.publicKeyCose ||2820      !p.userHandle ||2821      !p.userName ||2822      (p.algorithm !== -7 && p.algorithm !== -8)2823    ) {2824      throw new Error("invalid passkey credential");2825    }2826  }2827  if (type === "card") {2828    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2829      throw new Error("cardholder name and card number are required");2830    }2831  }28322833  return {2834    type,2835    title,2836    username: type === "login" ? entry.username?.trim() ?? "" : undefined,2837    email: type === "login" ? entry.email?.trim() || undefined : undefined,2838    password: type === "login" ? entry.password : undefined,2839    url: type === "login" ? allowHttpUrl(entry.url) : undefined,2840    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2841    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2842    // PCH-01-019: preserve non-default TOTP params through batch-add/import so2843    // they are not silently stripped (they fall back to SHA-1/6/30 when absent).2844    totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2845    totpDigits: type === "login" ? entry.totpDigits : undefined,2846    totpPeriod: type === "login" ? entry.totpPeriod : undefined,2847    notes: entry.notes?.trim() || undefined,2848    breach: type === "login" ? entry.breach : undefined,2849    // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags2850    // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here.2851    canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2852    tags: sanitizeTags(entry.tags),2853    passkey: entry.passkey,2854    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2855    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2856    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2857    expYear: type === "card" ? entry.expYear?.trim() : undefined,2858    cvc: type === "card" ? entry.cvc?.trim() : undefined,2859    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2860    network: type === "card" ? entry.network : undefined,2861    last4: type === "card" ? entry.last4?.trim() : undefined,2862  };2863}28642865function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2866  const nextPatch: Partial<VaultEntry> = {};28672868  if ("type" in patch) {2869    nextPatch.type = patch.type;2870  }28712872  if ("title" in patch) {2873    const title = patch.title?.trim() ?? "";2874    if (!title) throw new Error("title is required");2875    nextPatch.title = title;2876  }28772878  if ("url" in patch) {2879    nextPatch.url = allowHttpUrl(patch.url);2880  }28812882  if ("additionalUrls" in patch) {2883    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2884    const seen = new Set<string>();2885    const cleaned: string[] = [];2886    for (const value of incoming) {2887      const trimmed = typeof value === "string" ? value.trim() : "";2888      if (!trimmed || !allowHttpUrl(trimmed)) continue;2889      const key = trimmed.toLowerCase();2890      if (seen.has(key)) continue;2891      seen.add(key);2892      cleaned.push(trimmed);2893    }2894    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2895  }28962897  if ("username" in patch) {2898    nextPatch.username = patch.username?.trim() ?? "";2899  }29002901  if ("email" in patch) {2902    nextPatch.email = patch.email?.trim() || undefined;2903  }29042905  if ("password" in patch) {2906    const password = patch.password ?? "";2907    // Allow clearing a password (e.g. on passkey-only entries)2908    nextPatch.password = password || undefined;2909  }29102911  if ("totp" in patch) {2912    nextPatch.totp = patch.totp?.trim() || undefined;2913  }29142915  if ("notes" in patch) {2916    nextPatch.notes = patch.notes?.trim() || undefined;2917  }29182919  if ("breach" in patch) {2920    nextPatch.breach = patch.breach;2921  }29222923  if ("canary" in patch) {2924    // PCH-02-001: route update patches through the same canary whitelist used2925    // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.2926    nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2927  }29282929  if ("passkey" in patch) {2930    // A patch with `passkey: undefined` removes the credential from the entry2931    // (used by "remove passkey" without deleting the whole entry).2932    if (patch.passkey === undefined || patch.passkey === null) {2933      nextPatch.passkey = undefined;2934    } else {2935      const sanitized = sanitizePasskey(patch.passkey);2936      if (!sanitized) throw new Error("invalid passkey credential");2937      nextPatch.passkey = sanitized;2938    }2939  }29402941  if ("tags" in patch) {2942    // PCH-02-001: bound tags exactly like both import funnels.2943    nextPatch.tags = sanitizeTags(patch.tags);2944  }29452946  if ("cardholderName" in patch) {2947    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2948  }2949  if ("cardNumber" in patch) {2950    const n = patch.cardNumber ?? "";2951    nextPatch.cardNumber = n || undefined;2952  }2953  if ("expMonth" in patch) {2954    nextPatch.expMonth = patch.expMonth?.trim() || undefined;2955  }2956  if ("expYear" in patch) {2957    nextPatch.expYear = patch.expYear?.trim() || undefined;2958  }2959  if ("cvc" in patch) {2960    nextPatch.cvc = patch.cvc?.trim() || undefined;2961  }2962  if ("cardNickname" in patch) {2963    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2964  }2965  if ("network" in patch) {2966    nextPatch.network = patch.network;2967  }2968  if ("last4" in patch) {2969    nextPatch.last4 = patch.last4?.trim() || undefined;2970  }29712972  return nextPatch;2973}29742975function normalizeEntry(entry: VaultEntry): VaultEntry {2976  const base: VaultEntry = {2977    id: entry.id,2978    type: entry.type,2979    title: entry.title,2980    username: entry.username,2981    email: entry.email,2982    password: entry.password,2983    url: entry.url,2984    additionalUrls:2985      "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2986        ? entry.additionalUrls2987        : undefined,2988    totp: entry.totp,2989    // PCH-01-019: carry TOTP params through the normalize/restore path too.2990    totpAlgorithm: entry.totpAlgorithm,2991    totpDigits: entry.totpDigits,2992    totpPeriod: entry.totpPeriod,2993    notes: entry.notes,2994    breach: entry.breach,2995    // Preserve the password-age clock across lock/unlock. Without this the2996    // field is dropped on every decode and the advanced-health "aged password"2997    // signal silently resets to createdAt on each unlock (SEV-2-A).2998    passwordChangedAt:2999      "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3000    // Preserve past-password history across lock/unlock. normalizeEntry is a3001    // field whitelist, so an unlisted field is silently dropped on every decode3002    // (same class as the passwordChangedAt SEV-2-A above).3003    passwordHistory:3004      "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3005        ? entry.passwordHistory3006        : undefined,3007    canary: entry.canary,3008    passkey: entry.passkey,3009    createdAt: entry.createdAt,3010    modifiedAt: entry.modifiedAt,3011    deletedAt: entry.deletedAt,3012    tags: entry.tags,3013    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3014    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3015    expMonth: "expMonth" in entry ? entry.expMonth : undefined,3016    expYear: "expYear" in entry ? entry.expYear : undefined,3017    cvc: "cvc" in entry ? entry.cvc : undefined,3018    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3019    network: "network" in entry ? entry.network : undefined,3020    last4: "last4" in entry ? entry.last4 : undefined,3021  };30223023  return base;3024}302530263027// ---------------------------------------------------------------------------3028// Deduplication3029// ---------------------------------------------------------------------------30303031function entryDupeKey(e: VaultEntry): string {3032  if (e.type === "note") return `note|${e.title.toLowerCase()}`;3033  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins3034  // on different sites that happen to share a username are still distinct;3035  // two passkey credentials for the same RP+user are duplicates regardless3036  // of any login fields they may also carry.3037  if (e.passkey) {3038    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3039  }3040  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3041}30423043/** Returns the number of duplicate entries (those that would be removed). */3044export function countDuplicates(): number {3045  const best = new Map<string, VaultEntry>();3046  let dupeCount = 0;3047  for (const e of entries.filter((e) => !e.deletedAt)) {3048    const key = entryDupeKey(e);3049    const existing = best.get(key);3050    if (!existing) {3051      best.set(key, e);3052    } else {3053      dupeCount++;3054      // Keep the more recently modified one in the map3055      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3056        best.set(key, e);3057      }3058    }3059  }3060  return dupeCount;3061}30623063/**3064 * Soft-deletes duplicate entries, keeping the most recently modified copy of3065 * each group. Returns the number of entries moved to trash.3066 */3067export async function deduplicateEntries(): Promise<number> {3068  if (!derivedKey) throw new Error("vault_locked");3069  // Dedup reshapes slot B permanently under the decoy key, which is3070  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.3071  if (vaultMode === "decoy") {3072    throw new Error("deduplicate disabled in decoy mode");3073  }30743075  const best = new Map<string, VaultEntry>();3076  for (const e of entries.filter((en) => !en.deletedAt)) {3077    const key = entryDupeKey(e);3078    const existing = best.get(key);3079    if (!existing) {3080      best.set(key, e);3081    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3082      best.set(key, e);3083    }3084  }30853086  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3087  let removed = 0;3088  entries = entries.map((e) => {3089    if (!e.deletedAt && !keepIds.has(e.id)) {3090      removed++;3091      const deletedAt = monotonicEntryTimestamp(Math.max(3092        e.modifiedAt,3093        tombstones.get(e.id) ?? 0,3094      ));3095      tombstones.set(e.id, deletedAt);3096      return { ...e, deletedAt };3097    }3098    return e;3099  });31003101  if (removed > 0) await persistEntries();3102  return removed;3103}31043105// ---------------------------------------------------------------------------3106// Vault history — snapshot BEFORE every vault write so the saved blob is3107// what is about to be lost, not what was just written. Capped at 5. The3108// stored blobs are AES-256-GCM ciphertext under the master password, so3109// history adds zero security surface.3110// ---------------------------------------------------------------------------31113112const HISTORY_KEY = "peach_vault_history";3113const HISTORY_MAX = 5;31143115// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the3116// plaintext snapshot field cannot reveal exact vault size or single-entry change3117// deltas. Buckets widen as the vault grows so absolute leakage stays roughly3118// proportional rather than exact.3119function bucketEntryCount(n: number): number {3120  if (n <= 0) return 0;3121  if (n <= 10) return 10;3122  if (n <= 25) return 25;3123  if (n <= 50) return 50;3124  if (n <= 100) return 100;3125  if (n <= 250) return 250;3126  if (n <= 500) return 500;3127  return Math.ceil(n / 500) * 500;3128}31293130/**3131 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE3132 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost3133 * state. Identical-to-most-recent blobs are skipped to avoid filling history3134 * with no-op writes.3135 */3136export async function takeVaultSnapshot(3137  reason: VaultSnapshot["reason"],3138): Promise<void> {3139  try {3140    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3141    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3142    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];31433144    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,3145    // pre-createVault). createVault skips snapshots intentionally — there is3146    // no prior state to preserve.3147    if (!slotA) return;31483149    const vault_a = JSON.stringify(slotA);3150    const vault_b = JSON.stringify(slotB ?? slotA);31513152    // Best-effort entry count: only meaningful when the in-memory state is3153    // unlocked AND in real mode. Decoy mode would leak the decoy entry3154    // count; locked mode has no entries to count. Both fall through to null.3155    // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage3156    // .local sits next to the AES-GCM blobs in plaintext, so an exact count would3157    // leak vault size and per-write deltas (the metadata padding hides this).3158    let entryCount: number | null = null;3159    if (derivedKey && vaultMode === "real") {3160      try {3161        entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3162      } catch {3163        entryCount = null;3164      }3165    }31663167    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3168    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3169      ? existingRaw3170      : { snapshots: [] };31713172    // Deduplication: if the most recent snapshot has byte-identical blobs,3173    // skip — saves the same vault N times in a row would otherwise burn the3174    // entire history window with no recovery value.3175    const latest = history.snapshots[0];3176    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3177      return;3178    }31793180    const snapshot: VaultSnapshot = {3181      id: crypto.randomUUID(),3182      savedAt: Date.now(),3183      reason,3184      vault_a,3185      vault_b,3186      entryCount,3187    };31883189    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3190    await chrome.storage.local.set({ [HISTORY_KEY]: history });3191  } catch (err) {3192    // Non-fatal — history must never block a save. A failed snapshot just3193    // means the user loses one rung of the safety ladder for this write.3194    // M4/QUA-006: log so the failure is visible; if it looks like a quota3195    // problem, notify the popup so the user can prune history themselves.3196    console.warn('[Peach] takeVaultSnapshot failed:', err);3197    if (String(err).includes('QUOTA') || String(err).includes('quota')) {3198      try {3199        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3200      } catch {3201        // popup may be closed; surfaced via console.warn above3202      }3203    }3204  }3205}32063207/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */3208export async function clearVaultHistory(): Promise<void> {3209  try {3210    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3211  } catch {3212    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().3213  }3214}32153216/** Read all snapshots, newest first, for the Settings vault history view. */3217export async function getVaultHistory(): Promise<VaultSnapshot[]> {3218  const raw = await chrome.storage.local.get(HISTORY_KEY);3219  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3220  if (!history || !Array.isArray(history.snapshots)) return [];3221  return history.snapshots;3222}32233224/**3225 * Replace the on-disk vault with the chosen snapshot's blobs and lock the3226 * vault. The current state is itself snapshotted with reason 'restore' first3227 * so the restore is reversible. After this returns, the user must unlock3228 * with the password that matches the restored blob.3229 */3230export async function restoreVaultSnapshot(3231  snapshotId: string,3232): Promise<{ ok: true } | { ok: false; error: string }> {3233  const history = await getVaultHistory();3234  const snapshot = history.find((s) => s.id === snapshotId);3235  if (!snapshot) return { ok: false, error: "snapshot_not_found" };32363237  // Snapshot the about-to-be-replaced current state so the restore is3238  // reversible. takeVaultSnapshot is no-op when the most recent entry is3239  // byte-identical, so a restore-of-a-restore stays clean.3240  await takeVaultSnapshot("restore");32413242  let restoredA: StoredVault;3243  let restoredB: StoredVault;3244  try {3245    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3246    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3247  } catch {3248    return { ok: false, error: "snapshot_corrupt" };3249  }32503251  await writeSlot(SLOT_A, restoredA);3252  await writeSlot(SLOT_B, restoredB);3253  await stampLocalVaultMutation();32543255  // Lock so the user must prove they can decrypt the restored blob. If they3256  // restored a pre-password-change snapshot they discover it immediately at3257  // the lock screen rather than silently holding an inaccessible vault.3258  await lock();32593260  return { ok: true };3261}32623263/**3264 * Rotate the vault's sync identity by generating a FRESH ROOT KEY and3265 * re-wrapping everything under it (MV-8). Because every sync credential —3266 * vault_id, the Noise static keypair, the Ed25519 manifest signing key,3267 * and the per-entry envelope keys — derives from the root key, rotation3268 * makes a lost/stolen device's retained key material useless. All paired3269 * devices must re-pair afterwards.3270 *3271 * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from3272 * the root key, so rotation INVALIDATES the old recovery phrase. The flow3273 * sets peach_recovery_pending so the popup re-presents the new words for3274 * confirmation, same as the initial recovery-word confirmation path.3275 */3276export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3277  if (!derivedKey || vaultMode !== "real") {3278    return { ok: false, error: "vault_locked" };3279  }3280  const slotA = await loadSlot(SLOT_A);3281  if (!slotA) return { ok: false, error: "missing_slot_a" };32823283  // 1. Fresh root key + recovery escrow (new words).3284  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3285    await generateAndWrapRootKey();32863287  // 2. Re-wrap the new root key under the current password-derived key.3288  const newRootKey = unhex(rawRootKey);3289  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);32903291  // 3. Snapshot, then single atomic slot write — both escrow wrappings of3292  //    the new root key land together (same transactional rule as3293  //    changePassword). Entries are untouched: they are sealed under the3294  //    password-derived key, not the root key.3295  await takeVaultSnapshot("local_save");3296  const nextSlotA: StoredVault = {3297    ...slotA,3298    recovery_salt: recoverySalt,3299    recovery_wrapped_root_key: wrappedRootKey,3300    recovery_nonce: wrappedNonce,3301    recovery_tag: wrappedTag,3302    ...pwRoot,3303  };3304  await writeSlot(SLOT_A, nextSlotA);33053306  // 4. Swap in memory only after disk committed.3307  if (rootKey) zeroKey(rootKey);3308  rootKey = newRootKey;33093310  // 5. Old peers' identities derive from the dead root key — drop them all,3311  // with their rendezvous secrets (PB-26-213: rotation clears the map, so3312  // there is nothing to rewrap under the new key).3313  await clearPairedDevices();3314  await clearPairSecrets();33153316  // 6. Force re-presentation of the NEW recovery words on next popup view.3317  await setRecoveryConfirmPending(); // same flag the create-vault flow uses33183319  await stampLocalVaultMutation();3320  return { ok: true };3321}33223323/**3324 * Change the master password. Re-encrypts slot A with a fresh salt + new3325 * derived key. Slot B (duress or noise) is left untouched when duress is3326 * configured — it uses an independent key derived from the duress password —3327 * and regenerated as noise when duress is not configured.3328 */3329export async function changePassword(3330  currentPassword: string,3331  newPassword: string3332): Promise<boolean> {3333  const epoch = vaultEpoch;3334  const stale = () => epoch !== vaultEpoch;3335  if (!derivedKey || vaultMode !== "real") return false;33363337  // Verify current password before allowing any change3338  const valid = await verifyMainPassword(currentPassword);3339  if (!valid) return false;33403341  const slotA = await loadSlot(SLOT_A);3342  if (!slotA) return false;3343  if (!rootKey) return false;33443345  // Derive fresh key from new password + new random salt3346  const newSalt = generateSalt();3347  const newKey = await deriveKey(newPassword, newSalt);3348  if (stale()) {3349    zeroKey(newKey);3350    return false;3351  }33523353  // Re-encrypt the in-memory entries with the new key3354  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3355  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");33563357  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey3358  // under recovery words and remain untouched — recovery phrase keeps working3359  // across password changes (H4/COR-002).3360  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);33613362  const finalSlotA: StoredVault = {3363    ...slotA,3364    salt: hex(newSalt),3365    nonce,3366    ciphertext,3367    tag,3368    ...pwRoot,3369  };33703371  // Snapshot the OLD-key blob before overwriting it. If the user later3372  // restores from this snapshot they will need the OLD password — the3373  // post-restore lock surfaces that immediately.3374  await takeVaultSnapshot("local_save");33753376  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in3377  // one storage transaction. An SW kill before this point leaves the old3378  // slot intact; after this point everything tied to the new password is3379  // already on disk together (M6/COR-004).3380  await writeSlot(SLOT_A, finalSlotA);3381  if (stale()) {3382    zeroKey(newKey);3383    return false;3384  }33853386  // Swap the in-memory key synchronously before any further await. If we3387  // threw between the Slot A write and this swap, the next persistEntries()3388  // would overwrite Slot A with old-key ciphertext, locking the user out.3389  zeroKey(derivedKey);3390  derivedKey = newKey;33913392  // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,3393  // the next service-worker restore decrypts the new-key Slot A with the stale3394  // key, fails tryDecryptSlotResult, and silently drops the user to a lock3395  // screen. Re-escrow the new key so restore survives a password change.3396  if (stale()) {3397    await lock();3398    return false;3399  }3400  await saveSessionKey(newKey, "real", epoch);3401  if (stale()) return false;34023403  // Regenerate noise for slot B when duress is not in use. Runs after the3404  // key swap so a failure here is a disposable noise regeneration, not a3405  // lockout.3406  if (!duressConfigured) {3407    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3408  }3409  await clearPinUnlockStorage();3410  await stampLocalVaultMutation();34113412  return true;3413}