vault.ts

133.3 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";16// Static, not `await import()`: runtime import() throws in an MV3 service17// worker. Neither module imports vault.ts back, so there is no cycle to break.18import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21  rootKeyToWords,22  wordsToRootKeyMaterial,23  deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26  AccentColorName,27  Canary,28  ManualSummonRecord,29  PasskeyData,30  SavedIdentity,31  StoredVault,32  VaultEntry,33  VaultHistory,34  VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import {39  clearRecoveryConfirmPending,40  setRecoveryConfirmPending,41} from "./recovery-pending";42import {43  clearPairSecrets,44  dropSessionCache,45  reconcilePairSecrets,46  setSessionAllowed,47} from "./pair-secrets";4849type EntryDraft = Omit<50  VaultEntry,51  "id" | "createdAt" | "modifiedAt" | "deletedAt"52>;5354// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B55// either holds the decoy vault (when duress is configured) or random noise56// that looks identical at rest. No external indicator is stored — duress57// configuration is recorded only inside the encrypted payload of slot A.58const SLOT_A = "peach_vault_a";59const SLOT_B = "peach_vault_b";6061// Plaintext is padded to the next multiple of this size before encryption.62// This coarsens ciphertext-length leakage into 1 KiB buckets.63const PAD_BLOCK = 1024;6465// How many past passwords to retain per entry. Generous enough to be useful as66// a recovery aid while bounding the secret material we hold (and the ciphertext67// growth) for an entry whose password is rotated frequently.68const PASSWORD_HISTORY_MAX = 50;6970// When duress is configured, BOTH slots are padded to this fixed ceiling so71// their ciphertext lengths are identical and cannot reveal that a hidden decoy72// vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably73// holds a very large vault (thousands of entries incl. passkeys). The decoy74// slot can't be re-encrypted on every real-vault save (the real session has no75// decoy key), so a fixed ceiling — rather than size-matching on each write — is76// what keeps the two slots equal. A real vault whose plaintext exceeds this77// would grow past the ceiling and re-expose the bit; that is an extreme,78// documented edge well beyond normal use.79const DURESS_SLOT_SIZE = 262_144;8081let derivedKey: Uint8Array | null = null;82// 32-byte random root key, separate from the derivedKey used for vault encryption.83// The root key is wrapped (encrypted) under the recovery key derived from the84// 12-word phrase. When the user changes their password, only the wrapping changes;85// the root key and recovery words remain stable.86let rootKey: Uint8Array | null = null;87let entries: VaultEntry[] = [];88// Tombstones outlive their full entry records so a peer that was offline89// during a purge cannot reintroduce an old ciphertext envelope. This map is90// serialized only inside the encrypted vault payload.91let tombstones = new Map<string, number>();92let domainIndex: Map<string, VaultEntry[]> = new Map();93let vaultMode: "real" | "decoy" = "real";94// Cached post-unlock view of whether the real vault records that duress is95// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock96// this stays false because we never see slot A's plaintext.97let duressConfigured = false;9899// Set to a corruption reason during the last unlock() attempt when both slots100// decrypted but failed schema validation. Consumed and cleared by101// consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted102// distinctly from wrong-password (M7/COR-007).103//104// NOTE: module-level state — if vault.ts is ever moved to a worker context,105// this must be replaced with a structured return value from unlock() or a106// postMessage channel. The signal is invisible across module-instance107// boundaries.108let lastUnlockFailureReason: string | null = null;109110export function consumeLastUnlockFailureReason(): string | null {111  const r = lastUnlockFailureReason;112  lastUnlockFailureReason = null;113  return r;114}115116// ---------------------------------------------------------------------------117// Settings (persisted inside the encrypted vault store as a sidecar key)118// ---------------------------------------------------------------------------119120export type TextSize = "small" | "medium" | "large";121122export interface VaultSettings {123  accentColor?: AccentColorName;124  theme?: "dark" | "light";125  autoLockMinutes?: number;126  clipboardClearSeconds?: number;127  lockOnClose?: boolean;128  breachMonitoring?: boolean;129  passkeyProviderEnabled?: boolean;130  trashRetentionDays?: number;131  manualSummonDomains?: Record<string, ManualSummonRecord>;132  manualSummonSalt?: string;133  declinedCardFingerprints?: string[];134135  /** Metadata is bounded because it remains outside the encrypted vault blob. */136  metadataVersion?: 1;137  /**138   * Text size scale for body copy and entry titles. Stops at "large" by139   * design — XL clips dense screens (Vault, Health) inside the fixed140   * 330×480 popup surface.141   */142  textSize?: TextSize;143  /**144   * When true, all entrance/exit transitions and ambient animations are145   * collapsed to instant. Honoured alongside the user's OS-level146   * `prefers-reduced-motion`.147   */148  reduceMotion?: boolean;149  /** Require an unlock after a worker restart before locked Sync resumes. */150  lockedSyncRequiresUnlock?: boolean;151  /** Whether this vault has a local PIN unlock wrapper configured. */152  pinEnabled?: boolean;153  /** Whether the lock screen should open on PIN instead of the password. */154  pinPreferred?: boolean;155  /**156   * User overrides for keyboard shortcuts. Keys are ShortcutAction names157   * (see src/shared/shortcuts.ts). Missing actions fall through to158   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the159   * canonical lookup. Storing only the deltas keeps the vault payload160   * small and lets default updates land for users who never rebind.161   */162  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;163}164165interface StoredPinWrap {166  version: 1;167  salt: string;168  nonce: string;169  ciphertext: string;170  tag: string;171}172173const PIN_WRAP_STORAGE = "peach_pin_wrap";174const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";175const PIN_MIN_LENGTH = 4;176const PIN_MAX_LENGTH = 12;177const PIN_MAX_ATTEMPTS = 3;178179let settings: VaultSettings = {};180const CARD_DECLINE_MAX = 256;181/** Hex length of a keyed card-decline digest. Legacy records are 16 hex. */182const CARD_DECLINE_DIGEST_HEX = 32;183184async function metadataDigest(value: string, key: string): Promise<string> {185  const bytes = new TextEncoder().encode(`${key}:${value}`);186  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));187  return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");188}189190let inMemoryPinWrap: StoredPinWrap | null = null;191let inMemoryPinFailedAttempts = 0;192let sessionPinStorageFailed = false;193194export function getSettings(): VaultSettings {195  return settings;196}197198export async function ensureSettingsLoaded(): Promise<VaultSettings> {199  await loadSettings();200  return settings;201}202203export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {204  await loadSettings();205  // LV-7: cheap insurance against prototype pollution if a future caller ever206  // feeds parsed external JSON here (native spread keeps __proto__ an own-207  // property today, but Object.assign-style consumers and JSON revivers have208  // been burned before).209  const safe = Object.fromEntries(210    Object.entries(patch).filter(211      ([k]) => !["__proto__", "constructor", "prototype"].includes(k),212    ),213  ) as Partial<VaultSettings>;214  settings = { ...settings, ...safe };215  await persistSettings();216}217218function isValidPin(pin: string): boolean {219  return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);220}221222/** Set up a local PIN wrapper after proving the current master password. */223export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {224  if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;225  if (!(await verifyMainPassword(currentPassword))) return false;226227  const salt = generateSalt();228  const pinKey = await deriveKey(pin, salt);229  try {230    const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");231    await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });232    await updateSettings({ pinEnabled: true });233    return true;234  } finally {235    zeroKey(pinKey);236  }237}238239async function clearPinUnlockStorage(): Promise<void> {240  inMemoryPinWrap = null;241  inMemoryPinFailedAttempts = 0;242  sessionPinStorageFailed = false;243  if (chrome.storage.session) {244    await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);245  }246  await updateSettings({ pinEnabled: false });247}248249/** Remove the PIN wrapper after proving the current master password. */250export async function disablePinUnlock(currentPassword: string): Promise<boolean> {251  if (!derivedKey || vaultMode !== "real") return false;252  if (!(await verifyMainPassword(currentPassword))) return false;253  await clearPinUnlockStorage();254  return true;255}256257/** Unlock the real vault from its locally stored PIN-wrapped derived key. */258export async function unlockWithPin(pin: string): Promise<boolean> {259  if (!isValidPin(pin)) return false;260  await loadSettings();261  if (!settings.pinEnabled) return false;262263  const { stored, failedAttempts } = await loadPinState();264  if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;265  if (266    !stored ||267    stored.version !== 1 ||268    !stored.salt ||269    !stored.nonce ||270    !stored.ciphertext ||271    !stored.tag272  ) return false;273274  const pinKey = await deriveKey(pin, unhex(stored.salt));275  let unwrappedKey: Uint8Array | null = null;276  try {277    const masterKeyHex = await decrypt(278      pinKey,279      stored.nonce,280      stored.ciphertext,281      stored.tag,282      "pin_wrap_v1",283    );284    if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;285    unwrappedKey = unhex(masterKeyHex);286    const result = await unlockWithKey(masterKeyHex);287    if (!result.ok) return false;288    await resetPinFailedAttempts();289    return true;290  } catch {291    await recordPinFailure(failedAttempts + 1);292    return false;293  } finally {294    zeroKey(pinKey);295    if (unwrappedKey) zeroKey(unwrappedKey);296  }297}298299async function savePinWrap(stored: StoredPinWrap): Promise<void> {300  inMemoryPinWrap = stored;301  inMemoryPinFailedAttempts = 0;302  sessionPinStorageFailed = !chrome.storage.session;303  if (!chrome.storage.session) return;304  try {305    await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });306    sessionPinStorageFailed = false;307  } catch {308    // Firefox fallback: keep the wrapper only in this background context.309    sessionPinStorageFailed = true;310  }311}312313async function loadPinState(): Promise<{314  stored: StoredPinWrap | undefined;315  failedAttempts: number;316}> {317  if (!chrome.storage.session) {318    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };319  }320  try {321    const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);322    return {323      stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??324        (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),325      failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"326        ? result[PIN_ATTEMPTS_STORAGE]327        : 0,328    };329  } catch {330    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };331  }332}333334async function recordPinFailure(failedAttempts: number): Promise<void> {335  inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);336  if (!chrome.storage.session) return;337  try {338    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });339  } catch { /* in-memory counter remains the fallback */ }340}341342async function resetPinFailedAttempts(): Promise<void> {343  inMemoryPinFailedAttempts = 0;344  if (!chrome.storage.session) return;345  try {346    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });347  } catch { /* in-memory counter remains the fallback */ }348}349350export async function getPinUnlockStatus(): Promise<{351  configured: boolean;352  available: boolean;353  attemptsRemaining: number;354}> {355  await loadSettings();356  if (!settings.pinEnabled) {357    return { configured: false, available: false, attemptsRemaining: 0 };358  }359  const { stored, failedAttempts } = await loadPinState();360  const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;361  return {362    configured: true,363    available,364    attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,365  };366}367368async function persistSettings(): Promise<void> {369  try {370    await chrome.storage.local.set({371      peach_settings: {372        ...settings,373        manualSummonDomains: undefined,374        manualSummonSalt: undefined,375        cardFingerprintSalt: undefined,376        declinedCardFingerprints: settings.declinedCardFingerprints ?? [],377        metadataVersion: 1,378      },379    });380  } catch (err) {381    throw mapStorageError(err);382  }383}384385function mapStorageError(err: unknown): Error {386  const message = err instanceof Error ? err.message : String(err);387  if (/QUOTA_BYTES|quota/i.test(message)) {388    return new Error("STORAGE_QUOTA_EXCEEDED");389  }390  return err instanceof Error ? err : new Error(message);391}392393async function loadSettings(): Promise<void> {394  const result = await chrome.storage.local.get("peach_settings");395  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};396}397398// Fire-and-forget sync trigger after a committed vault write. A failure here399// must never fail the write that caused it.400function fireSyncTrigger(reason: "vault_change"): void {401  void Promise.resolve()402    .then(() => triggerSyncIfReachable({ reason }))403    .catch(() => {404      /* no sync runner registered, or nothing reachable — non-fatal */405    });406}407export function lockedSyncRequiresUnlock(): boolean {408  return settings.lockedSyncRequiresUnlock === true;409}410411let lockedSyncRefreshHook: (() => Promise<void>) | null = null;412let lockedSyncUnlockHook: (() => Promise<void>) | null = null;413let decoySyncTeardownHook: (() => void) | null = null;414export type IdentityHooks = {415  /** Drop the cached identity: the session is no longer a real unlocked one. */416  onSessionEnded: () => Promise<void>;417  /** Re-wrap the identity under the keys now in force, after a vault key swap. */418  onKeysRotated: () => Promise<void>;419  /** Promote a pre-vault identity once a vault exists, given the password that created it. */420  onVaultCreated: (password: string) => Promise<void>;421};422423let identityHooks: IdentityHooks | null = null;424425export function setIdentityHooks(hooks: IdentityHooks): void {426  identityHooks = hooks;427}428429async function notifyIdentitySessionEnded(): Promise<void> {430  await identityHooks?.onSessionEnded().catch((error) => {431    console.warn("[Peach] local identity cache clear failed:", error);432  });433}434435async function notifyIdentityKeysRotated(): Promise<void> {436  await identityHooks?.onKeysRotated().catch((error) => {437    console.warn("[Peach] local identity re-wrap failed:", error);438  });439}440441export type SealedBox = { nonce: string; ciphertext: string; tag: string };442export type IdentityKeyEnvelope = {443  root?: SealedBox;444  local?: SealedBox;445  pending?: SealedBox;446  pendingSalt?: string;447};448449export function setLockedSyncHooks(hooks: {450  refresh: () => Promise<void>;451  afterUnlock: () => Promise<void>;452  onDecoy?: () => void;453}): void {454  lockedSyncRefreshHook = hooks.refresh;455  lockedSyncUnlockHook = hooks.afterUnlock;456  decoySyncTeardownHook = hooks.onDecoy ?? null;457}458459async function refreshLockedSyncState(): Promise<void> {460  if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;461  await lockedSyncRefreshHook().catch((error) => {462    console.warn("[Peach sync] locked spool refresh failed:", error);463  });464}465466// ---------------------------------------------------------------------------467// HIBP breach check (k-anonymity model)468// ---------------------------------------------------------------------------469470export async function checkBreach(password: string): Promise<number> {471  // HIBP k-anonymity API is keyed on SHA-1 prefixes — this is dictated by472  // the public API, not a security choice. Do not migrate to SHA-256 here.473  const encoder = new TextEncoder();474  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));475  const hashHex = Array.from(new Uint8Array(hashBuffer))476    .map((b) => b.toString(16).padStart(2, "0"))477    .join("")478    .toUpperCase();479480  const prefix = hashHex.slice(0, 5);481  const suffix = hashHex.slice(5);482483  try {484    // PCH-02-007: request HIBP response padding so response length does not485    // reveal how many suffixes a queried prefix returned.486    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {487      headers: { "Add-Padding": "true" },488    });489    if (!res.ok) return 0;490    const text = await res.text();491    const match = text.split("\r\n").find((l) => l.startsWith(suffix));492    return match ? parseInt(match.split(":")[1], 10) : 0;493  } catch {494    return 0; // Network unavailable — don't block495  }496}497498export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {499  // Unreachable from the message layer, which gates on isUnlocked first.500  const key = await cardDeclineKey();501  if (!key) return;502  const fingerprint = await cardFingerprint(last4, expMonth, expYear);503  await loadSettings();504  settings = {505    ...settings,506    declinedCardFingerprints: [507      // Legacy records predate the keyed digest and are silently dropped here:508      // they are 16 hex characters rather than 32, and are no longer accepted.509      ...(settings.declinedCardFingerprints ?? []).filter(510        (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,511      ),512      await metadataDigest(fingerprint, key),513    ].slice(-CARD_DECLINE_MAX),514  };515  await persistSettings();516}517518export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {519  await loadSettings();520  const key = await cardDeclineKey();521  if (!key) return false;522  const fingerprint = await cardFingerprint(last4, expMonth, expYear);523  const candidate = await metadataDigest(fingerprint, key);524  return (settings.declinedCardFingerprints ?? []).some(525    (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,526  );527}528529/**530 * Keying material for the declined-card digest, derived from the vault root531 * key so it is available only while unlocked and never written to storage.532 *533 * PB-26-108: the previous scheme stored a random salt beside the records. A534 * salt defeats precomputed tables, not an attacker holding the file — the535 * candidate space here is ~10^4 last-four values x 12 months x a few years, so536 * a stored salt left the card's last four and expiry recoverable offline in537 * seconds. `peach_settings` is unencrypted and directly readable by a content538 * script on Firefox, so the key has to live outside storage entirely.539 */540async function cardDeclineKey(): Promise<string | null> {541  const root = getRootKeyHex();542  if (!root) return null;543  const digest = new Uint8Array(544    await crypto.subtle.digest(545      "SHA-256",546      new TextEncoder().encode(`peach-card-decline-v1:${root}`),547    ),548  );549  return hex(digest);550}551552async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {553  const encoder = new TextEncoder();554  const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));555  return Array.from(new Uint8Array(buf).slice(0, 8))556    .map((b) => b.toString(16).padStart(2, "0"))557    .join("");558}559560function hex(buf: Uint8Array): string {561  return Array.from(buf)562    .map((b) => b.toString(16).padStart(2, "0"))563    .join("");564}565566function unhex(s: string): Uint8Array {567  const out = new Uint8Array(s.length / 2);568  for (let i = 0; i < s.length; i += 2) {569    out[i / 2] = parseInt(s.slice(i, i + 2), 16);570  }571  return out;572}573574// ---------------------------------------------------------------------------575// Root key generation and recovery word wrapping576// ---------------------------------------------------------------------------577578/**579 * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key580 * derived from the recovery word material via HKDF-SHA256. The wrapped blob581 * fields are stored in slot A alongside the password-wrapped ciphertext. The582 * root key itself is kept in memory for the session so getRecoveryWords() can583 * encode it without re-deriving.584 *585 * SECURITY MODEL (PCH-01-005): the recovery KEK is derived from the 12-word586 * material, which encodes only rootKey[0..16] (recovery-words.ts). The KEK is thus587 * derived from a PREFIX of the very plaintext it wraps, and effective recovery588 * security is 128-bit (standard for a 12-word phrase), NOT 256-bit. This is safe —589 * unwrapping requires the words, and the GCM ciphertext leaks nothing without them —590 * but it is intentional, not an oversight. A future escrow version wanting 256-bit591 * recovery must derive the words from INDEPENDENT entropy, not from the root key.592 */593async function generateAndWrapRootKey(): Promise<{594  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey595  recoverySalt: string;596  wrappedRootKey: string;597  wrappedNonce: string;598  wrappedTag: string;599}> {600  const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));601602  // Derive the recovery key from the BIP39 words encoding of the root key.603  const words = await rootKeyToWords(rootKeyBytes);604  const wordMaterial = await wordsToRootKeyMaterial(words);605  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));606  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);607608  // Wrap the root key with the recovery key using AES-256-GCM directly.609  // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.610  const nonce = crypto.getRandomValues(new Uint8Array(12));611  const encoded = new TextEncoder().encode(hex(rootKeyBytes));612  const raw = await crypto.subtle.encrypt(613    {614      name: "AES-GCM",615      iv: nonce,616      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,617      tagLength: 128,618    },619    recoveryKey,620    encoded621  );622  // WebCrypto returns ciphertext || auth tag concatenated; split them.623  const rawU8 = new Uint8Array(raw);624  const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));625  const tag = hex(rawU8.slice(rawU8.length - 16));626627  return {628    rawRootKey: hex(rootKeyBytes),629    recoverySalt: hex(recoverySalt),630    wrappedRootKey: ciphertext,631    wrappedNonce: hex(nonce),632    wrappedTag: tag,633  };634}635636async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{637  recoverySalt: string;638  wrappedRootKey: string;639  wrappedNonce: string;640  wrappedTag: string;641}> {642  if (rootKeyBytes.length !== 32) {643    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");644  }645646  const words = await rootKeyToWords(rootKeyBytes);647  const wordMaterial = await wordsToRootKeyMaterial(words);648  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));649  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);650  const nonce = crypto.getRandomValues(new Uint8Array(12));651  const encoded = new TextEncoder().encode(hex(rootKeyBytes));652  const raw = await crypto.subtle.encrypt(653    {654      name: "AES-GCM",655      iv: nonce,656      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,657      tagLength: 128,658    },659    recoveryKey,660    encoded,661  );662  const rawU8 = new Uint8Array(raw);663  return {664    recoverySalt: hex(recoverySalt),665    wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),666    wrappedNonce: hex(nonce),667    wrappedTag: hex(rawU8.slice(rawU8.length - 16)),668  };669}670671/**672 * Unwrap the root key using recovery words. Called during recovery flow when673 * the user provides their 12-word phrase and a new password.674 * Exported for use by the RECOVER_WITH_WORDS message handler.675 */676export async function unwrapRootKeyForRecovery(677  wrappedRootKey: string,678  wrappedNonce: string,679  wrappedTag: string,680  recoverySaltHex: string,681  words: string[]682): Promise<void> {683  const wordMaterial = await wordsToRootKeyMaterial(words);684  const recoverySalt = unhex(recoverySaltHex);685  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);686687  // Decrypt and cache the root key. The root key bytes were hex-encoded before688  // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt689  // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.690  // Each component is stored separately in the vault slot; decode them individually691  // so the nonce is always exactly the 12 bytes from wrappedNonce.692  const nonceRaw = unhex(wrappedNonce);693  const ciphertextRaw = unhex(wrappedRootKey);694  const tagRaw = unhex(wrappedTag);695  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).696  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.697  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;698  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);699  combined.set(ciphertextRaw, 0);700  combined.set(tagRaw, ciphertextRaw.length);701  const raw = await crypto.subtle.decrypt(702    { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },703    recoveryKey,704    combined705  );706  const decryptedHex = new TextDecoder().decode(raw);707  rootKey = unhex(decryptedHex);708}709710/**711 * Re-wrap the cached root key under a new derived key (from the user's new712 * password). Called after password change and after recovery re-key so the713 * same recovery words continue to work with the updated password — and so714 * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.715 *716 * recovery_* fields are intentionally not touched: those wrap rootKey under717 * the recovery words, which do not change when the password changes.718 */719export async function rewrapRootKeyForPassword(720  newDerivedKey: Uint8Array,721): Promise<void> {722  if (!rootKey) throw new Error("root key not set");723  const slotA = await loadSlot(SLOT_A);724  if (!slotA) throw new Error("missing slot A");725  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);726  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });727}728729/**730 * Change the vault encryption to a new derived key (already derived from731 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.732 * Caller is responsible for key swap and snapshot.733 */734async function reencryptVaultForNewKey(735  newKey: Uint8Array,736  slotA: StoredVault737): Promise<void> {738  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);739  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");740  await writeSlot(SLOT_A, {741    ...slotA,742    nonce,743    ciphertext,744    tag,745  });746}747748/**749 * Full password change with an already-derived key (used after recovery).750 * Unlike changePassword(), this skips re-verification of current password751 * since recovery flow has already validated the user's identity via words.752 */753export async function changePasswordWithNewKey(754  newSalt: Uint8Array,755  newKey: Uint8Array756): Promise<void> {757  const epoch = vaultEpoch;758  const throwIfStale = () => {759    if (epoch === vaultEpoch) return;760    zeroKey(newKey);761    throw new Error("vault_locked");762  };763  const slotA = await loadSlot(SLOT_A);764  throwIfStale();765  if (!slotA) throw new Error("missing slot A");766  // reencryptVaultForNewKey must receive the updated salt so that subsequent767  // UNLOCK calls with the new password can re-derive the correct key.768  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });769  throwIfStale();770  if (derivedKey) zeroKey(derivedKey);771  derivedKey = newKey;772  // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow773  // reaches this path, so a post-recovery SW restart would otherwise lock out774  // a user who just reset their password.775  await saveSessionKey(newKey, "real", epoch);776  throwIfStale();777  // Same reason as changePassword: the unlock key just changed, so the778  // identity's local wrap has to be re-encrypted under it.779  await notifyIdentityKeysRotated();780  throwIfStale();781  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works782  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow783  // should have populated it — fall back silently rather than blowing up the784  // recovery path; the escrow is already present on every supported vault.785  if (rootKey) {786    const slotAfter = await loadSlot(SLOT_A);787    throwIfStale();788    if (slotAfter) {789      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);790      throwIfStale();791      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });792      throwIfStale();793    }794  }795  if (!duressConfigured) {796    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));797    throwIfStale();798  }799  await clearPinUnlockStorage();800  throwIfStale();801  await stampLocalVaultMutation();802  throwIfStale();803}804805// ---------------------------------------------------------------------------806// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive807// a new key after password reset without re-importing the crypto module.808// ---------------------------------------------------------------------------809810export { deriveKey, generateSalt };811812export async function loadSlotA(): Promise<StoredVault | null> {813  return loadSlot(SLOT_A);814}815816/**817 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields818 * (a second wrapping of the root key under the password-derived key). This819 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without820 * the user re-entering their recovery phrase: the same root key bytes —821 * and therefore the same 12 words — survive every unlock.822 *823 * Returns null when the escrow is absent or decryption fails.824 */825async function deriveRootKeyFromDerivedKey(826  derivedKeyBytes: Uint8Array,827  slotA: StoredVault,828): Promise<Uint8Array | null> {829  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {830    return null;831  }832  try {833    const rootHex = await decrypt(834      derivedKeyBytes,835      slotA.pw_root_nonce,836      slotA.pw_root_ciphertext,837      slotA.pw_root_tag,838      "pw_root_v1",839    );840    return unhex(rootHex);841  } catch {842    return null;843  }844}845846export async function sealIdentityKeyUnderVault(ik: Uint8Array): Promise<IdentityKeyEnvelope> {847  if (vaultMode !== "real" || !rootKey || !derivedKey) throw new Error("vault_locked");848  // The root wrap survives password change and recovery-words re-key; the849  // local wrap is available during normal unlock. Pending is only pre-vault.850  const [root, local] = await Promise.all([851    encrypt(rootKey, hex(ik), "peach-local-identity-ik-root-v1"),852    encrypt(derivedKey, hex(ik), "peach-local-identity-ik-local-v1"),853  ]);854  return { root, local };855}856857export async function sealIdentityKeyPreVault(858  password: string,859  ik: Uint8Array,860): Promise<IdentityKeyEnvelope> {861  const salt = generateSalt();862  const key = await deriveKey(password, salt);863  try {864    const pending = await encrypt(key, hex(ik), "peach-local-identity-ik-pending-v1");865    return { pending, pendingSalt: hex(salt) };866  } finally {867    zeroKey(key);868  }869}870871/**872 * Open the identity key with any wrapping key that is currently available:873 * the live vault-unlock key, then the vault root key, then the pre-vault874 * password. Each attempt is awaited only after the previous one fails, so a875 * successful early unwrap never leaves a later decrypt rejection unobserved,876 * and the pre-vault Argon2id is not paid when a vault key already opens the877 * envelope.878 */879export async function openIdentityKey(880  env: IdentityKeyEnvelope,881  password?: string,882): Promise<Uint8Array | null> {883  if (env.local && derivedKey) {884    const ik = await openSealedKey(derivedKey, env.local, "peach-local-identity-ik-local-v1");885    if (ik) return ik;886  }887  if (env.root && rootKey) {888    const ik = await openSealedKey(rootKey, env.root, "peach-local-identity-ik-root-v1");889    if (ik) return ik;890  }891  if (!env.pending || !env.pendingSalt || password === undefined) return null;892  let key: Uint8Array;893  try {894    const salt = unhex(env.pendingSalt);895    key = await deriveKey(password, salt);896    salt.fill(0);897  } catch {898    return null;899  }900  try {901    return await openSealedKey(key, env.pending, "peach-local-identity-ik-pending-v1");902  } finally {903    zeroKey(key);904  }905}906907/** Decrypt a sealed 32-byte key; null for a wrong key, a bad shape, or corrupt bytes. */908async function openSealedKey(909  key: Uint8Array,910  box: SealedBox,911  info: string,912): Promise<Uint8Array | null> {913  try {914    const value = unhex(await decrypt(key, box.nonce, box.ciphertext, box.tag, info));915    if (value.length === 32) return value;916    value.fill(0);917    return null;918  } catch {919    return null;920  }921}922923// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the924// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.925async function wrapRootKeyForDerivedKey(926  derivedKeyBytes: Uint8Array,927  rootKeyBytes: Uint8Array,928): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {929  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");930  return {931    pw_root_ciphertext: ciphertext,932    pw_root_nonce: nonce,933    pw_root_tag: tag,934  };935}936937/** Returns the 12 recovery words for the current session's root key. */938export async function getRecoveryWords(): Promise<string[]> {939  if (!rootKey) {940    throw new Error("recovery not set up");941  }942  return rootKeyToWords(rootKey);943}944945async function loadSlot(key: string): Promise<StoredVault | null> {946  const result = await chrome.storage.local.get(key);947  return (result as Record<string, StoredVault | undefined>)[key] ?? null;948}949950async function writeSlot(key: string, vault: StoredVault): Promise<void> {951  try {952    await chrome.storage.local.set({ [key]: vault });953  } catch (err) {954    throw mapStorageError(err);955  }956}957958/**959 * Roll back a vault that was created by [createVaultFromRootKey] but960 * couldn't be paired (e.g. the subsequent license activation, identity961 * derivation, or paired-device upsert failed). Without this, a failed962 * receiver session leaves behind an empty vault — the user has to963 * re-pair to get a working setup, but the empty vault sits in the way.964 *965 * The call is idempotent: removing keys that aren't there is a no-op.966 */967export async function rollbackAdoptedVault(): Promise<void> {968  await lock();969  try {970    await chrome.storage.local.remove([971      SLOT_A,972      SLOT_B,973      "peach_last_modified_at",974    ]);975  } catch (err) {976    throw mapStorageError(err);977  }978}979980async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {981  const raw = await chrome.storage.local.get("peach_last_modified_at");982  const previous = Number(983    (raw as Record<string, unknown>).peach_last_modified_at,984  );985  const next = Math.max(986    Date.now(),987    Number.isFinite(previous) ? previous + 1 : 0,988  );989  try {990    await chrome.storage.local.set({991      [SLOT_A]: slotA,992      [SLOT_B]: slotB,993      peach_last_modified_at: String(next),994    });995  } catch (err) {996    throw mapStorageError(err);997  }998}9991000// Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the1001// sync layer's last-write-wins merge against wall-clock regressions (NTP step,1002// manual clock change, DST glitch): a backwards Date.now() must never produce1003// an entry timestamp that's <= the entry's existing modifiedAt, or a genuine1004// local edit would lose to a stale remote copy. The returned value is always1005// strictly greater than both the entry's current modifiedAt and the previous1006// call's result (SEV-2-E).1007let lastEntryTimestamp = 0;1008function monotonicEntryTimestamp(currentModifiedAt?: number): number {1009  const base = Math.max(1010    Date.now(),1011    (currentModifiedAt ?? 0) + 1,1012    lastEntryTimestamp + 1,1013  );1014  lastEntryTimestamp = base;1015  return base;1016}10171018async function stampLocalVaultMutation(): Promise<void> {1019  const raw = await chrome.storage.local.get("peach_last_modified_at");1020  const previous = Number(1021    (raw as Record<string, unknown>).peach_last_modified_at,1022  );1023  const next = Math.max(1024    Date.now(),1025    Number.isFinite(previous) ? previous + 1 : 0,1026  );1027  await chrome.storage.local.set({1028    peach_last_modified_at: String(next),1029  });1030}10311032// ---------------------------------------------------------------------------1033// Padding + noise helpers1034// ---------------------------------------------------------------------------10351036/**1037 * Pads the plaintext string with trailing spaces so that the encoded byte1038 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing1039 * whitespace after a complete document, so the padding is transparent on1040 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.1041 */1042function padPlaintext(json: string, minBytes = 0): string {1043  const encoded = new TextEncoder().encode(json);1044  const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);1045  // minBytes lets the duress path force a fixed ceiling so both slots match.1046  const target = Math.max(bucket, minBytes);1047  const padLen = target - encoded.length;1048  return json + " ".repeat(padLen);1049}10501051/**1052 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The1053 * ciphertext length matches the given byte count. Because GCM ciphertext is1054 * pseudo-random, this blob is indistinguishable at rest from a real1055 * encrypted vault without the key.1056 *1057 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto1058 * spec hard limit). Chunk the fill so vaults larger than that — common once1059 * the user has dozens of entries or any passkey — don't throw.1060 */1061const CSPRNG_CHUNK = 65_536;10621063function fillRandom(buf: Uint8Array): Uint8Array {1064  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {1065    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);1066    const chunk = new Uint8Array(end - offset);1067    crypto.getRandomValues(chunk);1068    buf.set(chunk, offset);1069  }1070  return buf;1071}10721073function generateNoiseBlob(ciphertextBytes: number): StoredVault {1074  const salt = crypto.getRandomValues(new Uint8Array(16));1075  const nonce = crypto.getRandomValues(new Uint8Array(12));1076  const cipher = fillRandom(new Uint8Array(ciphertextBytes));1077  const tag = crypto.getRandomValues(new Uint8Array(16));1078  return {1079    version: 1,1080    salt: hex(salt),1081    nonce: hex(nonce),1082    ciphertext: hex(cipher),1083    tag: hex(tag),1084  };1085}10861087// ---------------------------------------------------------------------------1088// Encrypted plaintext envelope1089// ---------------------------------------------------------------------------10901091type VaultPayload = {1092  entries: VaultEntry[];1093  tombstones?: Record<string, number>;1094  duressConfigured?: boolean;1095};10961097type DecryptedPayload = {1098  entries: VaultEntry[];1099  tombstones: Map<string, number>;1100  duressConfigured: boolean;1101};11021103function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {1104  const envelope: VaultPayload = {1105    entries,1106    tombstones: Object.fromEntries(tombstones),1107    duressConfigured: duress,1108  };1109  return padPlaintext(JSON.stringify(envelope), minBytes);1110}11111112function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {1113  const result = new Map<string, number>();1114  if (raw && typeof raw === "object" && !Array.isArray(raw)) {1115    for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {1116      if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {1117        result.set(id, timestamp);1118      }1119    }1120  }1121  // Older payloads had no ledger. Rebuild the durable portion from any1122  // soft-deleted entries before they are eligible for a later purge.1123  for (const entry of allEntries) {1124    if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1125      continue;1126    }1127    if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1128      result.set(entry.id, entry.deletedAt);1129    }1130  }1131  return result;1132}11331134function reconcileTombstones(): void {1135  const byId = new Map(entries.map((entry) => [entry.id, entry]));1136  for (const [id] of tombstones) {1137    if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1138  }1139  for (const entry of entries) {1140    if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1141      if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1142        tombstones.set(entry.id, entry.deletedAt);1143      }1144    }1145  }1146}11471148function decodePayload(plain: string): DecryptedPayload {1149  const parsed = JSON.parse(plain) as VaultPayload;1150  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1151    throw new Error("invalid vault payload");1152  }1153  const normalized = parsed.entries.map(normalizeEntry);1154  return {1155    entries: normalized,1156    tombstones: decodeTombstones(parsed.tombstones, normalized),1157    duressConfigured: parsed.duressConfigured === true,1158  };1159}11601161/**1162 * Consolidate duplicate passkey records after registration.1163 *1164 * Multiple registrations for one RP can leave duplicate passkey records. This1165 * pass collapses the duplicates:1166 *1167 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.1168 *   2. Within each group, the most recently-active credential wins (prefer1169 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).1170 *   3. If a NON-passkey login entry exists for that RP (matched by url or1171 *      additionalUrls hostname), attach the winning passkey to it and1172 *      soft-delete every passkey-bearing entry in the group.1173 *   4. Otherwise, soft-delete every loser in the group, keeping the winner1174 *      as a passkey-only entry.1175 *1176 * Soft delete (deletedAt) rather than splice — the trash flow keeps the1177 * record around for retention and the user can restore if the heuristic1178 * picked wrong. The `merged` flag goes back to decodePayload so the1179 * unlock path persists the consolidated shape immediately.1180 */1181function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1182  let merged = false;1183  const now = Date.now();11841185  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1186  const byRpId = new Map<string, VaultEntry[]>();1187  for (const e of candidates) {1188    const rpId = e.passkey!.rpId;1189    const list = byRpId.get(rpId);1190    if (list) list.push(e);1191    else byRpId.set(rpId, [e]);1192  }11931194  for (const [rpId, group] of byRpId) {1195    if (group.length === 0) continue;1196    group.sort((a, b) => {1197      const aT =1198        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1199      const bT =1200        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1201      return bT - aT;1202    });1203    const winner = group[0]!;12041205    // Look for a NON-passkey login entry matching this RP. Use the URL1206    // hostname rather than a substring search so a "login.foo.com" entry1207    // doesn't accidentally absorb a passkey for "foo.com".1208    const loginTarget = entries.find((e) => {1209      if (e.deletedAt || e.passkey || e.type !== "login") return false;1210      if (e.url && extractDomain(e.url) === rpId) return true;1211      for (const extra of e.additionalUrls ?? []) {1212        if (extractDomain(extra) === rpId) return true;1213      }1214      return false;1215    });12161217    if (loginTarget) {1218      loginTarget.passkey = winner.passkey;1219      // A passkey entry may also carry the site's TOTP seed. Preserve it when1220      // the passkey is consolidated onto an existing login instead of1221      // soft-deleting the only record that held the 2FA configuration.1222      if (!loginTarget.totp && winner.totp) {1223        loginTarget.totp = winner.totp;1224        loginTarget.totpAlgorithm = winner.totpAlgorithm;1225        loginTarget.totpDigits = winner.totpDigits;1226        loginTarget.totpPeriod = winner.totpPeriod;1227      }1228      loginTarget.modifiedAt = now;1229      for (const dup of group) {1230        if (!dup.deletedAt) {1231          dup.deletedAt = now;1232          dup.modifiedAt = now;1233        }1234      }1235      merged = true;1236    } else if (group.length > 1) {1237      for (let i = 1; i < group.length; i += 1) {1238        const dup = group[i]!;1239        if (!dup.deletedAt) {1240          dup.deletedAt = now;1241          dup.modifiedAt = now;1242        }1243      }1244      merged = true;1245    }1246  }12471248  return { merged };1249}12501251// Validates that a `StoredVault` record on disk has the field shape we expect1252// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only1253// authenticates the static VERSION_BYTE AAD, not the `version` field on the1254// outer record — a corrupted `version: 42` slot still decrypts cleanly. This1255// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a1256// history-restore path instead of falsely reporting "wrong password".1257function hasValidVaultSchema(v: unknown): v is StoredVault {1258  if (typeof v !== "object" || v === null) return false;1259  const r = v as Record<string, unknown>;1260  return (1261    r.version === 1 &&1262    typeof r.salt === "string" &&1263    typeof r.nonce === "string" &&1264    typeof r.ciphertext === "string" &&1265    typeof r.tag === "string"1266  );1267}1268const MAX_HISTORY_BLOB_BYTES = 1_024 * 1_024;12691270function hasValidHistorySlot(value: unknown): value is StoredVault {1271  if (!hasValidVaultSchema(value)) return false;1272  return /^[0-9a-f]{32}$/i.test(value.salt) &&1273    /^[0-9a-f]{24}$/i.test(value.nonce) &&1274    /^[0-9a-f]+$/i.test(value.ciphertext) &&1275    value.ciphertext.length % 2 === 0 &&1276    value.ciphertext.length / 2 <= MAX_HISTORY_BLOB_BYTES &&1277    /^[0-9a-f]{32}$/i.test(value.tag);1278}12791280function parseHistorySlot(value: string): StoredVault | null {1281  try {1282    const parsed: unknown = JSON.parse(value);1283    return hasValidHistorySlot(parsed) ? parsed : null;1284  } catch {1285    return null;1286  }1287}12881289function isHistoryRecord(value: unknown): value is VaultSnapshot {1290  if (typeof value !== "object" || value === null) return false;1291  const record = value as Record<string, unknown>;1292  return typeof record.id === "string" && record.id.length > 0 && record.id.length <= 128 &&1293    typeof record.savedAt === "number" && Number.isFinite(record.savedAt) &&1294    typeof record.reason === "string" &&1295    typeof record.vault_a === "string" && record.vault_a.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1296    typeof record.vault_b === "string" && record.vault_b.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1297    (record.entryCount === null || typeof record.entryCount === "number");1298}12991300function sanitizeHistory(value: unknown): VaultSnapshot[] {1301  if (typeof value !== "object" || value === null) return [];1302  if (!("snapshots" in value) || !Array.isArray(value.snapshots)) return [];1303  return value.snapshots.filter(isHistoryRecord).slice(0, HISTORY_MAX);1304}13051306export type TryDecryptResult =1307  | { status: "ok"; payload: DecryptedPayload }1308  | { status: "wrong_key" }1309  | { status: "corrupted"; reason: string };13101311// Exported solely for `unlockFromCodex` below, which needs to verify a1312// supplied passphrase against a slotA blob *without* committing anything to1313// storage. Keep this internal otherwise — callers crossing the messages.ts1314// boundary should always go through `unlock` / `verifyMainPassword` so the1315// constant-time invariants in Projects.md §3.4 are not bypassed.1316export async function tryDecryptSlot(1317  vault: StoredVault,1318  key: Uint8Array,1319  aadTag?: string,1320): Promise<DecryptedPayload | null> {1321  const res = await tryDecryptSlotResult(vault, key, aadTag);1322  return res.status === "ok" ? res.payload : null;1323}13241325export async function tryDecryptSlotResult(1326  vault: StoredVault,1327  key: Uint8Array,1328  aadTag?: string,1329): Promise<TryDecryptResult> {1330  if (!hasValidVaultSchema(vault)) {1331    return { status: "corrupted", reason: "invalid_slot_schema" };1332  }1333  let plain: string;1334  try {1335    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1336  } catch {1337    return { status: "wrong_key" };1338  }1339  try {1340    const parsed = JSON.parse(plain) as unknown;1341    if (typeof parsed !== "object" || parsed === null) {1342      return { status: "corrupted", reason: "invalid_payload_shape" };1343    }1344    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1345      return { status: "corrupted", reason: "invalid_payload_shape" };1346    }1347    return { status: "ok", payload: decodePayload(plain) };1348  } catch {1349    return { status: "corrupted", reason: "invalid_payload_json" };1350  }1351}13521353// ---------------------------------------------------------------------------1354// Core operations1355// ---------------------------------------------------------------------------13561357function buildDomainIndexSync(all: VaultEntry[]): void {1358  domainIndex = new Map();13591360  for (const entry of all) {1361    if (entry.deletedAt) continue;1362    if (entry.type === "note" || entry.type === "card") continue;13631364    // An entry can match multiple sites via `additionalUrls`. Index each1365    // hostname so `queryDomain` returns the entry regardless of which alias1366    // the user is currently visiting. Dedupe per-entry so a single entry1367    // never appears twice in one bucket.1368    const domains = new Set<string>();1369    const primary = extractDomain(entry.url ?? "");1370    if (primary) domains.add(primary);1371    for (const extra of entry.additionalUrls ?? []) {1372      const d = extractDomain(extra);1373      if (d) domains.add(d);1374    }1375    if (domains.size === 0) continue;13761377    for (const domain of domains) {1378      const bucket = domainIndex.get(domain) ?? [];1379      bucket.push(entry);1380      domainIndex.set(domain, bucket);1381    }1382  }1383}13841385export async function createVault(password: string): Promise<void> {1386  const salt = generateSalt();1387  derivedKey = await deriveKey(password, salt);1388  entries = [];1389  tombstones = new Map();1390  vaultMode = "real";1391  duressConfigured = false;1392  buildDomainIndexSync(entries);13931394  // Generate and wrap the root key for recovery word escrow.1395  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1396    await generateAndWrapRootKey();1397  rootKey = unhex(rawRootKey);13981399  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1400  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");14011402  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate1403  // it without recovery words (H2/COR-006).1404  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);14051406  const slotA: StoredVault = {1407    version: 1,1408    salt: hex(salt),1409    nonce,1410    ciphertext,1411    tag,1412    recovery_salt: recoverySalt,1413    recovery_wrapped_root_key: wrappedRootKey,1414    recovery_nonce: wrappedNonce,1415    recovery_tag: wrappedTag,1416    ...pwRoot,1417  };1418  await writeSlot(SLOT_A, slotA);14191420  // Matching-size noise blob for slot B, so a fresh install shows two1421  // similarly-shaped records regardless of whether duress is ever set up.1422  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1423  await stampLocalVaultMutation();14241425  // An identity created while this install had no vault was sealed under the1426  // password alone; the vault keys exist now, so re-seal it under them.1427  await identityHooks?.onVaultCreated(password).catch((error) => {1428    console.warn("[Peach] local identity promotion failed:", error);1429  });14301431  touchAutoLock();1432}14331434/**1435 * Creates a local vault from an existing vault root key received through the1436 * SAS-gated adoption protocol. The password is local to this device; it wraps1437 * the adopted root key at rest and does not need to match the source device.1438 */1439export async function createVaultFromRootKey(1440  password: string,1441  rootKeyHex: string,1442): Promise<{ vaultId: string }> {1443  if (typeof password !== "string" || password.length === 0) {1444    throw new Error("ADOPT_INVALID_PASSWORD");1445  }1446  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1447    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1448  }14491450  const rootKeyBytes = unhex(rootKeyHex);1451  if (rootKeyBytes.length !== 32) {1452    rootKeyBytes.fill(0);1453    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1454  }14551456  let nextDerivedKey: Uint8Array | null = null;1457  let sessionRootKey: Uint8Array | null = null;1458  try {1459    if ((await loadSlot(SLOT_A)) !== null) {1460      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1461    }14621463    const nextEntries: VaultEntry[] = [];1464    const nextDuressConfigured = false;1465    const vaultId = await computeVaultId(rootKeyBytes);1466    const salt = generateSalt();1467    nextDerivedKey = await deriveKey(password, salt);1468    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);14691470    sessionRootKey = new Uint8Array(rootKeyBytes);1471    const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1472    const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1473    const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1474    const slotA: StoredVault = {1475      version: 1,1476      salt: hex(salt),1477      nonce,1478      ciphertext,1479      tag,1480      recovery_salt: recovery.recoverySalt,1481      recovery_wrapped_root_key: recovery.wrappedRootKey,1482      recovery_nonce: recovery.wrappedNonce,1483      recovery_tag: recovery.wrappedTag,1484      ...pwRoot,1485    };1486    const slotB = generateNoiseBlob(ciphertext.length / 2);1487    await commitCreatedVaultSlots(slotA, slotB);14881489    const previousDerivedKey = derivedKey;1490    const previousRootKey = rootKey;1491    derivedKey = nextDerivedKey;1492    rootKey = sessionRootKey;1493    nextDerivedKey = null;1494    sessionRootKey = null;1495    entries = nextEntries;1496    tombstones = new Map();1497    vaultMode = "real";1498    duressConfigured = nextDuressConfigured;1499    buildDomainIndexSync(entries);1500    if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1501    if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1502    touchAutoLock();15031504    // The receiver's identity was minted before this vault existed (adoption v21505    // needs it in its first frame), so re-seal it under the vault keys now.1506    await identityHooks?.onVaultCreated(password).catch((error) => {1507      console.warn("[Peach] local identity promotion failed:", error);1508    });15091510    return { vaultId };1511  } finally {1512    rootKeyBytes.fill(0);1513    if (nextDerivedKey) zeroKey(nextDerivedKey);1514    if (sessionRootKey) sessionRootKey.fill(0);1515  }1516}15171518export async function hasVault(): Promise<boolean> {1519  return (await loadSlot(SLOT_A)) !== null;1520}15211522/**1523 * Returns whether the real vault has recorded that a duress passphrase is1524 * configured. Reliable only after a real-mode unlock — before unlock or in1525 * decoy mode, returns false because we cannot read slot A's plaintext.1526 */1527export async function isDuressEnabled(): Promise<boolean> {1528  return vaultMode === "real" && duressConfigured;1529}15301531export async function setupDuress(duressPassword: string): Promise<void> {1532  if (!derivedKey || vaultMode !== "real") {1533    throw new Error("vault must be unlocked in real mode");1534  }1535  // Encrypt the decoy payload (empty entries) under the duress key. Pad to1536  // at least the current slot A ciphertext size so the two slots look the1537  // same at rest at setup time.1538  const slotA = await loadSlot(SLOT_A);1539  if (!slotA) throw new Error("missing slot A");15401541  const decoySalt = generateSalt();1542  const decoyKey = await deriveKey(duressPassword, decoySalt);1543  try {1544    // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1545    // same ceiling by the persistSlotA() call below (duressConfigured is now1546    // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1547    // lengths cannot reveal that duress is configured (audit MEDIUM).1548    const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1549    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1550    await writeSlot(SLOT_B, {1551      version: 1,1552      salt: hex(decoySalt),1553      nonce,1554      ciphertext,1555      tag,1556    });1557  } finally {1558    zeroKey(decoyKey);1559  }15601561  // Re-encrypt slot A with the duressConfigured flag set to true. No external1562  // flag is written — the bit lives inside the encrypted envelope.1563  duressConfigured = true;1564  await persistSlotA();1565}15661567export async function disableDuress(): Promise<void> {1568  if (!derivedKey || vaultMode !== "real") {1569    throw new Error("vault must be unlocked in real mode");1570  }1571  // Overwrite slot B with fresh noise that matches slot A's size. The slot1572  // remains occupied so its mere existence does not signal that duress was1573  // ever configured.1574  const slotA = await loadSlot(SLOT_A);1575  if (slotA) {1576    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1577  }15781579  duressConfigured = false;1580  await persistSlotA();1581}15821583// L14/COR-015: this function intentionally checks slot A (real vault) only.1584// A duress-mode user attempting changePassword is rejected earlier by the1585// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1586// distinction here is by design — duress users cannot rotate the real1587// password by guessing it, and the asymmetry in error timing is acceptable1588// because duress mode is opt-in and the user knows their own decoy key.1589export async function verifyMainPassword(password: string): Promise<boolean> {1590  const slotA = await loadSlot(SLOT_A);1591  if (!slotA) return false;1592  const key = await deriveKey(password, unhex(slotA.salt));1593  try {1594    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1595  } finally {1596    zeroKey(key);1597  }1598}15991600export async function unlock(password: string): Promise<boolean> {1601  return attemptUnlockWithDerive(password, deriveKey);1602}16031604async function attemptUnlockWithDerive(1605  password: string,1606  derive: typeof deriveKey,1607): Promise<boolean> {1608  const epoch = vaultEpoch;1609  const stale = () => epoch !== vaultEpoch;1610  // Load both current-format slots in parallel. Both exist on a supported1611  // install; the1612  // parallel load and the parallel derivation below keep wall-clock time1613  // constant regardless of which slot's passphrase (real or decoy) the1614  // caller supplied, so a timing side channel cannot distinguish the1615  // duress path from the real-unlock path (DUR-002).1616  const [slotA, slotB] = await Promise.all([1617    loadSlot(SLOT_A),1618    loadSlot(SLOT_B),1619  ]);16201621  // Derive both keys in parallel, unconditionally.1622  const [keyA, keyB] = await Promise.all([1623    slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1624    slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1625  ]);16261627  // Attempt both decrypts in parallel. The Result variant lets us distinguish1628  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1629  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1630  const [resultA, resultB] = await Promise.all([1631    keyA && slotA1632      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1633      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1634    keyB && slotB1635      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1636      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1637  ]);1638  const payloadA = resultA.status === "ok" ? resultA.payload : null;1639  const payloadB = resultB.status === "ok" ? resultB.payload : null;16401641  if (stale()) {1642    if (keyA) zeroKey(keyA);1643    if (keyB) zeroKey(keyB);1644    return false;1645  }16461647  if (payloadA && keyA) {1648    if (stale()) {1649      zeroKey(keyA);1650      if (keyB) zeroKey(keyB);1651      return false;1652    }1653    derivedKey = keyA;1654    vaultMode = "real";1655    entries = payloadA.entries;1656    tombstones = payloadA.tombstones;1657    duressConfigured = payloadA.duressConfigured;1658    buildDomainIndexSync(entries);1659    await loadSettings();1660    if (stale()) {1661      await lock();1662      return false;1663    }1664    touchAutoLock();1665    if (keyB) zeroKey(keyB);16661667    // Current vaults always carry a password-wrapped root key. Older vaults1668    // without this escrow are outside the supported storage format.1669    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1670    if (!rootKey) {1671      await lock();1672      return false;1673    }16741675    // PB-26-213: adopt legacy plaintext pair secrets into the encrypted1676    // record and refresh the trusted-session operational copy.1677    await reconcilePairSecrets(rootKey);1678    if (stale()) {1679      await lock();1680      return false;1681    }1682    await lockedSyncUnlockHook?.().catch((error) => {1683      console.warn("[Peach sync] locked inbox drain failed:", error);1684    });1685    if (stale()) {1686      await lock();1687      return false;1688    }1689    await resetPinFailedAttempts();1690    if (stale()) {1691      await lock();1692      return false;1693    }1694    await saveSessionKey(keyA, "real", epoch);1695    if (stale()) return false;1696    return true;1697  }16981699  if (payloadB && keyB) {1700    if (stale()) {1701      zeroKey(keyB);1702      if (keyA) zeroKey(keyA);1703      return false;1704    }1705    derivedKey = keyB;1706    vaultMode = "decoy";1707    decoySyncTeardownHook?.();1708    // A duress session must not retain the real vault's root key. It gates the1709    // recovery words, the sync identity, and the root wrap of the local device1710    // identity, so leaving it live would expose all three in duress mode.1711    if (rootKey) {1712      rootKey.fill(0);1713      rootKey = null;1714    }1715    entries = payloadB.entries;1716    tombstones = payloadB.tombstones;1717    duressConfigured = false;1718    buildDomainIndexSync(entries);1719    await loadSettings();1720    touchAutoLock();1721    if (keyA) zeroKey(keyA);1722    // PB-26-213: a duress session must never serve the real pair-secret map.1723    await dropSessionCache();1724    // Nor the real device identity: a duress session is not a real one.1725    await notifyIdentitySessionEnded();1726    if (stale()) {1727      await lock();1728      return false;1729    }1730    await saveSessionKey(keyB, "decoy", epoch);1731    if (stale()) return false;1732    return true;1733  }17341735  if (keyA) zeroKey(keyA);1736  if (keyB) zeroKey(keyB);1737  // Surface corruption (good AES tag, broken schema) distinctly from1738  // wrong-password so the popup can offer history-restore (M7/COR-007).1739  if (resultA.status === "corrupted") {1740    lastUnlockFailureReason = resultA.reason;1741  } else if (resultB.status === "corrupted") {1742    lastUnlockFailureReason = resultB.reason;1743  }1744  return false;1745}17461747// ---------------------------------------------------------------------------1748// MV3 service worker session key — survives SW restarts within a browser session1749// ---------------------------------------------------------------------------17501751// chrome.storage.session is cleared on browser close; survives SW restart.1752// Firefox does not implement it (persistent background script, no restart risk).1753const SESSION_KEY_STORAGE = "peach_session_dk";17541755// Order writes and removals so lock's awaited removal is a barrier after1756// every escrow write that started before it.1757let sessionKeyMutation = Promise.resolve();17581759function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1760  const task = sessionKeyMutation.then(mutation, mutation);1761  sessionKeyMutation = task.catch(() => undefined);1762  return task;1763}17641765// PB-26-203: the session-key escrow may only operate where startup positively1766// established trusted-only session storage. Defaults to available so direct1767// vault imports (tests, non-entry bootstraps) keep working; entry.ts applies1768// the real hardening result before the first restore.1769let sessionEscrowAvailable = true;17701771/**1772 * Apply the storage-hardening result at startup. When the trusted-only1773 * session state cannot be established, any stored session key is removed and1774 * the escrow stays off: the user unlocks again after a worker restart1775 * instead of trusting an unproven boundary.1776 */1777export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1778  setSessionAllowed(sessionEscrow);1779  if (sessionEscrow) return;1780  sessionEscrowAvailable = false;1781  await clearSessionKey();1782}17831784async function saveSessionKey(1785  key: Uint8Array,1786  mode: "real" | "decoy",1787  expectedEpoch = vaultEpoch,1788): Promise<void> {1789  const session = chrome.storage.session;1790  if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1791  const stored = { keyHex: hex(key), mode };1792  await mutateSessionKey(async () => {1793    if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1794    await session.set({ [SESSION_KEY_STORAGE]: stored });1795  });1796}17971798async function clearSessionKey(): Promise<void> {1799  const session = chrome.storage.session;1800  if (!session) return;1801  await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1802}18031804// A lock that only holds while the service worker stays alive is no lock at1805// all: a cold worker's restoreFromSession() would find the surviving session1806// key and silently re-unlock ~a minute after the user locked. So lock()1807// bumps this epoch synchronously at entry and zeroes keys/indexes before any1808// await; a restore that started earlier sees the mismatch after its next1809// await and aborts instead of publishing.1810let vaultEpoch = 0;18111812// One in-flight restore shared by concurrent callers: storage is read once1813// and the locked-sync unlock hook runs once.1814let restoreInFlight: Promise<boolean> | null = null;18151816/**1817 * On SW restart, re-derive all in-memory vault state from the session key1818 * stored in chrome.storage.session (set at unlock time, cleared by lock();1819 * also cleared on browser close or when restoration fails).1820 * Returns true if the vault was restored, false if no session key was found.1821 * No-ops if the vault is already unlocked.1822 */1823export async function restoreFromSession(): Promise<boolean> {1824  if (derivedKey !== null) return true;1825  if (restoreInFlight) return restoreInFlight;1826  const task = restoreFromSessionInner();1827  restoreInFlight = task;1828  try {1829    return await task;1830  } finally {1831    restoreInFlight = null;1832  }1833}18341835async function restoreFromSessionInner(): Promise<boolean> {1836  const epoch = vaultEpoch;1837  const stale = () => epoch !== vaultEpoch;1838  if (derivedKey !== null) return true;1839  if (!sessionEscrowAvailable) return false;1840  if (!chrome.storage.session) return false;1841  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1842  if (stale()) return false;1843  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1844  if (!entry?.keyHex) return false;18451846  const key = unhex(entry.keyHex);1847  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1848  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1849  const slot = await loadSlot(slotKey);1850  if (stale()) { zeroKey(key); return false; }1851  if (!slot) { await clearSessionKey(); return false; }18521853  const result = await tryDecryptSlotResult(slot, key, aadTag);1854  if (stale()) { zeroKey(key); return false; }1855  if (result.status !== "ok") { await clearSessionKey(); return false; }18561857  // Decrypted payload stays in locals until the final epoch-checked commit1858  // below; nothing observable is published before then.1859  const payload = result.payload;1860  await loadSettings();1861  if (stale()) { zeroKey(key); return false; }18621863  // Rehydrate the root key used by sync identity derivation from the password1864  // escrow stored on the current vault format.1865  let freshRoot: Uint8Array | null = null;1866  if (entry.mode === "real") {1867    freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1868    if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1869    if (!freshRoot) {1870      await lock();1871      return false;1872    }1873  }18741875  if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1876  if (freshRoot) {1877    // PB-26-213: encrypted pair-secret record + session copy, before publish.1878    await reconcilePairSecrets(freshRoot);1879    if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1880  } else {1881    // PB-26-213: a duress restore must never serve the real pair-secret map.1882    await dropSessionCache();1883    // Nor the real device identity.1884    await notifyIdentitySessionEnded();1885    if (stale()) { zeroKey(key); return false; }1886  }1887  derivedKey = key;1888  vaultMode = entry.mode;1889  if (entry.mode === "decoy") decoySyncTeardownHook?.();1890  entries = payload.entries;1891  tombstones = payload.tombstones;1892  duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1893  if (freshRoot) rootKey = freshRoot;1894  buildDomainIndexSync(entries);1895  touchAutoLock();18961897  await lockedSyncUnlockHook?.().catch((error) => {1898    console.warn("[Peach sync] locked inbox drain failed:", error);1899  });1900  if (stale()) return false;19011902  return true;1903}19041905export async function lock(): Promise<void> {1906  // A user-initiated lock must be durable across MV3 service-worker restarts.1907  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1908  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1909  // If the session key survived lock(), that restore would silently re-unlock1910  // the vault ~a minute after the user locked it — a lock that only held1911  // while the SW happened to stay alive. The background-sync convenience the1912  // session key once bought (sync runner re-waking to a restored vault) is1913  // not worth an unlocked vault the user never asked to unlock; when locked,1914  // sync-runner correctly reports VAULT_LOCKED instead.1915  vaultEpoch++;1916  if (derivedKey) {1917    zeroKey(derivedKey);1918    derivedKey = null;1919  }1920  if (rootKey) {1921    rootKey.fill(0);1922    rootKey = null;1923  }19241925  entries = [];1926  tombstones = new Map();1927  domainIndex = new Map();1928  vaultMode = "real";1929  duressConfigured = false;1930  clearAutoLock();1931  await clearSessionKey();1932  // The decrypted identity is session state as well, so a locked vault must not1933  // leave one behind; clear it after the in-memory state is gone.1934  await notifyIdentitySessionEnded();1935  await clearRecoveryConfirmPending();1936}19371938export function isUnlocked(): boolean {1939  return derivedKey !== null;1940}1941/** True only while the active session is unlocked through the decoy slot. */1942export function isDecoyMode(): boolean {1943  return derivedKey !== null && vaultMode === "decoy";1944}19451946// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1947// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1948// always derives from scratch via Argon2id.1949export async function unlockWithKey(1950  masterKeyHex: string,1951): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1952  const epoch = vaultEpoch;1953  const stale = () => epoch !== vaultEpoch;1954  let masterKeyBytes: Uint8Array | null = null;1955  try {1956    masterKeyBytes = unhex(masterKeyHex);1957    const stored = await loadSlot(SLOT_A);1958    if (!stored) return { ok: false, error: "invalid_key" };1959    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1960    if (!payload || stale()) {1961      zeroKey(masterKeyBytes);1962      return { ok: false, error: "invalid_key" };1963    }19641965    derivedKey = masterKeyBytes;1966    vaultMode = "real";1967    entries = payload.entries;1968    tombstones = payload.tombstones;1969    duressConfigured = payload.duressConfigured;1970    buildDomainIndexSync(entries);1971    await loadSettings();1972    touchAutoLock();19731974    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1975    if (!rootKey) {1976      await lock();1977      return { ok: false, error: "invalid_key" };1978    }1979    await reconcilePairSecrets(rootKey);1980    if (stale()) {1981      await lock();1982      return { ok: false, error: "invalid_key" };1983    }1984    await saveSessionKey(masterKeyBytes, "real", epoch);1985    if (stale()) return { ok: false, error: "invalid_key" };1986    return { ok: true, entries };1987  } catch {1988    if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1989    return { ok: false, error: "invalid_key" };1990  }1991}19921993// --- Adoption duress-slot helpers (passthrough, no decrypt) ---19941995/**1996 * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1997 * duress is configured and slot B exists, so the inline snapshot can ship it1998 * to the receiver. Returns null otherwise. Never decrypts.1999 */2000export async function exportDuressSlotForAdoption(): Promise<string | null> {2001  if (!(await isDuressEnabled())) return null;2002  const slotB = await loadSlot(SLOT_B);2003  if (!slotB) return null;2004  return JSON.stringify(slotB);2005}20062007/**2008 * Adoption RECEIVER helper: installs a duress slot received from a source2009 * device. Validates the StoredVault shape (same guard as unlockFromCodex)2010 * and writes it to slot B. Throws on invalid shape so the caller can treat2011 * it as a non-fatal snapshot failure. Never decrypts.2012 */2013export async function installAdoptedDuressSlot(json: string): Promise<void> {2014  let parsed: StoredVault;2015  try {2016    const raw = JSON.parse(json) as unknown;2017    if (!hasValidVaultSchema(raw)) {2018      throw new Error("invalid_duress_slot_shape");2019    }2020    parsed = raw;2021  } catch (err) {2022    if (err instanceof SyntaxError) {2023      throw new Error("invalid_duress_slot_shape");2024    }2025    throw err;2026  }2027  await writeSlot(SLOT_B, parsed);2028}20292030/**2031 * Restore the vault from a parsed Peach Codex payload.2032 *2033 * The supplied passphrase is verified against the Codex's slotA *before* any2034 * storage mutation — wrong passphrases return without touching disk. On2035 * success, the current vault state is snapshotted (reason: "restore") so the2036 * user can revert via Settings -> Vault History, then slotA/slotB are written2037 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A2038 * regular `unlock()` follows so the rest of the SW sees the standard2039 * post-unlock state.2040 *2041 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex2042 * printed by a Pro user must always be restorable, even on a free device.2043 *2044 * Timing is not held constant here (in contrast to `unlock`): the Codex flow2045 * is an explicit user action with a known passphrase, not a passive lock2046 * screen, so the duress-vs-real timing side channel does not apply.2047 */2048export async function unlockFromCodex(params: {2049  slotA: string; // JSON string of StoredVault for slot A2050  slotB: string | null; // JSON string of StoredVault for slot B, or null2051  timestamp: number;2052  masterPassphrase: string;2053}): Promise<2054  | { ok: true; entryCount: number }2055  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" | "missing_root_escrow" }2056> {2057  let parsedSlotA: StoredVault;2058  let parsedSlotB: StoredVault | null = null;2059  try {2060    const rawA = JSON.parse(params.slotA) as unknown;2061    if (!hasValidVaultSchema(rawA)) {2062      return { ok: false, reason: "invalid_slot_format" };2063    }2064    parsedSlotA = rawA;2065    if (params.slotB !== null) {2066      const rawB = JSON.parse(params.slotB) as unknown;2067      if (!hasValidVaultSchema(rawB)) {2068        return { ok: false, reason: "invalid_slot_format" };2069      }2070      parsedSlotB = rawB;2071    }2072  } catch {2073    return { ok: false, reason: "invalid_slot_format" };2074  }20752076  // Verify the passphrase and the password-wrapped root escrow against the2077  // staged Slot A WITHOUT writing anything. Payload decoding is part of the2078  // trial decrypt, so unlock() cannot be the first place these checks run.2079  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));2080  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");2081  if (!trialPayload) {2082    zeroKey(trialKey);2083    return { ok: false, reason: "wrong_passphrase" };2084  }2085  const hasEscrow =2086    !!parsedSlotA.pw_root_ciphertext && !!parsedSlotA.pw_root_nonce && !!parsedSlotA.pw_root_tag;2087  if (!hasEscrow) {2088    zeroKey(trialKey);2089    return { ok: false, reason: "missing_root_escrow" };2090  }2091  const stagedRootKey = await deriveRootKeyFromDerivedKey(trialKey, parsedSlotA);2092  if (!stagedRootKey) {2093    zeroKey(trialKey);2094    return { ok: false, reason: "wrong_passphrase" };2095  }2096  zeroKey(stagedRootKey);2097  zeroKey(trialKey);20982099  // Stage the complete candidate pair in memory before touching storage.2100  const stagedSlotB = parsedSlotB ?? generateNoiseBlob(parsedSlotA.ciphertext.length / 2);21012102  // Drop the trial key; `unlock()` below derives its own copy. Holding two2103  // copies of the master key in memory at once would be a needless surface.21042105  // Snapshot the about-to-be-overwritten on-disk state so the user can revert2106  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.2107  await takeVaultSnapshot("restore");2108  const previousSlotA = await loadSlot(SLOT_A);2109  const previousSlotB = await loadSlot(SLOT_B);2110  let slotACommitted = false;2111  let slotBCommitted = false;2112  try {2113    await writeSlot(SLOT_A, parsedSlotA);2114    slotACommitted = true;2115    await writeSlot(SLOT_B, stagedSlotB);2116    slotBCommitted = true;2117    await chrome.storage.local.set({2118      peach_last_modified_at: String(params.timestamp),2119    });2120  } catch (err) {2121    if (slotBCommitted && previousSlotB) await writeSlot(SLOT_B, previousSlotB);2122    if (slotACommitted && previousSlotA) await writeSlot(SLOT_A, previousSlotA);2123    throw err;2124  }21252126  // Standard unlock path so domain index, settings, auto-lock, and2127  // recovery-key state all populate exactly as a normal unlock would.2128  const ok = await unlock(params.masterPassphrase);2129  if (!ok) {2130    // Should be unreachable — the staged candidate passed the same checks.2131    return { ok: false, reason: "wrong_passphrase" };2132  }2133  return { ok: true, entryCount: getEntries().length };2134}21352136// Returns the current in-memory derived key as a hex string. Only the PIN2137// unlock path needs this value, and it reads module state directly; this2138// accessor exists so unlockWithKey's root-key rehydration is testable.2139export /* @testable */ function getDerivedKeyHex(): string {2140  if (!derivedKey) throw new Error("vault_not_unlocked");2141  return hex(derivedKey);2142}21432144/**2145 * Returns the vault's stable root key as hex, or null when locked / not yet2146 * populated. The root key is random per-vault, generated at creation, escrowed2147 * under the recovery words, and re-wrapped (not regenerated) on every password2148 * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.2149 *2150 * This is the correct input for sync identity derivation (vault_id, the Noise2151 * NK static key, the Ed25519 manifest signing key, and per-entry envelope2152 * keys): paired devices keep the same identity and can still decrypt each2153 * other's entries after either side rotates its master password (SEV-2-B).2154 *2155 * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED2156 * instead of crashing; rootKey is populated on every supported real-mode2157 * unlock via deriveRootKeyFromDerivedKey.2158 */2159export function getRootKeyHex(): string | null {2160  return rootKey ? hex(rootKey) : null;2161}21622163export function getEntries(): VaultEntry[] {2164  if (!derivedKey) throw new Error("vault_locked");2165  return entries.filter((entry) => !entry.deletedAt);2166}21672168const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;21692170export function getIdentities(): SavedIdentity[] {2171  if (!derivedKey) throw new Error("vault_locked");2172  const active = entries.filter((e) => !e.deletedAt && e.type === "login");21732174  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();2175  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();21762177  for (const entry of active) {2178    const domain = extractDomain(entry.url ?? "") ?? "";2179    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;21802181    function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {2182      const existing = map.get(v);2183      if (!existing || modifiedAt > existing.lastUsed) {2184        map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });2185      } else {2186        map.set(v, { ...existing, count: existing.count + 1 });2187      }2188    }21892190    const uVal = entry.username?.trim();2191    if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);21922193    // Also count the dedicated email field — entries created via the inline2194    // popup new-entry form store email here rather than in username.2195    const eVal = entry.email?.trim();2196    if (eVal) tally(emailMap, eVal);2197  }21982199  const toIdentity = (type: "email" | "username") =>2200    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({2201      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,2202    });22032204  return [2205    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2206    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2207  ];2208}22092210export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2211  if (!derivedKey) throw new Error("vault_locked");22122213  const now = Date.now();2214  const added: VaultEntry[] = [];2215  for (const draft of drafts) {2216    try {2217      const sanitized = sanitizeEntryDraft(draft);2218      const nextEntry: VaultEntry = {2219        ...sanitized,2220        id: crypto.randomUUID(),2221        createdAt: now,2222        modifiedAt: monotonicEntryTimestamp(),2223      };2224      entries.push(nextEntry);2225      added.push(nextEntry);2226    } catch {2227      // Skip invalid drafts silently — caller can see diff between input and output length2228    }2229  }22302231  if (added.length > 0) {2232    await persistEntries();2233  }22342235  return added;2236}22372238/**2239 * Restore a set of entries in full fidelity — preserves id, createdAt,2240 * modifiedAt, and every other field unlike `batchAddEntries` which2241 * assigns fresh identity. Used by restore paths where the backup is the2242 * authoritative source of truth. `mode` controls how entries already in2243 * the vault are treated.2244 */2245export async function restoreEntries(2246  incoming: VaultEntry[],2247  mode: "replace" | "merge"2248): Promise<{ added: number; updated: number; skipped: number }> {2249  if (!derivedKey) throw new Error("vault_locked");22502251  // A replace with zero incoming entries is almost certainly a caller bug2252  // (e.g. an import that silently dropped everything). Refuse rather than2253  // wipe the vault.2254  if (mode === "replace" && incoming.length === 0) {2255    throw new Error("refuse to replace with empty entry set");2256  }22572258  let added = 0;2259  let updated = 0;2260  let skipped = 0;22612262  if (mode === "replace") {2263    entries = [];2264    for (const entry of incoming) {2265      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2266      if (!normalized) {2267        skipped += 1;2268        continue;2269      }2270      entries.push(normalized);2271      if (normalized.deletedAt) {2272        tombstones.set(2273          normalized.id,2274          Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2275        );2276      } else {2277        tombstones.delete(normalized.id);2278      }2279      added += 1;2280    }2281  } else {2282    const byId = new Map<string, VaultEntry>();2283    for (const e of entries) byId.set(e.id, e);22842285    for (const entry of incoming) {2286      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2287      if (!normalized) {2288        skipped += 1;2289        continue;2290      }2291      const existing = byId.get(normalized.id);2292      if (!existing) {2293        entries.push(normalized);2294        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2295        else tombstones.delete(normalized.id);2296        added += 1;2297      } else if (normalized.modifiedAt > existing.modifiedAt) {2298        const idx = entries.findIndex((e) => e.id === existing.id);2299        if (idx !== -1) {2300          entries[idx] = normalized;2301          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2302          else tombstones.delete(normalized.id);2303          updated += 1;2304        }2305      } else {2306        skipped += 1;2307      }2308    }2309  }23102311  await persistEntries("import");2312  return { added, updated, skipped };2313}23142315/**2316 * A Peach entry id is a UUID. A restored or imported file carries whatever id2317 * its author used, and no reader bounds it, so one oversized id would ride2318 * into every future sync frame and be rejected there — a permanent, opaque2319 * failure on both platforms, caused by data that was already local.2320 *2321 * The action is mint, not reject: this is the last point where an id is still2322 * ours to choose, so the worst outcome is one duplicate if the same vault is2323 * later restored beside a device still holding the old id. Rejecting would2324 * lose the user's import.2325 *2326 * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries2327 * also calls. Sync has to adopt a peer's id verbatim — that is the only thing2328 * keeping two devices agreed on which entry they are discussing — so reminting2329 * there would fork identity on every device, on every round.2330 */2331const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;23322333function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2334  if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2335  return { ...entry, id: crypto.randomUUID() };2336}23372338/**2339 * Validate + normalise a VaultEntry coming from a backup, import, or sync2340 * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies2341 * only known VaultEntry fields (unknown/attacker keys are dropped), and2342 * nested `passkey`/`canary` shapes are rebuilt field-by-field, never2343 * spread. Returns null for values the live vault shouldn't accept.2344 */2345function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2346  if (!entry || typeof entry !== "object") return null;2347  const title = typeof entry.title === "string" ? entry.title.trim() : "";2348  if (!title) return null;2349  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2350  const now = Date.now();2351  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2352  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;23532354  // normalizeEntry whitelists every known VaultEntry field before the entry2355  // reaches the live vault.2356  const normalized = normalizeEntry({2357    ...entry,2358    id,2359    title,2360    type: entry.type ?? "login",2361    createdAt,2362    modifiedAt,2363  });23642365  if (normalized.passkey) {2366    normalized.passkey = sanitizePasskey(normalized.passkey);2367  }2368  if (normalized.canary) {2369    normalized.canary = sanitizeCanary(normalized.canary);2370  }2371  // PCH-01-016: bound tags on the restore path too, so both import funnels agree.2372  normalized.tags = sanitizeTags(normalized.tags);23732374  // Login entries from backup must carry a password (or be passkey-only,2375  // in which case the passkey sub-object replaces the password requirement).2376  if (2377    normalized.type === "login" &&2378    !normalized.passkey &&2379    typeof normalized.password !== "string"2380  ) {2381    return null;2382  }2383  if (entry.passkey && !normalized.passkey) {2384    // Entry claimed a passkey but the shape didn't validate — refuse the2385    // whole entry rather than silently importing a credential-less husk.2386    return null;2387  }23882389  return normalized;2390}23912392/**2393 * Rebuild a PasskeyData object from untrusted input, field by field.2394 * Returns undefined when required fields are missing/mistyped — a rogue2395 * passkey credential must not activate via import (MV-6 attack scenario).2396 */2397function sanitizePasskey(pk: unknown): PasskeyData | undefined {2398  if (!pk || typeof pk !== "object") return undefined;2399  const r = pk as Record<string, unknown>;2400  if (2401    typeof r.rpId !== "string" || !r.rpId ||2402    typeof r.credentialId !== "string" || !r.credentialId ||2403    typeof r.publicKeyCose !== "string" ||2404    typeof r.userHandle !== "string" ||2405    typeof r.userName !== "string"2406  ) {2407    return undefined;2408  }2409  return {2410    rpId: r.rpId,2411    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2412    credentialId: r.credentialId,2413    publicKeyCose: r.publicKeyCose,2414    privateKeyJwk:2415      r.privateKeyJwk && typeof r.privateKeyJwk === "object"2416        ? (r.privateKeyJwk as JsonWebKey)2417        : undefined,2418    userHandle: r.userHandle,2419    userName: r.userName,2420    userDisplayName:2421      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2422    algorithm: r.algorithm === -8 ? -8 : -7,2423    signCount: typeof r.signCount === "number" ? r.signCount : 0,2424    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2425    backupEligible: r.backupEligible === true,2426    backupState: r.backupState === true,2427    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2428    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2429    transports: Array.isArray(r.transports)2430      ? r.transports.filter((t): t is string => typeof t === "string")2431      : undefined,2432    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2433  };2434}24352436/**2437 * Rebuild a Canary object from untrusted input. The webhook must be a2438 * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing2439 * exfiltration endpoint that the canary subsystem will POST entry titles2440 * to (MV-6 attack scenario). Invalid → the canary is dropped, not the2441 * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire2442 * time (isAllowedCanaryWebhookUrl), so this import-time check is the2443 * outer of two gates.2444 */2445function sanitizeCanary(c: unknown): Canary | undefined {2446  if (!c || typeof c !== "object") return undefined;2447  const r = c as Record<string, unknown>;2448  if (typeof r.webhookUrl !== "string") return undefined;2449  let url: URL;2450  try {2451    url = new URL(r.webhookUrl);2452  } catch {2453    return undefined;2454  }2455  if (url.protocol !== "https:") return undefined;2456  return {2457    webhookUrl: url.toString(),2458    label: typeof r.label === "string" ? r.label : undefined,2459    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2460    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2461  };2462}24632464/**2465 * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted.2466 * Keep only non-empty strings, cap each at 256 chars, and cap the count at 2552467 * (the same per-entry ceiling the export codec enforces, peachPayload.ts).2468 * Non-array or all-invalid input collapses to undefined.2469 */2470function sanitizeTags(tags: unknown): string[] | undefined {2471  if (!Array.isArray(tags)) return undefined;2472  const out: string[] = [];2473  for (const t of tags) {2474    if (typeof t !== "string") continue;2475    const trimmed = t.trim();2476    if (!trimmed) continue;2477    out.push(trimmed.slice(0, 256));2478    if (out.length >= 255) break;2479  }2480  return out.length > 0 ? out : undefined;2481}24822483// Soft-deletes every non-deleted entry in one call. The literal string2484// argument is a tripwire against accidental calls — any caller that would2485// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this2486// to a boolean.2487export async function clearVaultEntries(2488  confirmation: "REPLACE_ALL"2489): Promise<void> {2490  if (confirmation !== "REPLACE_ALL") {2491    throw new Error("clearVaultEntries requires explicit confirmation");2492  }2493  if (!derivedKey) throw new Error("vault_locked");2494  for (const entry of entries) {2495    if (!entry.deletedAt) {2496      const stamp = monotonicEntryTimestamp(Math.max(2497        entry.modifiedAt,2498        tombstones.get(entry.id) ?? 0,2499      ));2500      entry.deletedAt = stamp;2501      entry.modifiedAt = stamp;2502      tombstones.set(entry.id, stamp);2503    }2504  }2505  await persistEntries();2506}25072508export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2509  if (!derivedKey) throw new Error("vault_locked");25102511  const sanitized = sanitizeEntryDraft(entry);2512  const now = Date.now();2513  const stamp = monotonicEntryTimestamp();2514  const nextEntry: VaultEntry = {2515    ...sanitized,2516    id: crypto.randomUUID(),2517    createdAt: now,2518    modifiedAt: stamp,2519    // Stamp passwordChangedAt at creation so age tracking is accurate from2520    // day one. Non-login entries carry the same stamp harmlessly.2521    passwordChangedAt: sanitized.password ? stamp : undefined,2522  };25232524  // Clone-and-replace so a failed persist rolls back the in-memory state2525  // (COR-008). Without this, a quota-full or storage error leaves the2526  // module-level `entries` ahead of what's actually on disk.2527  const rollback = entries;2528  entries = [...entries, nextEntry];2529  try {2530    await persistEntries();2531  } catch (err) {2532    entries = rollback;2533    throw err;2534  }25352536  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)2537  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2538    checkBreach(nextEntry.password).then(async (count) => {2539      if (count > 0) {2540        const idx = entries.findIndex((e) => e.id === nextEntry.id);2541        if (idx !== -1) {2542          entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2543          await persistEntries();2544        }2545      }2546    });2547  }25482549  fireSyncTrigger("vault_change");25502551  return nextEntry;2552}25532554/**2555 * Run the passkey-merge pass on the live in-memory entries and persist if2556 * anything changed. Called immediately after any passkey registration so that2557 * a same-domain TOTP-only entry already in the vault gets consolidated right2558 * away instead of waiting until the next lock/unlock cycle.2559 */2560export async function consolidatePasskeyEntries(): Promise<void> {2561  if (!derivedKey) return;2562  const { merged } = mergePasskeyOnlyIntoLogins(entries);2563  if (merged) {2564    await persistEntries();2565  }2566}25672568export async function updateEntry(2569  id: string,2570  patch: Partial<VaultEntry>2571): Promise<void> {2572  if (!derivedKey) throw new Error("vault_locked");25732574  const idx = entries.findIndex((entry) => entry.id === id);2575  if (idx === -1 || entries[idx].deletedAt) {2576    throw new Error("entry_not_found");2577  }25782579  const current = entries[idx];2580  const sanitizedPatch = sanitizeEntryPatch(patch);2581  // Refresh passwordChangedAt only when the password value actually changes —2582  // a metadata-only update (title, tags, etc.) must not reset the age clock2583  // that drives the Pro health rotation recommendation.2584  const passwordChanged =2585    "password" in patch &&2586    typeof sanitizedPatch.password === "string" &&2587    sanitizedPatch.password !== current.password;2588  const stamp = monotonicEntryTimestamp(current.modifiedAt);2589  // When the password actually changes, push the outgoing one onto the history2590  // (newest first) so the user can reveal/copy/restore it later. We record the2591  // age it carried (passwordChangedAt, falling back to createdAt) rather than2592  // `now`, so the list reflects when each password was last in effect. Only2593  // capture a non-empty prior value — clearing a password leaves no secret to2594  // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)2595  // to avoid noise.2596  const priorPassword = current.password;2597  const shouldCapture =2598    passwordChanged &&2599    typeof priorPassword === "string" &&2600    priorPassword.length > 0 &&2601    current.passwordHistory?.[0]?.password !== priorPassword;2602  const nextHistory = shouldCapture2603    ? [2604        { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2605        ...(current.passwordHistory ?? []),2606      ].slice(0, PASSWORD_HISTORY_MAX)2607    : current.passwordHistory;2608  entries[idx] = {2609    ...current,2610    ...sanitizedPatch,2611    modifiedAt: stamp,2612    ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2613  };2614261526162617  // Card entries must retain their card number2618  if (entries[idx].type === "card" && !entries[idx].cardNumber) {2619    entries[idx] = current;2620    throw new Error("card number is required for card entries");2621  }26222623  // Roll the in-memory entry back to its pre-edit state if the disk write2624  // fails (e.g. storage quota). Without this the module-level `entries` array2625  // would hold an edit that never landed on disk, diverging from the vault2626  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.2627  try {2628    await persistEntries();2629  } catch (err) {2630    entries[idx] = current;2631    throw err;2632  }26332634  // Breach check when password changes: clear immediately so old breach doesn't linger,2635  // then async-check the new password and only re-set if it's also found.2636  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2637    entries[idx] = { ...entries[idx], breach: undefined };2638    await persistEntries();2639    const password = entries[idx].password;2640    if (password) {2641      checkBreach(password).then(async (count) => {2642        const i = entries.findIndex((e) => e.id === id);2643        if (i !== -1 && count > 0) {2644          entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2645          await persistEntries();2646        }2647      });2648    }2649  }26502651  fireSyncTrigger("vault_change");2652}26532654export async function deleteEntry(id: string): Promise<void> {2655  if (!derivedKey) throw new Error("vault_locked");26562657  const entry = entries.find((item) => item.id === id);2658  if (!entry || entry.deletedAt) {2659    throw new Error("entry_not_found");2660  }26612662  // Tombstone timestamp must be strictly greater than the entry's last2663  // modification so the deletion wins LWW against any in-flight edit, even2664  // under clock skew (SEV-2-E).2665  const previousModifiedAt = entry.modifiedAt;2666  const previousTombstone = tombstones.get(id);2667  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2668  entry.deletedAt = stamp;2669  entry.modifiedAt = stamp;2670  tombstones.set(id, stamp);2671  // Roll the tombstone back if the disk write fails, so a quota error doesn't2672  // leave the entry deleted in memory but alive on disk (SEV-3-A).2673  try {2674    await persistEntries();2675  } catch (err) {2676    delete entry.deletedAt;2677    entry.modifiedAt = previousModifiedAt;2678    if (previousTombstone === undefined) tombstones.delete(id);2679    else tombstones.set(id, previousTombstone);2680    throw err;2681  }2682  fireSyncTrigger("vault_change");2683}26842685export function getDeletedEntries(): VaultEntry[] {2686  if (!derivedKey) return [];2687  return entries.filter((e) => !!e.deletedAt);2688}26892690/** Return all durable deletion timestamps, including purged full entries. */2691export function getTombstoneManifest(): Record<string, number> {2692  if (!derivedKey) return {};2693  return Object.fromEntries(tombstones);2694}26952696/**2697 * Returns the vault's monotonic version — the `peach_last_modified_at` value2698 * stamped on every user-initiated write. Remote (sync) writes do NOT advance2699 * this version so a sync round never cascades into a second outbound sync.2700 */2701export async function getVaultVersion(): Promise<number> {2702  try {2703    const raw = await chrome.storage.local.get("peach_last_modified_at");2704    const v = (raw as Record<string, unknown>).peach_last_modified_at;2705    if (typeof v === "number") return v;2706    if (typeof v === "string" && v !== "") {2707      const n = Number.parseInt(v, 10);2708      return Number.isFinite(n) ? n : 0;2709    }2710    return 0;2711  } catch {2712    return 0;2713  }2714}27152716/**2717 * Apply a batch of remote entries + tombstones from a sync peer.2718 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when2719 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and2720 * persists with reason `sync_received` — does NOT fire the sync dispatcher, so2721 * applying a sync round cannot cascade into another outbound sync.2722 *2723 * `applied` counts entries that actually changed local state (new add,2724 * overwrite-by-newer, or new tombstone). `received` is the raw payload size2725 * the caller can surface in the sync result UI.2726 */2727export async function applyMergedRemoteEntries(2728  remoteEntries: VaultEntry[],2729  remoteTombstones: Record<string, number>,2730): Promise<{ received: number; applied: number; conflicts: number }> {2731  // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices2732  // concurrently edit different fields of the same entry, the one with the2733  // newer modifiedAt wins the whole entry and the other device's field edits2734  // are dropped silently. This is the deliberate trade-off for a simple,2735  // conflict-free merge — per-field CRDT merge is out of scope. Guidance for2736  // users: edit a given entry on one device at a time.2737  if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");27382739  const received = remoteEntries.length + Object.keys(remoteTombstones).length;2740  let applied = 0;2741  let conflicts = 0;27422743  const byId = new Map<string, VaultEntry>();2744  for (const e of entries) byId.set(e.id, e);27452746  const mergeNow = Date.now();2747  const remoteTombstoneTimes = new Map<string, number>();2748  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2749    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2750      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2751    }2752  }27532754  for (const remote of remoteEntries) {2755    const sanitized = sanitizeRestoredEntry(remote);2756    if (!sanitized) continue;2757    // HV-1a: a far-future remote modifiedAt must not permanently pin a2758    // malicious value against honest local edits. Clamp BEFORE any2759    // comparison against local timestamps.2760    sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2761    if (typeof sanitized.deletedAt === "number") {2762      sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2763    }2764    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2765    const localTombstoneTs = tombstones.get(sanitized.id);2766    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2767    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2768      continue;2769    }2770    const local = byId.get(sanitized.id);2771    if (!local) {2772      entries.push(sanitized);2773      byId.set(sanitized.id, sanitized);2774      tombstones.delete(sanitized.id);2775      applied += 1;2776    } else if (sanitized.modifiedAt > local.modifiedAt) {2777      const idx = entries.findIndex((e) => e.id === local.id);2778      if (idx !== -1) {2779        entries[idx] = sanitized;2780        byId.set(sanitized.id, sanitized);2781        tombstones.delete(sanitized.id);2782        applied += 1;2783        if (!local.deletedAt) conflicts += 1;2784      }2785    }2786  }27872788  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2789    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2790    // HV-1a/1c: clamp the tombstone timestamp before every comparison.2791    const deletedAt = remoteTombstoneTimes.get(id)!;2792    const knownTombstone = tombstones.get(id);2793    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2794    const local = byId.get(id);2795    if (!local) {2796      tombstones.set(id, deletedAt);2797      applied += 1;2798      continue;2799    }2800    if (local.deletedAt && local.deletedAt >= deletedAt) continue;2801    if (deletedAt < local.modifiedAt) continue;2802    const idx = entries.findIndex((e) => e.id === id);2803    if (idx !== -1) {2804      entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2805      tombstones.set(id, deletedAt);2806      applied += 1;2807    }2808  }28092810  if (applied > 0 && vaultMode === "real") {2811    await persistEntries("sync_received");2812    buildDomainIndexSync(entries);2813    // An open popup holds the entries it loaded at mount and polls nothing, so2814    // a background round (the relay listener responding to a peer's push, or the2815    // alarm-driven dial) otherwise lands silently and the user sees stale data2816    // until they reopen. Best-effort: no popup listening is the normal case.2817    try {2818      void chrome.runtime2819        .sendMessage({ type: "VAULT_ENTRIES_CHANGED" })2820        .catch(() => undefined);2821    } catch {2822      // messaging unavailable2823    }2824  }2825  return { received, applied, conflicts };2826}28272828export async function restoreEntry(id: string): Promise<void> {2829  if (!derivedKey) throw new Error("vault_locked");2830  const entry = entries.find((e) => e.id === id && e.deletedAt);2831  if (!entry) throw new Error("deleted entry not found");2832  const previousDeletedAt = entry.deletedAt;2833  const previousModifiedAt = entry.modifiedAt;2834  const previousTombstone = tombstones.get(id);2835  delete entry.deletedAt;2836  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2837    entry.modifiedAt,2838    previousTombstone ?? 0,2839  ));2840  tombstones.delete(id);2841  try {2842    await persistEntries();2843  } catch (err) {2844    entry.deletedAt = previousDeletedAt;2845    entry.modifiedAt = previousModifiedAt;2846    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2847    throw err;2848  }2849  buildDomainIndexSync(entries);2850}28512852export async function purgeEntry(id: string): Promise<void> {2853  if (!derivedKey) throw new Error("vault_locked");2854  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2855  if (idx === -1) throw new Error("deleted entry not found");2856  const entry = entries[idx]!;2857  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2858  entries.splice(idx, 1);2859  await persistEntries();2860}28612862/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */2863export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2864  if (!derivedKey || retentionMs === 0) return 0;2865  const cutoff = Date.now() - retentionMs;2866  const before = entries.length;2867  for (const entry of entries) {2868    if (entry.deletedAt && entry.deletedAt < cutoff) {2869      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2870    }2871  }2872  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2873  const purged = before - entries.length;2874  if (purged > 0) await persistEntries();2875  return purged;2876}28772878export function queryDomain(domain: string): VaultEntry[] {2879  return domainIndex.get(domain) ?? [];2880}28812882export function getEntryById(id: string): VaultEntry | undefined {2883  return entries.find((entry) => entry.id === id && !entry.deletedAt);2884}28852886async function persistSlotA(2887  reason: VaultSnapshot["reason"] = "local_save",2888): Promise<StoredVault> {2889  if (!derivedKey) throw new Error("vault_locked");2890  const epoch = vaultEpoch;2891  const throwIfStale = () => {2892    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2893  };28942895  const slotA = await loadSlot(SLOT_A);2896  throwIfStale();2897  if (!slotA) throw new Error("missing slot A");28982899  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures2900  // are swallowed by takeVaultSnapshot — history must never block a save.2901  await takeVaultSnapshot(reason);2902  throwIfStale();29032904  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2905  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2906  throwIfStale();29072908  // Preserve existing recovery escrow fields.2909  const nextSlotA: StoredVault = {2910    ...slotA,2911    nonce,2912    ciphertext,2913    tag,2914    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot2915  };29162917  // Slot A (the real vault) is now committed. Wrap every later write so a2918  // mid-persist failure (e.g. a storage error after slot A landed) restores the2919  // prior slot A before rethrowing. Without this, a "failed" save that already2920  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/2921  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces2922  // as a duplicate on the next unlock (observed under storage-quota pressure).2923  let slotACommitted = false;2924  try {2925    throwIfStale();2926    await writeSlot(SLOT_A, nextSlotA);2927    slotACommitted = true;2928    throwIfStale();29292930    // When duress is not configured, keep slot B size-matched with slot A by2931    // regenerating its noise. When duress IS configured, leave slot B alone —2932    // we don't have the decoy key to re-encrypt it. This is a known trade-off.2933    if (!duressConfigured) {2934      throwIfStale();2935      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2936      throwIfStale();2937    }2938    // Only advance the vault version for locally-originated writes. Entries2939    // applied from a sync peer ("sync_received") must NOT bump the counter —2940    // otherwise applying a remote round makes us look newer than the peer and2941    // triggers a redundant outbound sync, defeating the no-op optimisation2942    // and risking a ping-pong between two devices (SEV-2-C).2943    if (reason !== "sync_received") {2944      throwIfStale();2945      await stampLocalVaultMutation();2946      throwIfStale();2947    }2948  } catch (err) {2949    // Best-effort restore — the prior slot A is smaller-or-equal, so this write2950    // fits even when the failure was a quota overflow.2951    if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2952    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2953    throw err;2954  }29552956  return nextSlotA;2957}29582959async function persistSlotB(): Promise<void> {2960  if (!derivedKey) throw new Error("vault_locked");29612962  const slotB = await loadSlot(SLOT_B);2963  if (!slotB) throw new Error("missing slot B");29642965  // Decoy plaintext always reports duressConfigured=false — the decoy user2966  // sees a clean slate. persistSlotB only runs in decoy mode, which only2967  // exists once duress is configured, so pad to the fixed duress ceiling to2968  // keep slot B's length equal to slot A's (audit MEDIUM — deniability).2969  const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2970  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");29712972  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2973  // Symmetric with persistSlotA: if the version stamp fails after the decoy2974  // slot committed, restore the prior slot B so a failed decoy-mode save can't2975  // leave disk ahead of the in-memory rollback.2976  try {2977    await stampLocalVaultMutation();2978  } catch (err) {2979    await writeSlot(SLOT_B, slotB).catch(() => {});2980    throw err;2981  }2982}29832984async function persistEntries(2985  reason: VaultSnapshot["reason"] = "local_save",2986): Promise<void> {2987  if (!derivedKey) throw new Error("vault_locked");29882989  // Reconcile direct soft-delete callers while restoring the previous map if2990  // storage rejects the save.2991  const previousTombstones = tombstones;2992  tombstones = new Map(tombstones);2993  reconcileTombstones();2994  try {2995    if (vaultMode === "decoy") {2996      await persistSlotB();2997    } else {2998      await persistSlotA(reason);2999    }3000  } catch (err) {3001    tombstones = previousTombstones;3002    throw err;3003  }3004  buildDomainIndexSync(entries);3005  await refreshLockedSyncState();3006}30073008// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage3009// boundary. Schemeless values (e.g. "github.com") are inert and kept — they3010// pre-date this guard in existing data. Unparseable values are kept: the3011// reviewed render paths never execute them (favicons via _favicon/, domains3012// via tldts); this is defense-in-depth against future sinks.3013function allowHttpUrl(url: string | undefined): string {3014  const trimmed = url?.trim() ?? "";3015  if (!trimmed) return "";3016  try {3017    const parsed = new URL(trimmed);3018    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";3019  } catch {3020    // No scheme to reject.3021  }3022  return trimmed;3023}30243025function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {3026  if (!Array.isArray(values)) return undefined;3027  const seen = new Set<string>();3028  const cleaned: string[] = [];3029  for (const value of values) {3030    const trimmed = typeof value === "string" ? value.trim() : "";3031    if (!trimmed) continue;3032    if (!allowHttpUrl(trimmed)) continue;3033    const key = trimmed.toLowerCase();3034    if (seen.has(key)) continue;3035    seen.add(key);3036    cleaned.push(trimmed);3037  }3038  return cleaned.length > 0 ? cleaned : undefined;3039}30403041function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {3042  const title = entry.title.trim();3043  const type: VaultEntry["type"] = entry.type ?? "login";30443045  if (!title) throw new Error("title is required");30463047  if (entry.passkey) {3048    const p = entry.passkey;3049    if (3050      !p.rpId ||3051      !p.credentialId ||3052      !p.publicKeyCose ||3053      !p.userHandle ||3054      !p.userName ||3055      (p.algorithm !== -7 && p.algorithm !== -8)3056    ) {3057      throw new Error("invalid passkey credential");3058    }3059  }3060  if (type === "card") {3061    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {3062      throw new Error("cardholder name and card number are required");3063    }3064  }30653066  return {3067    type,3068    title,3069    username: type === "login" ? entry.username?.trim() ?? "" : undefined,3070    email: type === "login" ? entry.email?.trim() || undefined : undefined,3071    password: type === "login" ? entry.password : undefined,3072    url: type === "login" ? allowHttpUrl(entry.url) : undefined,3073    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,3074    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,3075    // PCH-01-019: preserve non-default TOTP params through batch-add/import so3076    // they are not silently stripped (they fall back to SHA-1/6/30 when absent).3077    totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,3078    totpDigits: type === "login" ? entry.totpDigits : undefined,3079    totpPeriod: type === "login" ? entry.totpPeriod : undefined,3080    notes: entry.notes?.trim() || undefined,3081    breach: type === "login" ? entry.breach : undefined,3082    // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags3083    // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here.3084    canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,3085    tags: sanitizeTags(entry.tags),3086    passkey: entry.passkey,3087    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,3088    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,3089    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,3090    expYear: type === "card" ? entry.expYear?.trim() : undefined,3091    cvc: type === "card" ? entry.cvc?.trim() : undefined,3092    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,3093    network: type === "card" ? entry.network : undefined,3094    last4: type === "card" ? entry.last4?.trim() : undefined,3095  };3096}30973098function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {3099  const nextPatch: Partial<VaultEntry> = {};31003101  if ("type" in patch) {3102    nextPatch.type = patch.type;3103  }31043105  if ("title" in patch) {3106    const title = patch.title?.trim() ?? "";3107    if (!title) throw new Error("title is required");3108    nextPatch.title = title;3109  }31103111  if ("url" in patch) {3112    nextPatch.url = allowHttpUrl(patch.url);3113  }31143115  if ("additionalUrls" in patch) {3116    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];3117    const seen = new Set<string>();3118    const cleaned: string[] = [];3119    for (const value of incoming) {3120      const trimmed = typeof value === "string" ? value.trim() : "";3121      if (!trimmed || !allowHttpUrl(trimmed)) continue;3122      const key = trimmed.toLowerCase();3123      if (seen.has(key)) continue;3124      seen.add(key);3125      cleaned.push(trimmed);3126    }3127    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;3128  }31293130  if ("username" in patch) {3131    nextPatch.username = patch.username?.trim() ?? "";3132  }31333134  if ("email" in patch) {3135    nextPatch.email = patch.email?.trim() || undefined;3136  }31373138  if ("password" in patch) {3139    const password = patch.password ?? "";3140    // Allow clearing a password (e.g. on passkey-only entries)3141    nextPatch.password = password || undefined;3142  }31433144  if ("totp" in patch) {3145    nextPatch.totp = patch.totp?.trim() || undefined;3146  }31473148  if ("totpAlgorithm" in patch) {3149    nextPatch.totpAlgorithm = patch.totpAlgorithm;3150  }31513152  if ("totpDigits" in patch) {3153    nextPatch.totpDigits = patch.totpDigits;3154  }31553156  if ("totpPeriod" in patch) {3157    nextPatch.totpPeriod = patch.totpPeriod;3158  }31593160  if ("notes" in patch) {3161    nextPatch.notes = patch.notes?.trim() || undefined;3162  }31633164  if ("breach" in patch) {3165    nextPatch.breach = patch.breach;3166  }31673168  if ("canary" in patch) {3169    // PCH-02-001: route update patches through the same canary whitelist used3170    // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.3171    nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;3172  }31733174  if ("passkey" in patch) {3175    // A patch with `passkey: undefined` removes the credential from the entry3176    // (used by "remove passkey" without deleting the whole entry).3177    if (patch.passkey === undefined || patch.passkey === null) {3178      nextPatch.passkey = undefined;3179    } else {3180      const sanitized = sanitizePasskey(patch.passkey);3181      if (!sanitized) throw new Error("invalid passkey credential");3182      nextPatch.passkey = sanitized;3183    }3184  }31853186  if ("tags" in patch) {3187    // PCH-02-001: bound tags exactly like both import funnels.3188    nextPatch.tags = sanitizeTags(patch.tags);3189  }31903191  if ("cardholderName" in patch) {3192    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;3193  }3194  if ("cardNumber" in patch) {3195    const n = patch.cardNumber ?? "";3196    nextPatch.cardNumber = n || undefined;3197  }3198  if ("expMonth" in patch) {3199    nextPatch.expMonth = patch.expMonth?.trim() || undefined;3200  }3201  if ("expYear" in patch) {3202    nextPatch.expYear = patch.expYear?.trim() || undefined;3203  }3204  if ("cvc" in patch) {3205    nextPatch.cvc = patch.cvc?.trim() || undefined;3206  }3207  if ("cardNickname" in patch) {3208    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;3209  }3210  if ("network" in patch) {3211    nextPatch.network = patch.network;3212  }3213  if ("last4" in patch) {3214    nextPatch.last4 = patch.last4?.trim() || undefined;3215  }32163217  return nextPatch;3218}32193220function normalizeEntry(entry: VaultEntry): VaultEntry {3221  const base: VaultEntry = {3222    id: entry.id,3223    type: entry.type,3224    title: entry.title,3225    username: entry.username,3226    email: entry.email,3227    password: entry.password,3228    url: entry.url,3229    additionalUrls:3230      "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3231        ? entry.additionalUrls3232        : undefined,3233    totp: entry.totp,3234    // PCH-01-019: carry TOTP params through the normalize/restore path too.3235    totpAlgorithm: entry.totpAlgorithm,3236    totpDigits: entry.totpDigits,3237    totpPeriod: entry.totpPeriod,3238    notes: entry.notes,3239    breach: entry.breach,3240    // Preserve the password-age clock across lock/unlock. Without this the3241    // field is dropped on every decode and the advanced-health "aged password"3242    // signal silently resets to createdAt on each unlock (SEV-2-A).3243    passwordChangedAt:3244      "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3245    // Preserve past-password history across lock/unlock. normalizeEntry is a3246    // field whitelist, so an unlisted field is silently dropped on every decode3247    // (same class as the passwordChangedAt SEV-2-A above).3248    passwordHistory:3249      "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3250        ? entry.passwordHistory3251        : undefined,3252    canary: entry.canary,3253    passkey: entry.passkey,3254    createdAt: entry.createdAt,3255    modifiedAt: entry.modifiedAt,3256    deletedAt: entry.deletedAt,3257    tags: entry.tags,3258    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3259    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3260    expMonth: "expMonth" in entry ? entry.expMonth : undefined,3261    expYear: "expYear" in entry ? entry.expYear : undefined,3262    cvc: "cvc" in entry ? entry.cvc : undefined,3263    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3264    network: "network" in entry ? entry.network : undefined,3265    last4: "last4" in entry ? entry.last4 : undefined,3266  };32673268  return base;3269}327032713272// ---------------------------------------------------------------------------3273// Deduplication3274// ---------------------------------------------------------------------------32753276function entryDupeKey(e: VaultEntry): string {3277  if (e.type === "note") return `note|${e.title.toLowerCase()}`;3278  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins3279  // on different sites that happen to share a username are still distinct;3280  // two passkey credentials for the same RP+user are duplicates regardless3281  // of any login fields they may also carry.3282  if (e.passkey) {3283    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3284  }3285  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3286}32873288/** Returns the number of duplicate entries (those that would be removed). */3289export function countDuplicates(): number {3290  const best = new Map<string, VaultEntry>();3291  let dupeCount = 0;3292  for (const e of entries.filter((e) => !e.deletedAt)) {3293    const key = entryDupeKey(e);3294    const existing = best.get(key);3295    if (!existing) {3296      best.set(key, e);3297    } else {3298      dupeCount++;3299      // Keep the more recently modified one in the map3300      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3301        best.set(key, e);3302      }3303    }3304  }3305  return dupeCount;3306}33073308/**3309 * Soft-deletes duplicate entries, keeping the most recently modified copy of3310 * each group. Returns the number of entries moved to trash.3311 */3312export async function deduplicateEntries(): Promise<number> {3313  if (!derivedKey) throw new Error("vault_locked");3314  // Dedup reshapes slot B permanently under the decoy key, which is3315  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.3316  if (vaultMode === "decoy") {3317    throw new Error("deduplicate disabled in decoy mode");3318  }33193320  const best = new Map<string, VaultEntry>();3321  for (const e of entries.filter((en) => !en.deletedAt)) {3322    const key = entryDupeKey(e);3323    const existing = best.get(key);3324    if (!existing) {3325      best.set(key, e);3326    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3327      best.set(key, e);3328    }3329  }33303331  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3332  let removed = 0;3333  entries = entries.map((e) => {3334    if (!e.deletedAt && !keepIds.has(e.id)) {3335      removed++;3336      const deletedAt = monotonicEntryTimestamp(Math.max(3337        e.modifiedAt,3338        tombstones.get(e.id) ?? 0,3339      ));3340      tombstones.set(e.id, deletedAt);3341      return { ...e, deletedAt };3342    }3343    return e;3344  });33453346  if (removed > 0) await persistEntries();3347  return removed;3348}33493350// ---------------------------------------------------------------------------3351// Vault history — snapshot BEFORE every vault write so the saved blob is3352// what is about to be lost, not what was just written. Capped at 5. The3353// stored blobs are AES-256-GCM ciphertext under the master password, so3354// history adds zero security surface.3355// ---------------------------------------------------------------------------33563357const HISTORY_KEY = "peach_vault_history";3358const HISTORY_MAX = 5;33593360// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the3361// plaintext snapshot field cannot reveal exact vault size or single-entry change3362// deltas. Buckets widen as the vault grows so absolute leakage stays roughly3363// proportional rather than exact.3364function bucketEntryCount(n: number): number {3365  if (n <= 0) return 0;3366  if (n <= 10) return 10;3367  if (n <= 25) return 25;3368  if (n <= 50) return 50;3369  if (n <= 100) return 100;3370  if (n <= 250) return 250;3371  if (n <= 500) return 500;3372  return Math.ceil(n / 500) * 500;3373}33743375/**3376 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE3377 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost3378 * state. Identical-to-most-recent blobs are skipped to avoid filling history3379 * with no-op writes.3380 */3381export async function takeVaultSnapshot(3382  reason: VaultSnapshot["reason"],3383): Promise<void> {3384  // The history store is real-mode state by construction, so it must not be3385  // written from a decoy session either. The guard lives here rather than only3386  // at the call sites so the invariant survives a new caller (PB-26-915).3387  if (vaultMode === "decoy") return;3388  try {3389    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3390    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3391    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];33923393    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,3394    // pre-createVault). createVault skips snapshots intentionally — there is3395    // no prior state to preserve.3396    if (!slotA) return;33973398    const vault_a = JSON.stringify(slotA);3399    const vault_b = JSON.stringify(slotB ?? slotA);34003401    // Best-effort entry count: only meaningful when the in-memory state is3402    // unlocked AND in real mode. Decoy mode would leak the decoy entry3403    // count; locked mode has no entries to count. Both fall through to null.3404    // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage3405    // .local sits next to the AES-GCM blobs in plaintext, so an exact count would3406    // leak vault size and per-write deltas (the metadata padding hides this).3407    let entryCount: number | null = null;3408    if (derivedKey && vaultMode === "real") {3409      try {3410        entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3411      } catch {3412        entryCount = null;3413      }3414    }34153416    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3417    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3418      ? existingRaw3419      : { snapshots: [] };34203421    // Deduplication: if the most recent snapshot has byte-identical blobs,3422    // skip — saves the same vault N times in a row would otherwise burn the3423    // entire history window with no recovery value.3424    const latest = history.snapshots[0];3425    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3426      return;3427    }34283429    const snapshot: VaultSnapshot = {3430      id: crypto.randomUUID(),3431      savedAt: Date.now(),3432      reason,3433      vault_a,3434      vault_b,3435      entryCount,3436    };34373438    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3439    await chrome.storage.local.set({ [HISTORY_KEY]: history });3440  } catch (err) {3441    // Non-fatal — history must never block a save. A failed snapshot just3442    // means the user loses one rung of the safety ladder for this write.3443    // M4/QUA-006: log so the failure is visible; if it looks like a quota3444    // problem, notify the popup so the user can prune history themselves.3445    console.warn('[Peach] takeVaultSnapshot failed:', err);3446    if (String(err).includes('QUOTA') || String(err).includes('quota')) {3447      try {3448        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3449      } catch {3450        // popup may be closed; surfaced via console.warn above3451      }3452    }3453  }3454}34553456/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */3457export async function clearVaultHistory(): Promise<void> {3458  try {3459    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3460  } catch {3461    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().3462  }3463}34643465/** Read all snapshots, newest first, for the Settings vault history view. */3466export async function getVaultHistory(): Promise<VaultSnapshot[]> {3467  if (vaultMode === "decoy") return [];3468  const raw = await chrome.storage.local.get(HISTORY_KEY);3469  const value = (raw as Record<string, unknown>)[HISTORY_KEY];3470  return sanitizeHistory(value);3471}34723473/**3474 * Replace the on-disk vault with the chosen snapshot's blobs and lock the3475 * vault. The current state is itself snapshotted before replacement.3476 */3477export async function restoreVaultSnapshot(3478  snapshotId: string,3479): Promise<{ ok: true } | { ok: false; error: string }> {3480  if (vaultMode === "decoy") return { ok: false, error: "vault_locked" };3481  const history = await getVaultHistory();3482  const snapshot = history.find((s) => s.id === snapshotId);3483  if (!snapshot) return { ok: false, error: "snapshot_not_found" };34843485  const restoredA = parseHistorySlot(snapshot.vault_a);3486  const restoredB = parseHistorySlot(snapshot.vault_b);3487  if (!restoredA || !restoredB) return { ok: false, error: "snapshot_corrupt" };34883489  await takeVaultSnapshot("restore");3490  const current = await chrome.storage.local.get([SLOT_A, SLOT_B]);3491  const previousA = (current as Record<string, unknown>)[SLOT_A];3492  const previousB = (current as Record<string, unknown>)[SLOT_B];3493  try {3494    await chrome.storage.local.set({ [SLOT_A]: restoredA, [SLOT_B]: restoredB });3495    await stampLocalVaultMutation();3496  } catch {3497    try {3498      await chrome.storage.local.set({ [SLOT_A]: previousA, [SLOT_B]: previousB });3499    } catch {3500      // Preserve the restore failure if storage cannot complete the rollback.3501    }3502    return { ok: false, error: "snapshot_corrupt" };3503  }35043505  await lock();3506  return { ok: true };3507}35083509/**3510 * Rotate the vault's sync identity by generating a FRESH ROOT KEY and3511 * re-wrapping everything under it (MV-8). Because every sync credential —3512 * vault_id, the Noise static keypair, the Ed25519 manifest signing key,3513 * and the per-entry envelope keys — derives from the root key, rotation3514 * makes a lost/stolen device's retained key material useless. All paired3515 * devices must re-pair afterwards.3516 *3517 * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from3518 * the root key, so rotation INVALIDATES the old recovery phrase. The flow3519 * sets peach_recovery_pending so the popup re-presents the new words for3520 * confirmation, same as the initial recovery-word confirmation path.3521 */3522export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3523  if (!derivedKey || vaultMode !== "real") {3524    return { ok: false, error: "vault_locked" };3525  }3526  const slotA = await loadSlot(SLOT_A);3527  if (!slotA) return { ok: false, error: "missing_slot_a" };35283529  // 1. Fresh root key + recovery escrow (new words).3530  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3531    await generateAndWrapRootKey();35323533  // 2. Re-wrap the new root key under the current password-derived key.3534  const newRootKey = unhex(rawRootKey);3535  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);35363537  // 3. Snapshot, then single atomic slot write — both escrow wrappings of3538  //    the new root key land together (same transactional rule as3539  //    changePassword). Entries are untouched: they are sealed under the3540  //    password-derived key, not the root key.3541  await takeVaultSnapshot("local_save");3542  const nextSlotA: StoredVault = {3543    ...slotA,3544    recovery_salt: recoverySalt,3545    recovery_wrapped_root_key: wrappedRootKey,3546    recovery_nonce: wrappedNonce,3547    recovery_tag: wrappedTag,3548    ...pwRoot,3549  };3550  await writeSlot(SLOT_A, nextSlotA);35513552  // 4. Swap in memory only after disk committed.3553  if (rootKey) zeroKey(rootKey);3554  rootKey = newRootKey;35553556  // 4b. The identity's root wrap was encrypted under the dead root. Without3557  //     this the identity survives only until the unlock key also changes.3558  await notifyIdentityKeysRotated();35593560  // 5. Old peers' identities derive from the dead root key — drop them all,3561  // with their rendezvous secrets (PB-26-213: rotation clears the map, so3562  // there is nothing to rewrap under the new key).3563  await clearPairedDevices();3564  await clearPairSecrets();35653566  // 6. Force re-presentation of the NEW recovery words on next popup view.3567  await setRecoveryConfirmPending(); // same flag the create-vault flow uses35683569  await stampLocalVaultMutation();3570  return { ok: true };3571}35723573/**3574 * Change the master password. Re-encrypts slot A with a fresh salt + new3575 * derived key. Slot B (duress or noise) is left untouched when duress is3576 * configured — it uses an independent key derived from the duress password —3577 * and regenerated as noise when duress is not configured.3578 */3579export async function changePassword(3580  currentPassword: string,3581  newPassword: string3582): Promise<boolean> {3583  const epoch = vaultEpoch;3584  const stale = () => epoch !== vaultEpoch;3585  if (!derivedKey || vaultMode !== "real") return false;35863587  // Verify current password before allowing any change3588  const valid = await verifyMainPassword(currentPassword);3589  if (!valid) return false;35903591  const slotA = await loadSlot(SLOT_A);3592  if (!slotA) return false;3593  if (!rootKey) return false;35943595  // Derive fresh key from new password + new random salt3596  const newSalt = generateSalt();3597  const newKey = await deriveKey(newPassword, newSalt);3598  if (stale()) {3599    zeroKey(newKey);3600    return false;3601  }36023603  // Re-encrypt the in-memory entries with the new key3604  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3605  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");36063607  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey3608  // under recovery words and remain untouched — recovery phrase keeps working3609  // across password changes (H4/COR-002).3610  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);36113612  const finalSlotA: StoredVault = {3613    ...slotA,3614    salt: hex(newSalt),3615    nonce,3616    ciphertext,3617    tag,3618    ...pwRoot,3619  };36203621  // Snapshot the OLD-key blob before overwriting it. If the user later3622  // restores from this snapshot they will need the OLD password — the3623  // post-restore lock surfaces that immediately.3624  await takeVaultSnapshot("local_save");36253626  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in3627  // one storage transaction. An SW kill before this point leaves the old3628  // slot intact; after this point everything tied to the new password is3629  // already on disk together (M6/COR-004).3630  await writeSlot(SLOT_A, finalSlotA);3631  if (stale()) {3632    zeroKey(newKey);3633    return false;3634  }36353636  // Swap the in-memory key synchronously before any further await. If we3637  // threw between the Slot A write and this swap, the next persistEntries()3638  // would overwrite Slot A with old-key ciphertext, locking the user out.3639  zeroKey(derivedKey);3640  derivedKey = newKey;36413642  // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,3643  // the next service-worker restore decrypts the new-key Slot A with the stale3644  // key, fails tryDecryptSlotResult, and silently drops the user to a lock3645  // screen. Re-escrow the new key so restore survives a password change.3646  if (stale()) {3647    await lock();3648    return false;3649  }3650  await saveSessionKey(newKey, "real", epoch);3651  if (stale()) return false;36523653  // The identity envelope holds one wrap per recovering key. Swapping the3654  // unlock key kills the local wrap, so re-wrap now or the next key swap3655  // orphans the identity -- and until then the old password still opens it.3656  await notifyIdentityKeysRotated();3657  if (stale()) return false;36583659  // Regenerate noise for slot B when duress is not in use. Runs after the3660  // key swap so a failure here is a disposable noise regeneration, not a3661  // lockout.3662  if (!duressConfigured) {3663    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3664  }3665  await clearPinUnlockStorage();3666  await stampLocalVaultMutation();36673668  return true;3669}