vault.ts

132.6 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";16// Static, not `await import()`: runtime import() throws in an MV3 service17// worker. Neither module imports vault.ts back, so there is no cycle to break.18import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21  rootKeyToWords,22  wordsToRootKeyMaterial,23  deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26  AccentColorName,27  Canary,28  ManualSummonRecord,29  PasskeyData,30  SavedIdentity,31  StoredVault,32  VaultEntry,33  VaultHistory,34  VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import {39  clearRecoveryConfirmPending,40  setRecoveryConfirmPending,41} from "./recovery-pending";42import {43  clearPairSecrets,44  dropSessionCache,45  reconcilePairSecrets,46  setSessionAllowed,47} from "./pair-secrets";4849type EntryDraft = Omit<50  VaultEntry,51  "id" | "createdAt" | "modifiedAt" | "deletedAt"52>;5354// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B55// either holds the decoy vault (when duress is configured) or random noise56// that looks identical at rest. No external indicator is stored — duress57// configuration is recorded only inside the encrypted payload of slot A.58const SLOT_A = "peach_vault_a";59const SLOT_B = "peach_vault_b";6061// Plaintext is padded to the next multiple of this size before encryption.62// This coarsens ciphertext-length leakage into 1 KiB buckets.63const PAD_BLOCK = 1024;6465// How many past passwords to retain per entry. Generous enough to be useful as66// a recovery aid while bounding the secret material we hold (and the ciphertext67// growth) for an entry whose password is rotated frequently.68const PASSWORD_HISTORY_MAX = 50;6970// When duress is configured, BOTH slots are padded to this fixed ceiling so71// their ciphertext lengths are identical and cannot reveal that a hidden decoy72// vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably73// holds a very large vault (thousands of entries incl. passkeys). The decoy74// slot can't be re-encrypted on every real-vault save (the real session has no75// decoy key), so a fixed ceiling — rather than size-matching on each write — is76// what keeps the two slots equal. A real vault whose plaintext exceeds this77// would grow past the ceiling and re-expose the bit; that is an extreme,78// documented edge well beyond normal use.79const DURESS_SLOT_SIZE = 262_144;8081let derivedKey: Uint8Array | null = null;82// 32-byte random root key, separate from the derivedKey used for vault encryption.83// The root key is wrapped (encrypted) under the recovery key derived from the84// 12-word phrase. When the user changes their password, only the wrapping changes;85// the root key and recovery words remain stable.86let rootKey: Uint8Array | null = null;87let entries: VaultEntry[] = [];88// Tombstones outlive their full entry records so a peer that was offline89// during a purge cannot reintroduce an old ciphertext envelope. This map is90// serialized only inside the encrypted vault payload.91let tombstones = new Map<string, number>();92let domainIndex: Map<string, VaultEntry[]> = new Map();93let vaultMode: "real" | "decoy" = "real";94// Cached post-unlock view of whether the real vault records that duress is95// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock96// this stays false because we never see slot A's plaintext.97let duressConfigured = false;9899// Set to a corruption reason during the last unlock() attempt when both slots100// decrypted but failed schema validation. Consumed and cleared by101// consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted102// distinctly from wrong-password (M7/COR-007).103//104// NOTE: module-level state — if vault.ts is ever moved to a worker context,105// this must be replaced with a structured return value from unlock() or a106// postMessage channel. The signal is invisible across module-instance107// boundaries.108let lastUnlockFailureReason: string | null = null;109110export function consumeLastUnlockFailureReason(): string | null {111  const r = lastUnlockFailureReason;112  lastUnlockFailureReason = null;113  return r;114}115116// ---------------------------------------------------------------------------117// Settings (persisted inside the encrypted vault store as a sidecar key)118// ---------------------------------------------------------------------------119120export type TextSize = "small" | "medium" | "large";121122export interface VaultSettings {123  accentColor?: AccentColorName;124  theme?: "dark" | "light";125  autoLockMinutes?: number;126  clipboardClearSeconds?: number;127  lockOnClose?: boolean;128  breachMonitoring?: boolean;129  passkeyProviderEnabled?: boolean;130  trashRetentionDays?: number;131  manualSummonDomains?: Record<string, ManualSummonRecord>;132  manualSummonSalt?: string;133  declinedCardFingerprints?: string[];134135  /** Metadata is bounded because it remains outside the encrypted vault blob. */136  metadataVersion?: 1;137  /**138   * Text size scale for body copy and entry titles. Stops at "large" by139   * design — XL clips dense screens (Vault, Health) inside the fixed140   * 330×480 popup surface.141   */142  textSize?: TextSize;143  /**144   * When true, all entrance/exit transitions and ambient animations are145   * collapsed to instant. Honoured alongside the user's OS-level146   * `prefers-reduced-motion`.147   */148  reduceMotion?: boolean;149  /** Whether this vault has a local PIN unlock wrapper configured. */150  pinEnabled?: boolean;151  /** Whether the lock screen should open on PIN instead of the password. */152  pinPreferred?: boolean;153  /**154   * User overrides for keyboard shortcuts. Keys are ShortcutAction names155   * (see src/shared/shortcuts.ts). Missing actions fall through to156   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the157   * canonical lookup. Storing only the deltas keeps the vault payload158   * small and lets default updates land for users who never rebind.159   */160  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;161}162163interface StoredPinWrap {164  version: 1;165  salt: string;166  nonce: string;167  ciphertext: string;168  tag: string;169}170171const PIN_WRAP_STORAGE = "peach_pin_wrap";172const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";173const PIN_MIN_LENGTH = 4;174const PIN_MAX_LENGTH = 12;175const PIN_MAX_ATTEMPTS = 3;176177let settings: VaultSettings = {};178const CARD_DECLINE_MAX = 256;179/** Hex length of a keyed card-decline digest. Legacy records are 16 hex. */180const CARD_DECLINE_DIGEST_HEX = 32;181182async function metadataDigest(value: string, key: string): Promise<string> {183  const bytes = new TextEncoder().encode(`${key}:${value}`);184  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));185  return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");186}187188let inMemoryPinWrap: StoredPinWrap | null = null;189let inMemoryPinFailedAttempts = 0;190let sessionPinStorageFailed = false;191192export function getSettings(): VaultSettings {193  return settings;194}195196export async function ensureSettingsLoaded(): Promise<VaultSettings> {197  await loadSettings();198  return settings;199}200201export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {202  await loadSettings();203  // LV-7: cheap insurance against prototype pollution if a future caller ever204  // feeds parsed external JSON here (native spread keeps __proto__ an own-205  // property today, but Object.assign-style consumers and JSON revivers have206  // been burned before).207  const safe = Object.fromEntries(208    Object.entries(patch).filter(209      ([k]) => !["__proto__", "constructor", "prototype"].includes(k),210    ),211  ) as Partial<VaultSettings>;212  settings = { ...settings, ...safe };213  await persistSettings();214}215216function isValidPin(pin: string): boolean {217  return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);218}219220/** Set up a local PIN wrapper after proving the current master password. */221export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {222  if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;223  if (!(await verifyMainPassword(currentPassword))) return false;224225  const salt = generateSalt();226  const pinKey = await deriveKey(pin, salt);227  try {228    const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");229    await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });230    await updateSettings({ pinEnabled: true });231    return true;232  } finally {233    zeroKey(pinKey);234  }235}236237async function clearPinUnlockStorage(): Promise<void> {238  inMemoryPinWrap = null;239  inMemoryPinFailedAttempts = 0;240  sessionPinStorageFailed = false;241  if (chrome.storage.session) {242    await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);243  }244  await updateSettings({ pinEnabled: false });245}246247/** Remove the PIN wrapper after proving the current master password. */248export async function disablePinUnlock(currentPassword: string): Promise<boolean> {249  if (!derivedKey || vaultMode !== "real") return false;250  if (!(await verifyMainPassword(currentPassword))) return false;251  await clearPinUnlockStorage();252  return true;253}254255/** Unlock the real vault from its locally stored PIN-wrapped derived key. */256export async function unlockWithPin(pin: string): Promise<boolean> {257  if (!isValidPin(pin)) return false;258  await loadSettings();259  if (!settings.pinEnabled) return false;260261  const { stored, failedAttempts } = await loadPinState();262  if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;263  if (264    !stored ||265    stored.version !== 1 ||266    !stored.salt ||267    !stored.nonce ||268    !stored.ciphertext ||269    !stored.tag270  ) return false;271272  const pinKey = await deriveKey(pin, unhex(stored.salt));273  let unwrappedKey: Uint8Array | null = null;274  try {275    const masterKeyHex = await decrypt(276      pinKey,277      stored.nonce,278      stored.ciphertext,279      stored.tag,280      "pin_wrap_v1",281    );282    if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;283    unwrappedKey = unhex(masterKeyHex);284    const result = await unlockWithKey(masterKeyHex);285    if (!result.ok) return false;286    await resetPinFailedAttempts();287    return true;288  } catch {289    await recordPinFailure(failedAttempts + 1);290    return false;291  } finally {292    zeroKey(pinKey);293    if (unwrappedKey) zeroKey(unwrappedKey);294  }295}296297async function savePinWrap(stored: StoredPinWrap): Promise<void> {298  inMemoryPinWrap = stored;299  inMemoryPinFailedAttempts = 0;300  sessionPinStorageFailed = !chrome.storage.session;301  if (!chrome.storage.session) return;302  try {303    await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });304    sessionPinStorageFailed = false;305  } catch {306    // Firefox fallback: keep the wrapper only in this background context.307    sessionPinStorageFailed = true;308  }309}310311async function loadPinState(): Promise<{312  stored: StoredPinWrap | undefined;313  failedAttempts: number;314}> {315  if (!chrome.storage.session) {316    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };317  }318  try {319    const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);320    return {321      stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??322        (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),323      failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"324        ? result[PIN_ATTEMPTS_STORAGE]325        : 0,326    };327  } catch {328    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };329  }330}331332async function recordPinFailure(failedAttempts: number): Promise<void> {333  inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);334  if (!chrome.storage.session) return;335  try {336    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });337  } catch { /* in-memory counter remains the fallback */ }338}339340async function resetPinFailedAttempts(): Promise<void> {341  inMemoryPinFailedAttempts = 0;342  if (!chrome.storage.session) return;343  try {344    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });345  } catch { /* in-memory counter remains the fallback */ }346}347348export async function getPinUnlockStatus(): Promise<{349  configured: boolean;350  available: boolean;351  attemptsRemaining: number;352}> {353  await loadSettings();354  if (!settings.pinEnabled) {355    return { configured: false, available: false, attemptsRemaining: 0 };356  }357  const { stored, failedAttempts } = await loadPinState();358  const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;359  return {360    configured: true,361    available,362    attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,363  };364}365366async function persistSettings(): Promise<void> {367  try {368    await chrome.storage.local.set({369      peach_settings: {370        ...settings,371        manualSummonDomains: undefined,372        manualSummonSalt: undefined,373        cardFingerprintSalt: undefined,374        declinedCardFingerprints: settings.declinedCardFingerprints ?? [],375        metadataVersion: 1,376      },377    });378  } catch (err) {379    throw mapStorageError(err);380  }381}382383function mapStorageError(err: unknown): Error {384  const message = err instanceof Error ? err.message : String(err);385  if (/QUOTA_BYTES|quota/i.test(message)) {386    return new Error("STORAGE_QUOTA_EXCEEDED");387  }388  return err instanceof Error ? err : new Error(message);389}390391async function loadSettings(): Promise<void> {392  const result = await chrome.storage.local.get("peach_settings");393  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};394}395396// Fire-and-forget sync trigger after a committed vault write. A failure here397// must never fail the write that caused it.398function fireSyncTrigger(reason: "vault_change"): void {399  void Promise.resolve()400    .then(() => triggerSyncIfReachable({ reason }))401    .catch(() => {402      /* no sync runner registered, or nothing reachable — non-fatal */403    });404}405406let lockedSyncRefreshHook: (() => Promise<void>) | null = null;407let lockedSyncUnlockHook: (() => Promise<void>) | null = null;408let decoySyncTeardownHook: (() => void) | null = null;409export type IdentityHooks = {410  /** Drop the cached identity: the session is no longer a real unlocked one. */411  onSessionEnded: () => Promise<void>;412  /** Re-wrap the identity under the keys now in force, after a vault key swap. */413  onKeysRotated: () => Promise<void>;414  /** Promote a pre-vault identity once a vault exists, given the password that created it. */415  onVaultCreated: (password: string) => Promise<void>;416};417418let identityHooks: IdentityHooks | null = null;419420export function setIdentityHooks(hooks: IdentityHooks): void {421  identityHooks = hooks;422}423424async function notifyIdentitySessionEnded(): Promise<void> {425  await identityHooks?.onSessionEnded().catch((error) => {426    console.warn("[Peach] local identity cache clear failed:", error);427  });428}429430async function notifyIdentityKeysRotated(): Promise<void> {431  await identityHooks?.onKeysRotated().catch((error) => {432    console.warn("[Peach] local identity re-wrap failed:", error);433  });434}435436export type SealedBox = { nonce: string; ciphertext: string; tag: string };437export type IdentityKeyEnvelope = {438  root?: SealedBox;439  local?: SealedBox;440  pending?: SealedBox;441  pendingSalt?: string;442};443444export function setLockedSyncHooks(hooks: {445  refresh: () => Promise<void>;446  afterUnlock: () => Promise<void>;447  onDecoy?: () => void;448}): void {449  lockedSyncRefreshHook = hooks.refresh;450  lockedSyncUnlockHook = hooks.afterUnlock;451  decoySyncTeardownHook = hooks.onDecoy ?? null;452}453454async function refreshLockedSyncState(): Promise<void> {455  if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;456  await lockedSyncRefreshHook().catch((error) => {457    console.warn("[Peach sync] locked spool refresh failed:", error);458  });459}460461// ---------------------------------------------------------------------------462// HIBP breach check (k-anonymity model)463// ---------------------------------------------------------------------------464465export async function checkBreach(password: string): Promise<number> {466  // HIBP k-anonymity API is keyed on SHA-1 prefixes — this is dictated by467  // the public API, not a security choice. Do not migrate to SHA-256 here.468  const encoder = new TextEncoder();469  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));470  const hashHex = Array.from(new Uint8Array(hashBuffer))471    .map((b) => b.toString(16).padStart(2, "0"))472    .join("")473    .toUpperCase();474475  const prefix = hashHex.slice(0, 5);476  const suffix = hashHex.slice(5);477478  try {479    // PCH-02-007: request HIBP response padding so response length does not480    // reveal how many suffixes a queried prefix returned.481    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {482      headers: { "Add-Padding": "true" },483    });484    if (!res.ok) return 0;485    const text = await res.text();486    const match = text.split("\r\n").find((l) => l.startsWith(suffix));487    return match ? parseInt(match.split(":")[1], 10) : 0;488  } catch {489    return 0; // Network unavailable — don't block490  }491}492493export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {494  // Unreachable from the message layer, which gates on isUnlocked first.495  const key = await cardDeclineKey();496  if (!key) return;497  const fingerprint = await cardFingerprint(last4, expMonth, expYear);498  await loadSettings();499  settings = {500    ...settings,501    declinedCardFingerprints: [502      // Legacy records predate the keyed digest and are silently dropped here:503      // they are 16 hex characters rather than 32, and are no longer accepted.504      ...(settings.declinedCardFingerprints ?? []).filter(505        (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,506      ),507      await metadataDigest(fingerprint, key),508    ].slice(-CARD_DECLINE_MAX),509  };510  await persistSettings();511}512513export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {514  await loadSettings();515  const key = await cardDeclineKey();516  if (!key) return false;517  const fingerprint = await cardFingerprint(last4, expMonth, expYear);518  const candidate = await metadataDigest(fingerprint, key);519  return (settings.declinedCardFingerprints ?? []).some(520    (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,521  );522}523524/**525 * Keying material for the declined-card digest, derived from the vault root526 * key so it is available only while unlocked and never written to storage.527 *528 * PB-26-108: the previous scheme stored a random salt beside the records. A529 * salt defeats precomputed tables, not an attacker holding the file — the530 * candidate space here is ~10^4 last-four values x 12 months x a few years, so531 * a stored salt left the card's last four and expiry recoverable offline in532 * seconds. `peach_settings` is unencrypted and directly readable by a content533 * script on Firefox, so the key has to live outside storage entirely.534 */535async function cardDeclineKey(): Promise<string | null> {536  const root = getRootKeyHex();537  if (!root) return null;538  const digest = new Uint8Array(539    await crypto.subtle.digest(540      "SHA-256",541      new TextEncoder().encode(`peach-card-decline-v1:${root}`),542    ),543  );544  return hex(digest);545}546547async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {548  const encoder = new TextEncoder();549  const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));550  return Array.from(new Uint8Array(buf).slice(0, 8))551    .map((b) => b.toString(16).padStart(2, "0"))552    .join("");553}554555function hex(buf: Uint8Array): string {556  return Array.from(buf)557    .map((b) => b.toString(16).padStart(2, "0"))558    .join("");559}560561function unhex(s: string): Uint8Array {562  const out = new Uint8Array(s.length / 2);563  for (let i = 0; i < s.length; i += 2) {564    out[i / 2] = parseInt(s.slice(i, i + 2), 16);565  }566  return out;567}568569// ---------------------------------------------------------------------------570// Root key generation and recovery word wrapping571// ---------------------------------------------------------------------------572573/**574 * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key575 * derived from the recovery word material via HKDF-SHA256. The wrapped blob576 * fields are stored in slot A alongside the password-wrapped ciphertext. The577 * root key itself is kept in memory for the session so getRecoveryWords() can578 * encode it without re-deriving.579 *580 * SECURITY MODEL (PCH-01-005): the recovery KEK is derived from the 12-word581 * material, which encodes only rootKey[0..16] (recovery-words.ts). The KEK is thus582 * derived from a PREFIX of the very plaintext it wraps, and effective recovery583 * security is 128-bit (standard for a 12-word phrase), NOT 256-bit. This is safe —584 * unwrapping requires the words, and the GCM ciphertext leaks nothing without them —585 * but it is intentional, not an oversight. A future escrow version wanting 256-bit586 * recovery must derive the words from INDEPENDENT entropy, not from the root key.587 */588async function generateAndWrapRootKey(): Promise<{589  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey590  recoverySalt: string;591  wrappedRootKey: string;592  wrappedNonce: string;593  wrappedTag: string;594}> {595  const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));596597  // Derive the recovery key from the BIP39 words encoding of the root key.598  const words = await rootKeyToWords(rootKeyBytes);599  const wordMaterial = await wordsToRootKeyMaterial(words);600  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));601  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);602603  // Wrap the root key with the recovery key using AES-256-GCM directly.604  // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.605  const nonce = crypto.getRandomValues(new Uint8Array(12));606  const encoded = new TextEncoder().encode(hex(rootKeyBytes));607  const raw = await crypto.subtle.encrypt(608    {609      name: "AES-GCM",610      iv: nonce,611      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,612      tagLength: 128,613    },614    recoveryKey,615    encoded616  );617  // WebCrypto returns ciphertext || auth tag concatenated; split them.618  const rawU8 = new Uint8Array(raw);619  const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));620  const tag = hex(rawU8.slice(rawU8.length - 16));621622  return {623    rawRootKey: hex(rootKeyBytes),624    recoverySalt: hex(recoverySalt),625    wrappedRootKey: ciphertext,626    wrappedNonce: hex(nonce),627    wrappedTag: tag,628  };629}630631async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{632  recoverySalt: string;633  wrappedRootKey: string;634  wrappedNonce: string;635  wrappedTag: string;636}> {637  if (rootKeyBytes.length !== 32) {638    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");639  }640641  const words = await rootKeyToWords(rootKeyBytes);642  const wordMaterial = await wordsToRootKeyMaterial(words);643  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));644  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);645  const nonce = crypto.getRandomValues(new Uint8Array(12));646  const encoded = new TextEncoder().encode(hex(rootKeyBytes));647  const raw = await crypto.subtle.encrypt(648    {649      name: "AES-GCM",650      iv: nonce,651      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,652      tagLength: 128,653    },654    recoveryKey,655    encoded,656  );657  const rawU8 = new Uint8Array(raw);658  return {659    recoverySalt: hex(recoverySalt),660    wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),661    wrappedNonce: hex(nonce),662    wrappedTag: hex(rawU8.slice(rawU8.length - 16)),663  };664}665666/**667 * Unwrap the root key using recovery words. Called during recovery flow when668 * the user provides their 12-word phrase and a new password.669 * Exported for use by the RECOVER_WITH_WORDS message handler.670 */671export async function unwrapRootKeyForRecovery(672  wrappedRootKey: string,673  wrappedNonce: string,674  wrappedTag: string,675  recoverySaltHex: string,676  words: string[]677): Promise<void> {678  const wordMaterial = await wordsToRootKeyMaterial(words);679  const recoverySalt = unhex(recoverySaltHex);680  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);681682  // Decrypt and cache the root key. The root key bytes were hex-encoded before683  // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt684  // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.685  // Each component is stored separately in the vault slot; decode them individually686  // so the nonce is always exactly the 12 bytes from wrappedNonce.687  const nonceRaw = unhex(wrappedNonce);688  const ciphertextRaw = unhex(wrappedRootKey);689  const tagRaw = unhex(wrappedTag);690  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).691  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.692  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;693  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);694  combined.set(ciphertextRaw, 0);695  combined.set(tagRaw, ciphertextRaw.length);696  const raw = await crypto.subtle.decrypt(697    { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },698    recoveryKey,699    combined700  );701  const decryptedHex = new TextDecoder().decode(raw);702  rootKey = unhex(decryptedHex);703}704705/**706 * Re-wrap the cached root key under a new derived key (from the user's new707 * password). Called after password change and after recovery re-key so the708 * same recovery words continue to work with the updated password — and so709 * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.710 *711 * recovery_* fields are intentionally not touched: those wrap rootKey under712 * the recovery words, which do not change when the password changes.713 */714export async function rewrapRootKeyForPassword(715  newDerivedKey: Uint8Array,716): Promise<void> {717  if (!rootKey) throw new Error("root key not set");718  const slotA = await loadSlot(SLOT_A);719  if (!slotA) throw new Error("missing slot A");720  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);721  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });722}723724/**725 * Change the vault encryption to a new derived key (already derived from726 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.727 * Caller is responsible for key swap and snapshot.728 */729async function reencryptVaultForNewKey(730  newKey: Uint8Array,731  slotA: StoredVault732): Promise<void> {733  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);734  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");735  await writeSlot(SLOT_A, {736    ...slotA,737    nonce,738    ciphertext,739    tag,740  });741}742743/**744 * Full password change with an already-derived key (used after recovery).745 * Unlike changePassword(), this skips re-verification of current password746 * since recovery flow has already validated the user's identity via words.747 */748export async function changePasswordWithNewKey(749  newSalt: Uint8Array,750  newKey: Uint8Array751): Promise<void> {752  const epoch = vaultEpoch;753  const throwIfStale = () => {754    if (epoch === vaultEpoch) return;755    zeroKey(newKey);756    throw new Error("vault_locked");757  };758  const slotA = await loadSlot(SLOT_A);759  throwIfStale();760  if (!slotA) throw new Error("missing slot A");761  // reencryptVaultForNewKey must receive the updated salt so that subsequent762  // UNLOCK calls with the new password can re-derive the correct key.763  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });764  throwIfStale();765  if (derivedKey) zeroKey(derivedKey);766  derivedKey = newKey;767  // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow768  // reaches this path, so a post-recovery SW restart would otherwise lock out769  // a user who just reset their password.770  await saveSessionKey(newKey, "real", epoch);771  throwIfStale();772  // Same reason as changePassword: the unlock key just changed, so the773  // identity's local wrap has to be re-encrypted under it.774  await notifyIdentityKeysRotated();775  throwIfStale();776  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works777  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow778  // should have populated it — fall back silently rather than blowing up the779  // recovery path; the escrow is already present on every supported vault.780  if (rootKey) {781    const slotAfter = await loadSlot(SLOT_A);782    throwIfStale();783    if (slotAfter) {784      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);785      throwIfStale();786      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });787      throwIfStale();788    }789  }790  if (!duressConfigured) {791    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));792    throwIfStale();793  }794  await clearPinUnlockStorage();795  throwIfStale();796  await stampLocalVaultMutation();797  throwIfStale();798}799800// ---------------------------------------------------------------------------801// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive802// a new key after password reset without re-importing the crypto module.803// ---------------------------------------------------------------------------804805export { deriveKey, generateSalt };806807export async function loadSlotA(): Promise<StoredVault | null> {808  return loadSlot(SLOT_A);809}810811/**812 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields813 * (a second wrapping of the root key under the password-derived key). This814 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without815 * the user re-entering their recovery phrase: the same root key bytes —816 * and therefore the same 12 words — survive every unlock.817 *818 * Returns null when the escrow is absent or decryption fails.819 */820async function deriveRootKeyFromDerivedKey(821  derivedKeyBytes: Uint8Array,822  slotA: StoredVault,823): Promise<Uint8Array | null> {824  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {825    return null;826  }827  try {828    const rootHex = await decrypt(829      derivedKeyBytes,830      slotA.pw_root_nonce,831      slotA.pw_root_ciphertext,832      slotA.pw_root_tag,833      "pw_root_v1",834    );835    return unhex(rootHex);836  } catch {837    return null;838  }839}840841export async function sealIdentityKeyUnderVault(ik: Uint8Array): Promise<IdentityKeyEnvelope> {842  if (vaultMode !== "real" || !rootKey || !derivedKey) throw new Error("vault_locked");843  // The root wrap survives password change and recovery-words re-key; the844  // local wrap is available during normal unlock. Pending is only pre-vault.845  const [root, local] = await Promise.all([846    encrypt(rootKey, hex(ik), "peach-local-identity-ik-root-v1"),847    encrypt(derivedKey, hex(ik), "peach-local-identity-ik-local-v1"),848  ]);849  return { root, local };850}851852export async function sealIdentityKeyPreVault(853  password: string,854  ik: Uint8Array,855): Promise<IdentityKeyEnvelope> {856  const salt = generateSalt();857  const key = await deriveKey(password, salt);858  try {859    const pending = await encrypt(key, hex(ik), "peach-local-identity-ik-pending-v1");860    return { pending, pendingSalt: hex(salt) };861  } finally {862    zeroKey(key);863  }864}865866/**867 * Open the identity key with any wrapping key that is currently available:868 * the live vault-unlock key, then the vault root key, then the pre-vault869 * password. Each attempt is awaited only after the previous one fails, so a870 * successful early unwrap never leaves a later decrypt rejection unobserved,871 * and the pre-vault Argon2id is not paid when a vault key already opens the872 * envelope.873 */874export async function openIdentityKey(875  env: IdentityKeyEnvelope,876  password?: string,877): Promise<Uint8Array | null> {878  if (env.local && derivedKey) {879    const ik = await openSealedKey(derivedKey, env.local, "peach-local-identity-ik-local-v1");880    if (ik) return ik;881  }882  if (env.root && rootKey) {883    const ik = await openSealedKey(rootKey, env.root, "peach-local-identity-ik-root-v1");884    if (ik) return ik;885  }886  if (!env.pending || !env.pendingSalt || password === undefined) return null;887  let key: Uint8Array;888  try {889    const salt = unhex(env.pendingSalt);890    key = await deriveKey(password, salt);891    salt.fill(0);892  } catch {893    return null;894  }895  try {896    return await openSealedKey(key, env.pending, "peach-local-identity-ik-pending-v1");897  } finally {898    zeroKey(key);899  }900}901902/** Decrypt a sealed 32-byte key; null for a wrong key, a bad shape, or corrupt bytes. */903async function openSealedKey(904  key: Uint8Array,905  box: SealedBox,906  info: string,907): Promise<Uint8Array | null> {908  try {909    const value = unhex(await decrypt(key, box.nonce, box.ciphertext, box.tag, info));910    if (value.length === 32) return value;911    value.fill(0);912    return null;913  } catch {914    return null;915  }916}917918// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the919// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.920async function wrapRootKeyForDerivedKey(921  derivedKeyBytes: Uint8Array,922  rootKeyBytes: Uint8Array,923): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {924  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");925  return {926    pw_root_ciphertext: ciphertext,927    pw_root_nonce: nonce,928    pw_root_tag: tag,929  };930}931932/** Returns the 12 recovery words for the current session's root key. */933export async function getRecoveryWords(): Promise<string[]> {934  if (!rootKey) {935    throw new Error("recovery not set up");936  }937  return rootKeyToWords(rootKey);938}939940async function loadSlot(key: string): Promise<StoredVault | null> {941  const result = await chrome.storage.local.get(key);942  return (result as Record<string, StoredVault | undefined>)[key] ?? null;943}944945async function writeSlot(key: string, vault: StoredVault): Promise<void> {946  try {947    await chrome.storage.local.set({ [key]: vault });948  } catch (err) {949    throw mapStorageError(err);950  }951}952953/**954 * Roll back a vault that was created by [createVaultFromRootKey] but955 * couldn't be paired (e.g. the subsequent license activation, identity956 * derivation, or paired-device upsert failed). Without this, a failed957 * receiver session leaves behind an empty vault — the user has to958 * re-pair to get a working setup, but the empty vault sits in the way.959 *960 * The call is idempotent: removing keys that aren't there is a no-op.961 */962export async function rollbackAdoptedVault(): Promise<void> {963  await lock();964  try {965    await chrome.storage.local.remove([966      SLOT_A,967      SLOT_B,968      "peach_last_modified_at",969    ]);970  } catch (err) {971    throw mapStorageError(err);972  }973}974975async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {976  const raw = await chrome.storage.local.get("peach_last_modified_at");977  const previous = Number(978    (raw as Record<string, unknown>).peach_last_modified_at,979  );980  const next = Math.max(981    Date.now(),982    Number.isFinite(previous) ? previous + 1 : 0,983  );984  try {985    await chrome.storage.local.set({986      [SLOT_A]: slotA,987      [SLOT_B]: slotB,988      peach_last_modified_at: String(next),989    });990  } catch (err) {991    throw mapStorageError(err);992  }993}994995// Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the996// sync layer's last-write-wins merge against wall-clock regressions (NTP step,997// manual clock change, DST glitch): a backwards Date.now() must never produce998// an entry timestamp that's <= the entry's existing modifiedAt, or a genuine999// local edit would lose to a stale remote copy. The returned value is always1000// strictly greater than both the entry's current modifiedAt and the previous1001// call's result (SEV-2-E).1002let lastEntryTimestamp = 0;1003function monotonicEntryTimestamp(currentModifiedAt?: number): number {1004  const base = Math.max(1005    Date.now(),1006    (currentModifiedAt ?? 0) + 1,1007    lastEntryTimestamp + 1,1008  );1009  lastEntryTimestamp = base;1010  return base;1011}10121013async function stampLocalVaultMutation(): Promise<void> {1014  const raw = await chrome.storage.local.get("peach_last_modified_at");1015  const previous = Number(1016    (raw as Record<string, unknown>).peach_last_modified_at,1017  );1018  const next = Math.max(1019    Date.now(),1020    Number.isFinite(previous) ? previous + 1 : 0,1021  );1022  await chrome.storage.local.set({1023    peach_last_modified_at: String(next),1024  });1025}10261027// ---------------------------------------------------------------------------1028// Padding + noise helpers1029// ---------------------------------------------------------------------------10301031/**1032 * Pads the plaintext string with trailing spaces so that the encoded byte1033 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing1034 * whitespace after a complete document, so the padding is transparent on1035 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.1036 */1037function padPlaintext(json: string, minBytes = 0): string {1038  const encoded = new TextEncoder().encode(json);1039  const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);1040  // minBytes lets the duress path force a fixed ceiling so both slots match.1041  const target = Math.max(bucket, minBytes);1042  const padLen = target - encoded.length;1043  return json + " ".repeat(padLen);1044}10451046/**1047 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The1048 * ciphertext length matches the given byte count. Because GCM ciphertext is1049 * pseudo-random, this blob is indistinguishable at rest from a real1050 * encrypted vault without the key.1051 *1052 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto1053 * spec hard limit). Chunk the fill so vaults larger than that — common once1054 * the user has dozens of entries or any passkey — don't throw.1055 */1056const CSPRNG_CHUNK = 65_536;10571058function fillRandom(buf: Uint8Array): Uint8Array {1059  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {1060    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);1061    const chunk = new Uint8Array(end - offset);1062    crypto.getRandomValues(chunk);1063    buf.set(chunk, offset);1064  }1065  return buf;1066}10671068function generateNoiseBlob(ciphertextBytes: number): StoredVault {1069  const salt = crypto.getRandomValues(new Uint8Array(16));1070  const nonce = crypto.getRandomValues(new Uint8Array(12));1071  const cipher = fillRandom(new Uint8Array(ciphertextBytes));1072  const tag = crypto.getRandomValues(new Uint8Array(16));1073  return {1074    version: 1,1075    salt: hex(salt),1076    nonce: hex(nonce),1077    ciphertext: hex(cipher),1078    tag: hex(tag),1079  };1080}10811082// ---------------------------------------------------------------------------1083// Encrypted plaintext envelope1084// ---------------------------------------------------------------------------10851086type VaultPayload = {1087  entries: VaultEntry[];1088  tombstones?: Record<string, number>;1089  duressConfigured?: boolean;1090};10911092type DecryptedPayload = {1093  entries: VaultEntry[];1094  tombstones: Map<string, number>;1095  duressConfigured: boolean;1096};10971098function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {1099  const envelope: VaultPayload = {1100    entries,1101    tombstones: Object.fromEntries(tombstones),1102    duressConfigured: duress,1103  };1104  return padPlaintext(JSON.stringify(envelope), minBytes);1105}11061107function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {1108  const result = new Map<string, number>();1109  if (raw && typeof raw === "object" && !Array.isArray(raw)) {1110    for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {1111      if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {1112        result.set(id, timestamp);1113      }1114    }1115  }1116  // Older payloads had no ledger. Rebuild the durable portion from any1117  // soft-deleted entries before they are eligible for a later purge.1118  for (const entry of allEntries) {1119    if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1120      continue;1121    }1122    if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1123      result.set(entry.id, entry.deletedAt);1124    }1125  }1126  return result;1127}11281129function reconcileTombstones(): void {1130  const byId = new Map(entries.map((entry) => [entry.id, entry]));1131  for (const [id] of tombstones) {1132    if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1133  }1134  for (const entry of entries) {1135    if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1136      if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1137        tombstones.set(entry.id, entry.deletedAt);1138      }1139    }1140  }1141}11421143function decodePayload(plain: string): DecryptedPayload {1144  const parsed = JSON.parse(plain) as VaultPayload;1145  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1146    throw new Error("invalid vault payload");1147  }1148  const normalized = parsed.entries.map(normalizeEntry);1149  return {1150    entries: normalized,1151    tombstones: decodeTombstones(parsed.tombstones, normalized),1152    duressConfigured: parsed.duressConfigured === true,1153  };1154}11551156/**1157 * Consolidate duplicate passkey records after registration.1158 *1159 * Multiple registrations for one RP can leave duplicate passkey records. This1160 * pass collapses the duplicates:1161 *1162 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.1163 *   2. Within each group, the most recently-active credential wins (prefer1164 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).1165 *   3. If a NON-passkey login entry exists for that RP (matched by url or1166 *      additionalUrls hostname), attach the winning passkey to it and1167 *      soft-delete every passkey-bearing entry in the group.1168 *   4. Otherwise, soft-delete every loser in the group, keeping the winner1169 *      as a passkey-only entry.1170 *1171 * Soft delete (deletedAt) rather than splice — the trash flow keeps the1172 * record around for retention and the user can restore if the heuristic1173 * picked wrong. The `merged` flag goes back to decodePayload so the1174 * unlock path persists the consolidated shape immediately.1175 */1176function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1177  let merged = false;1178  const now = Date.now();11791180  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1181  const byRpId = new Map<string, VaultEntry[]>();1182  for (const e of candidates) {1183    const rpId = e.passkey!.rpId;1184    const list = byRpId.get(rpId);1185    if (list) list.push(e);1186    else byRpId.set(rpId, [e]);1187  }11881189  for (const [rpId, group] of byRpId) {1190    if (group.length === 0) continue;1191    group.sort((a, b) => {1192      const aT =1193        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1194      const bT =1195        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1196      return bT - aT;1197    });1198    const winner = group[0]!;11991200    // Look for a NON-passkey login entry matching this RP. Use the URL1201    // hostname rather than a substring search so a "login.foo.com" entry1202    // doesn't accidentally absorb a passkey for "foo.com".1203    const loginTarget = entries.find((e) => {1204      if (e.deletedAt || e.passkey || e.type !== "login") return false;1205      if (e.url && extractDomain(e.url) === rpId) return true;1206      for (const extra of e.additionalUrls ?? []) {1207        if (extractDomain(extra) === rpId) return true;1208      }1209      return false;1210    });12111212    if (loginTarget) {1213      loginTarget.passkey = winner.passkey;1214      // A passkey entry may also carry the site's TOTP seed. Preserve it when1215      // the passkey is consolidated onto an existing login instead of1216      // soft-deleting the only record that held the 2FA configuration.1217      if (!loginTarget.totp && winner.totp) {1218        loginTarget.totp = winner.totp;1219        loginTarget.totpAlgorithm = winner.totpAlgorithm;1220        loginTarget.totpDigits = winner.totpDigits;1221        loginTarget.totpPeriod = winner.totpPeriod;1222      }1223      loginTarget.modifiedAt = now;1224      for (const dup of group) {1225        if (!dup.deletedAt) {1226          dup.deletedAt = now;1227          dup.modifiedAt = now;1228        }1229      }1230      merged = true;1231    } else if (group.length > 1) {1232      for (let i = 1; i < group.length; i += 1) {1233        const dup = group[i]!;1234        if (!dup.deletedAt) {1235          dup.deletedAt = now;1236          dup.modifiedAt = now;1237        }1238      }1239      merged = true;1240    }1241  }12421243  return { merged };1244}12451246// Validates that a `StoredVault` record on disk has the field shape we expect1247// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only1248// authenticates the static VERSION_BYTE AAD, not the `version` field on the1249// outer record — a corrupted `version: 42` slot still decrypts cleanly. This1250// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a1251// history-restore path instead of falsely reporting "wrong password".1252function hasValidVaultSchema(v: unknown): v is StoredVault {1253  if (typeof v !== "object" || v === null) return false;1254  const r = v as Record<string, unknown>;1255  return (1256    r.version === 1 &&1257    typeof r.salt === "string" &&1258    typeof r.nonce === "string" &&1259    typeof r.ciphertext === "string" &&1260    typeof r.tag === "string"1261  );1262}1263const MAX_HISTORY_BLOB_BYTES = 1_024 * 1_024;12641265function hasValidHistorySlot(value: unknown): value is StoredVault {1266  if (!hasValidVaultSchema(value)) return false;1267  return /^[0-9a-f]{32}$/i.test(value.salt) &&1268    /^[0-9a-f]{24}$/i.test(value.nonce) &&1269    /^[0-9a-f]+$/i.test(value.ciphertext) &&1270    value.ciphertext.length % 2 === 0 &&1271    value.ciphertext.length / 2 <= MAX_HISTORY_BLOB_BYTES &&1272    /^[0-9a-f]{32}$/i.test(value.tag);1273}12741275function parseHistorySlot(value: string): StoredVault | null {1276  try {1277    const parsed: unknown = JSON.parse(value);1278    return hasValidHistorySlot(parsed) ? parsed : null;1279  } catch {1280    return null;1281  }1282}12831284function isHistoryRecord(value: unknown): value is VaultSnapshot {1285  if (typeof value !== "object" || value === null) return false;1286  const record = value as Record<string, unknown>;1287  return typeof record.id === "string" && record.id.length > 0 && record.id.length <= 128 &&1288    typeof record.savedAt === "number" && Number.isFinite(record.savedAt) &&1289    typeof record.reason === "string" &&1290    typeof record.vault_a === "string" && record.vault_a.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1291    typeof record.vault_b === "string" && record.vault_b.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1292    (record.entryCount === null || typeof record.entryCount === "number");1293}12941295function sanitizeHistory(value: unknown): VaultSnapshot[] {1296  if (typeof value !== "object" || value === null) return [];1297  if (!("snapshots" in value) || !Array.isArray(value.snapshots)) return [];1298  return value.snapshots.filter(isHistoryRecord).slice(0, HISTORY_MAX);1299}13001301export type TryDecryptResult =1302  | { status: "ok"; payload: DecryptedPayload }1303  | { status: "wrong_key" }1304  | { status: "corrupted"; reason: string };13051306// Exported solely for `unlockFromCodex` below, which needs to verify a1307// supplied passphrase against a slotA blob *without* committing anything to1308// storage. Keep this internal otherwise — callers crossing the messages.ts1309// boundary should always go through `unlock` / `verifyMainPassword` so the1310// constant-time invariants in Projects.md §3.4 are not bypassed.1311export async function tryDecryptSlot(1312  vault: StoredVault,1313  key: Uint8Array,1314  aadTag?: string,1315): Promise<DecryptedPayload | null> {1316  const res = await tryDecryptSlotResult(vault, key, aadTag);1317  return res.status === "ok" ? res.payload : null;1318}13191320export async function tryDecryptSlotResult(1321  vault: StoredVault,1322  key: Uint8Array,1323  aadTag?: string,1324): Promise<TryDecryptResult> {1325  if (!hasValidVaultSchema(vault)) {1326    return { status: "corrupted", reason: "invalid_slot_schema" };1327  }1328  let plain: string;1329  try {1330    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1331  } catch {1332    return { status: "wrong_key" };1333  }1334  try {1335    const parsed = JSON.parse(plain) as unknown;1336    if (typeof parsed !== "object" || parsed === null) {1337      return { status: "corrupted", reason: "invalid_payload_shape" };1338    }1339    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1340      return { status: "corrupted", reason: "invalid_payload_shape" };1341    }1342    return { status: "ok", payload: decodePayload(plain) };1343  } catch {1344    return { status: "corrupted", reason: "invalid_payload_json" };1345  }1346}13471348// ---------------------------------------------------------------------------1349// Core operations1350// ---------------------------------------------------------------------------13511352function buildDomainIndexSync(all: VaultEntry[]): void {1353  domainIndex = new Map();13541355  for (const entry of all) {1356    if (entry.deletedAt) continue;1357    if (entry.type === "note" || entry.type === "card") continue;13581359    // An entry can match multiple sites via `additionalUrls`. Index each1360    // hostname so `queryDomain` returns the entry regardless of which alias1361    // the user is currently visiting. Dedupe per-entry so a single entry1362    // never appears twice in one bucket.1363    const domains = new Set<string>();1364    const primary = extractDomain(entry.url ?? "");1365    if (primary) domains.add(primary);1366    for (const extra of entry.additionalUrls ?? []) {1367      const d = extractDomain(extra);1368      if (d) domains.add(d);1369    }1370    if (domains.size === 0) continue;13711372    for (const domain of domains) {1373      const bucket = domainIndex.get(domain) ?? [];1374      bucket.push(entry);1375      domainIndex.set(domain, bucket);1376    }1377  }1378}13791380export async function createVault(password: string): Promise<void> {1381  const salt = generateSalt();1382  derivedKey = await deriveKey(password, salt);1383  entries = [];1384  tombstones = new Map();1385  vaultMode = "real";1386  duressConfigured = false;1387  buildDomainIndexSync(entries);13881389  // Generate and wrap the root key for recovery word escrow.1390  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1391    await generateAndWrapRootKey();1392  rootKey = unhex(rawRootKey);13931394  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1395  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");13961397  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate1398  // it without recovery words (H2/COR-006).1399  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);14001401  const slotA: StoredVault = {1402    version: 1,1403    salt: hex(salt),1404    nonce,1405    ciphertext,1406    tag,1407    recovery_salt: recoverySalt,1408    recovery_wrapped_root_key: wrappedRootKey,1409    recovery_nonce: wrappedNonce,1410    recovery_tag: wrappedTag,1411    ...pwRoot,1412  };1413  await writeSlot(SLOT_A, slotA);14141415  // Matching-size noise blob for slot B, so a fresh install shows two1416  // similarly-shaped records regardless of whether duress is ever set up.1417  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1418  await stampLocalVaultMutation();14191420  // An identity created while this install had no vault was sealed under the1421  // password alone; the vault keys exist now, so re-seal it under them.1422  await identityHooks?.onVaultCreated(password).catch((error) => {1423    console.warn("[Peach] local identity promotion failed:", error);1424  });14251426  touchAutoLock();1427}14281429/**1430 * Creates a local vault from an existing vault root key received through the1431 * SAS-gated adoption protocol. The password is local to this device; it wraps1432 * the adopted root key at rest and does not need to match the source device.1433 */1434export async function createVaultFromRootKey(1435  password: string,1436  rootKeyHex: string,1437): Promise<{ vaultId: string }> {1438  if (typeof password !== "string" || password.length === 0) {1439    throw new Error("ADOPT_INVALID_PASSWORD");1440  }1441  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1442    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1443  }14441445  const rootKeyBytes = unhex(rootKeyHex);1446  if (rootKeyBytes.length !== 32) {1447    rootKeyBytes.fill(0);1448    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1449  }14501451  let nextDerivedKey: Uint8Array | null = null;1452  let sessionRootKey: Uint8Array | null = null;1453  try {1454    if ((await loadSlot(SLOT_A)) !== null) {1455      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1456    }14571458    const nextEntries: VaultEntry[] = [];1459    const nextDuressConfigured = false;1460    const vaultId = await computeVaultId(rootKeyBytes);1461    const salt = generateSalt();1462    nextDerivedKey = await deriveKey(password, salt);1463    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);14641465    sessionRootKey = new Uint8Array(rootKeyBytes);1466    const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1467    const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1468    const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1469    const slotA: StoredVault = {1470      version: 1,1471      salt: hex(salt),1472      nonce,1473      ciphertext,1474      tag,1475      recovery_salt: recovery.recoverySalt,1476      recovery_wrapped_root_key: recovery.wrappedRootKey,1477      recovery_nonce: recovery.wrappedNonce,1478      recovery_tag: recovery.wrappedTag,1479      ...pwRoot,1480    };1481    const slotB = generateNoiseBlob(ciphertext.length / 2);1482    await commitCreatedVaultSlots(slotA, slotB);14831484    const previousDerivedKey = derivedKey;1485    const previousRootKey = rootKey;1486    derivedKey = nextDerivedKey;1487    rootKey = sessionRootKey;1488    nextDerivedKey = null;1489    sessionRootKey = null;1490    entries = nextEntries;1491    tombstones = new Map();1492    vaultMode = "real";1493    duressConfigured = nextDuressConfigured;1494    buildDomainIndexSync(entries);1495    if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1496    if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1497    touchAutoLock();14981499    // The receiver's identity was minted before this vault existed (adoption v21500    // needs it in its first frame), so re-seal it under the vault keys now.1501    await identityHooks?.onVaultCreated(password).catch((error) => {1502      console.warn("[Peach] local identity promotion failed:", error);1503    });15041505    return { vaultId };1506  } finally {1507    rootKeyBytes.fill(0);1508    if (nextDerivedKey) zeroKey(nextDerivedKey);1509    if (sessionRootKey) sessionRootKey.fill(0);1510  }1511}15121513export async function hasVault(): Promise<boolean> {1514  return (await loadSlot(SLOT_A)) !== null;1515}15161517/**1518 * Returns whether the real vault has recorded that a duress passphrase is1519 * configured. Reliable only after a real-mode unlock — before unlock or in1520 * decoy mode, returns false because we cannot read slot A's plaintext.1521 */1522export async function isDuressEnabled(): Promise<boolean> {1523  return vaultMode === "real" && duressConfigured;1524}15251526export async function setupDuress(duressPassword: string): Promise<void> {1527  if (!derivedKey || vaultMode !== "real") {1528    throw new Error("vault must be unlocked in real mode");1529  }1530  // Encrypt the decoy payload (empty entries) under the duress key. Pad to1531  // at least the current slot A ciphertext size so the two slots look the1532  // same at rest at setup time.1533  const slotA = await loadSlot(SLOT_A);1534  if (!slotA) throw new Error("missing slot A");15351536  const decoySalt = generateSalt();1537  const decoyKey = await deriveKey(duressPassword, decoySalt);1538  try {1539    // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1540    // same ceiling by the persistSlotA() call below (duressConfigured is now1541    // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1542    // lengths cannot reveal that duress is configured (audit MEDIUM).1543    const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1544    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1545    await writeSlot(SLOT_B, {1546      version: 1,1547      salt: hex(decoySalt),1548      nonce,1549      ciphertext,1550      tag,1551    });1552  } finally {1553    zeroKey(decoyKey);1554  }15551556  // Re-encrypt slot A with the duressConfigured flag set to true. No external1557  // flag is written — the bit lives inside the encrypted envelope.1558  duressConfigured = true;1559  await persistSlotA();1560}15611562export async function disableDuress(): Promise<void> {1563  if (!derivedKey || vaultMode !== "real") {1564    throw new Error("vault must be unlocked in real mode");1565  }1566  // Overwrite slot B with fresh noise that matches slot A's size. The slot1567  // remains occupied so its mere existence does not signal that duress was1568  // ever configured.1569  const slotA = await loadSlot(SLOT_A);1570  if (slotA) {1571    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1572  }15731574  duressConfigured = false;1575  await persistSlotA();1576}15771578// L14/COR-015: this function intentionally checks slot A (real vault) only.1579// A duress-mode user attempting changePassword is rejected earlier by the1580// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1581// distinction here is by design — duress users cannot rotate the real1582// password by guessing it, and the asymmetry in error timing is acceptable1583// because duress mode is opt-in and the user knows their own decoy key.1584export async function verifyMainPassword(password: string): Promise<boolean> {1585  const slotA = await loadSlot(SLOT_A);1586  if (!slotA) return false;1587  const key = await deriveKey(password, unhex(slotA.salt));1588  try {1589    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1590  } finally {1591    zeroKey(key);1592  }1593}15941595export async function unlock(password: string): Promise<boolean> {1596  return attemptUnlockWithDerive(password, deriveKey);1597}15981599async function attemptUnlockWithDerive(1600  password: string,1601  derive: typeof deriveKey,1602): Promise<boolean> {1603  const epoch = vaultEpoch;1604  const stale = () => epoch !== vaultEpoch;1605  // Load both current-format slots in parallel. Both exist on a supported1606  // install; the1607  // parallel load and the parallel derivation below keep wall-clock time1608  // constant regardless of which slot's passphrase (real or decoy) the1609  // caller supplied, so a timing side channel cannot distinguish the1610  // duress path from the real-unlock path (DUR-002).1611  const [slotA, slotB] = await Promise.all([1612    loadSlot(SLOT_A),1613    loadSlot(SLOT_B),1614  ]);16151616  // Derive both keys in parallel, unconditionally.1617  const [keyA, keyB] = await Promise.all([1618    slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1619    slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1620  ]);16211622  // Attempt both decrypts in parallel. The Result variant lets us distinguish1623  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1624  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1625  const [resultA, resultB] = await Promise.all([1626    keyA && slotA1627      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1628      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1629    keyB && slotB1630      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1631      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1632  ]);1633  const payloadA = resultA.status === "ok" ? resultA.payload : null;1634  const payloadB = resultB.status === "ok" ? resultB.payload : null;16351636  if (stale()) {1637    if (keyA) zeroKey(keyA);1638    if (keyB) zeroKey(keyB);1639    return false;1640  }16411642  if (payloadA && keyA) {1643    if (stale()) {1644      zeroKey(keyA);1645      if (keyB) zeroKey(keyB);1646      return false;1647    }1648    derivedKey = keyA;1649    vaultMode = "real";1650    entries = payloadA.entries;1651    tombstones = payloadA.tombstones;1652    duressConfigured = payloadA.duressConfigured;1653    buildDomainIndexSync(entries);1654    await loadSettings();1655    if (stale()) {1656      await lock();1657      return false;1658    }1659    touchAutoLock();1660    if (keyB) zeroKey(keyB);16611662    // Current vaults always carry a password-wrapped root key. Older vaults1663    // without this escrow are outside the supported storage format.1664    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1665    if (!rootKey) {1666      await lock();1667      return false;1668    }16691670    // PB-26-213: adopt legacy plaintext pair secrets into the encrypted1671    // record and refresh the trusted-session operational copy.1672    await reconcilePairSecrets(rootKey);1673    if (stale()) {1674      await lock();1675      return false;1676    }1677    await lockedSyncUnlockHook?.().catch((error) => {1678      console.warn("[Peach sync] locked inbox drain failed:", error);1679    });1680    if (stale()) {1681      await lock();1682      return false;1683    }1684    await resetPinFailedAttempts();1685    if (stale()) {1686      await lock();1687      return false;1688    }1689    await saveSessionKey(keyA, "real", epoch);1690    if (stale()) return false;1691    return true;1692  }16931694  if (payloadB && keyB) {1695    if (stale()) {1696      zeroKey(keyB);1697      if (keyA) zeroKey(keyA);1698      return false;1699    }1700    derivedKey = keyB;1701    vaultMode = "decoy";1702    decoySyncTeardownHook?.();1703    // A duress session must not retain the real vault's root key. It gates the1704    // recovery words, the sync identity, and the root wrap of the local device1705    // identity, so leaving it live would expose all three in duress mode.1706    if (rootKey) {1707      rootKey.fill(0);1708      rootKey = null;1709    }1710    entries = payloadB.entries;1711    tombstones = payloadB.tombstones;1712    duressConfigured = false;1713    buildDomainIndexSync(entries);1714    await loadSettings();1715    touchAutoLock();1716    if (keyA) zeroKey(keyA);1717    // PB-26-213: a duress session must never serve the real pair-secret map.1718    await dropSessionCache();1719    // Nor the real device identity: a duress session is not a real one.1720    await notifyIdentitySessionEnded();1721    if (stale()) {1722      await lock();1723      return false;1724    }1725    await saveSessionKey(keyB, "decoy", epoch);1726    if (stale()) return false;1727    return true;1728  }17291730  if (keyA) zeroKey(keyA);1731  if (keyB) zeroKey(keyB);1732  // Surface corruption (good AES tag, broken schema) distinctly from1733  // wrong-password so the popup can offer history-restore (M7/COR-007).1734  if (resultA.status === "corrupted") {1735    lastUnlockFailureReason = resultA.reason;1736  } else if (resultB.status === "corrupted") {1737    lastUnlockFailureReason = resultB.reason;1738  }1739  return false;1740}17411742// ---------------------------------------------------------------------------1743// MV3 service worker session key — survives SW restarts within a browser session1744// ---------------------------------------------------------------------------17451746// chrome.storage.session is cleared on browser close; survives SW restart.1747// Firefox does not implement it (persistent background script, no restart risk).1748const SESSION_KEY_STORAGE = "peach_session_dk";17491750// Order writes and removals so lock's awaited removal is a barrier after1751// every escrow write that started before it.1752let sessionKeyMutation = Promise.resolve();17531754function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1755  const task = sessionKeyMutation.then(mutation, mutation);1756  sessionKeyMutation = task.catch(() => undefined);1757  return task;1758}17591760// PB-26-203: the session-key escrow may only operate where startup positively1761// established trusted-only session storage. Defaults to available so direct1762// vault imports (tests, non-entry bootstraps) keep working; entry.ts applies1763// the real hardening result before the first restore.1764let sessionEscrowAvailable = true;17651766/**1767 * Apply the storage-hardening result at startup. When the trusted-only1768 * session state cannot be established, any stored session key is removed and1769 * the escrow stays off: the user unlocks again after a worker restart1770 * instead of trusting an unproven boundary.1771 */1772export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1773  setSessionAllowed(sessionEscrow);1774  if (sessionEscrow) return;1775  sessionEscrowAvailable = false;1776  await clearSessionKey();1777}17781779async function saveSessionKey(1780  key: Uint8Array,1781  mode: "real" | "decoy",1782  expectedEpoch = vaultEpoch,1783): Promise<void> {1784  const session = chrome.storage.session;1785  if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1786  const stored = { keyHex: hex(key), mode };1787  await mutateSessionKey(async () => {1788    if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1789    await session.set({ [SESSION_KEY_STORAGE]: stored });1790  });1791}17921793async function clearSessionKey(): Promise<void> {1794  const session = chrome.storage.session;1795  if (!session) return;1796  await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1797}17981799// A lock that only holds while the service worker stays alive is no lock at1800// all: a cold worker's restoreFromSession() would find the surviving session1801// key and silently re-unlock ~a minute after the user locked. So lock()1802// bumps this epoch synchronously at entry and zeroes keys/indexes before any1803// await; a restore that started earlier sees the mismatch after its next1804// await and aborts instead of publishing.1805let vaultEpoch = 0;18061807// One in-flight restore shared by concurrent callers: storage is read once1808// and the locked-sync unlock hook runs once.1809let restoreInFlight: Promise<boolean> | null = null;18101811/**1812 * On SW restart, re-derive all in-memory vault state from the session key1813 * stored in chrome.storage.session (set at unlock time, cleared by lock();1814 * also cleared on browser close or when restoration fails).1815 * Returns true if the vault was restored, false if no session key was found.1816 * No-ops if the vault is already unlocked.1817 */1818export async function restoreFromSession(): Promise<boolean> {1819  if (derivedKey !== null) return true;1820  if (restoreInFlight) return restoreInFlight;1821  const task = restoreFromSessionInner();1822  restoreInFlight = task;1823  try {1824    return await task;1825  } finally {1826    restoreInFlight = null;1827  }1828}18291830async function restoreFromSessionInner(): Promise<boolean> {1831  const epoch = vaultEpoch;1832  const stale = () => epoch !== vaultEpoch;1833  if (derivedKey !== null) return true;1834  if (!sessionEscrowAvailable) return false;1835  if (!chrome.storage.session) return false;1836  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1837  if (stale()) return false;1838  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1839  if (!entry?.keyHex) return false;18401841  const key = unhex(entry.keyHex);1842  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1843  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1844  const slot = await loadSlot(slotKey);1845  if (stale()) { zeroKey(key); return false; }1846  if (!slot) { await clearSessionKey(); return false; }18471848  const result = await tryDecryptSlotResult(slot, key, aadTag);1849  if (stale()) { zeroKey(key); return false; }1850  if (result.status !== "ok") { await clearSessionKey(); return false; }18511852  // Decrypted payload stays in locals until the final epoch-checked commit1853  // below; nothing observable is published before then.1854  const payload = result.payload;1855  await loadSettings();1856  if (stale()) { zeroKey(key); return false; }18571858  // Rehydrate the root key used by sync identity derivation from the password1859  // escrow stored on the current vault format.1860  let freshRoot: Uint8Array | null = null;1861  if (entry.mode === "real") {1862    freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1863    if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1864    if (!freshRoot) {1865      await lock();1866      return false;1867    }1868  }18691870  if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1871  if (freshRoot) {1872    // PB-26-213: encrypted pair-secret record + session copy, before publish.1873    await reconcilePairSecrets(freshRoot);1874    if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1875  } else {1876    // PB-26-213: a duress restore must never serve the real pair-secret map.1877    await dropSessionCache();1878    // Nor the real device identity.1879    await notifyIdentitySessionEnded();1880    if (stale()) { zeroKey(key); return false; }1881  }1882  derivedKey = key;1883  vaultMode = entry.mode;1884  if (entry.mode === "decoy") decoySyncTeardownHook?.();1885  entries = payload.entries;1886  tombstones = payload.tombstones;1887  duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1888  if (freshRoot) rootKey = freshRoot;1889  buildDomainIndexSync(entries);1890  touchAutoLock();18911892  await lockedSyncUnlockHook?.().catch((error) => {1893    console.warn("[Peach sync] locked inbox drain failed:", error);1894  });1895  if (stale()) return false;18961897  return true;1898}18991900export async function lock(): Promise<void> {1901  // A user-initiated lock must be durable across MV3 service-worker restarts.1902  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1903  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1904  // If the session key survived lock(), that restore would silently re-unlock1905  // the vault ~a minute after the user locked it — a lock that only held1906  // while the SW happened to stay alive. The background-sync convenience the1907  // session key once bought (sync runner re-waking to a restored vault) is1908  // not worth an unlocked vault the user never asked to unlock; when locked,1909  // sync-runner correctly reports VAULT_LOCKED instead.1910  vaultEpoch++;1911  if (derivedKey) {1912    zeroKey(derivedKey);1913    derivedKey = null;1914  }1915  if (rootKey) {1916    rootKey.fill(0);1917    rootKey = null;1918  }19191920  entries = [];1921  tombstones = new Map();1922  domainIndex = new Map();1923  vaultMode = "real";1924  duressConfigured = false;1925  clearAutoLock();1926  await clearSessionKey();1927  // The decrypted identity is session state as well, so a locked vault must not1928  // leave one behind; clear it after the in-memory state is gone.1929  await notifyIdentitySessionEnded();1930  await clearRecoveryConfirmPending();1931}19321933export function isUnlocked(): boolean {1934  return derivedKey !== null;1935}1936/** True only while the active session is unlocked through the decoy slot. */1937export function isDecoyMode(): boolean {1938  return derivedKey !== null && vaultMode === "decoy";1939}19401941// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1942// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1943// always derives from scratch via Argon2id.1944export async function unlockWithKey(1945  masterKeyHex: string,1946): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1947  const epoch = vaultEpoch;1948  const stale = () => epoch !== vaultEpoch;1949  let masterKeyBytes: Uint8Array | null = null;1950  try {1951    masterKeyBytes = unhex(masterKeyHex);1952    const stored = await loadSlot(SLOT_A);1953    if (!stored) return { ok: false, error: "invalid_key" };1954    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1955    if (!payload || stale()) {1956      zeroKey(masterKeyBytes);1957      return { ok: false, error: "invalid_key" };1958    }19591960    derivedKey = masterKeyBytes;1961    vaultMode = "real";1962    entries = payload.entries;1963    tombstones = payload.tombstones;1964    duressConfigured = payload.duressConfigured;1965    buildDomainIndexSync(entries);1966    await loadSettings();1967    touchAutoLock();19681969    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1970    if (!rootKey) {1971      await lock();1972      return { ok: false, error: "invalid_key" };1973    }1974    await reconcilePairSecrets(rootKey);1975    if (stale()) {1976      await lock();1977      return { ok: false, error: "invalid_key" };1978    }1979    await saveSessionKey(masterKeyBytes, "real", epoch);1980    if (stale()) return { ok: false, error: "invalid_key" };1981    return { ok: true, entries };1982  } catch {1983    if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1984    return { ok: false, error: "invalid_key" };1985  }1986}19871988// --- Adoption duress-slot helpers (passthrough, no decrypt) ---19891990/**1991 * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1992 * duress is configured and slot B exists, so the inline snapshot can ship it1993 * to the receiver. Returns null otherwise. Never decrypts.1994 */1995export async function exportDuressSlotForAdoption(): Promise<string | null> {1996  if (!(await isDuressEnabled())) return null;1997  const slotB = await loadSlot(SLOT_B);1998  if (!slotB) return null;1999  return JSON.stringify(slotB);2000}20012002/**2003 * Adoption RECEIVER helper: installs a duress slot received from a source2004 * device. Validates the StoredVault shape (same guard as unlockFromCodex)2005 * and writes it to slot B. Throws on invalid shape so the caller can treat2006 * it as a non-fatal snapshot failure. Never decrypts.2007 */2008export async function installAdoptedDuressSlot(json: string): Promise<void> {2009  let parsed: StoredVault;2010  try {2011    const raw = JSON.parse(json) as unknown;2012    if (!hasValidVaultSchema(raw)) {2013      throw new Error("invalid_duress_slot_shape");2014    }2015    parsed = raw;2016  } catch (err) {2017    if (err instanceof SyntaxError) {2018      throw new Error("invalid_duress_slot_shape");2019    }2020    throw err;2021  }2022  await writeSlot(SLOT_B, parsed);2023}20242025/**2026 * Restore the vault from a parsed Peach Codex payload.2027 *2028 * The supplied passphrase is verified against the Codex's slotA *before* any2029 * storage mutation — wrong passphrases return without touching disk. On2030 * success, the current vault state is snapshotted (reason: "restore") so the2031 * user can revert via Settings -> Vault History, then slotA/slotB are written2032 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A2033 * regular `unlock()` follows so the rest of the SW sees the standard2034 * post-unlock state.2035 *2036 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex2037 * printed by a Pro user must always be restorable, even on a free device.2038 *2039 * Timing is not held constant here (in contrast to `unlock`): the Codex flow2040 * is an explicit user action with a known passphrase, not a passive lock2041 * screen, so the duress-vs-real timing side channel does not apply.2042 */2043export async function unlockFromCodex(params: {2044  slotA: string; // JSON string of StoredVault for slot A2045  slotB: string | null; // JSON string of StoredVault for slot B, or null2046  timestamp: number;2047  masterPassphrase: string;2048}): Promise<2049  | { ok: true; entryCount: number }2050  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" | "missing_root_escrow" }2051> {2052  let parsedSlotA: StoredVault;2053  let parsedSlotB: StoredVault | null = null;2054  try {2055    const rawA = JSON.parse(params.slotA) as unknown;2056    if (!hasValidVaultSchema(rawA)) {2057      return { ok: false, reason: "invalid_slot_format" };2058    }2059    parsedSlotA = rawA;2060    if (params.slotB !== null) {2061      const rawB = JSON.parse(params.slotB) as unknown;2062      if (!hasValidVaultSchema(rawB)) {2063        return { ok: false, reason: "invalid_slot_format" };2064      }2065      parsedSlotB = rawB;2066    }2067  } catch {2068    return { ok: false, reason: "invalid_slot_format" };2069  }20702071  // Verify the passphrase and the password-wrapped root escrow against the2072  // staged Slot A WITHOUT writing anything. Payload decoding is part of the2073  // trial decrypt, so unlock() cannot be the first place these checks run.2074  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));2075  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");2076  if (!trialPayload) {2077    zeroKey(trialKey);2078    return { ok: false, reason: "wrong_passphrase" };2079  }2080  const hasEscrow =2081    !!parsedSlotA.pw_root_ciphertext && !!parsedSlotA.pw_root_nonce && !!parsedSlotA.pw_root_tag;2082  if (!hasEscrow) {2083    zeroKey(trialKey);2084    return { ok: false, reason: "missing_root_escrow" };2085  }2086  const stagedRootKey = await deriveRootKeyFromDerivedKey(trialKey, parsedSlotA);2087  if (!stagedRootKey) {2088    zeroKey(trialKey);2089    return { ok: false, reason: "wrong_passphrase" };2090  }2091  zeroKey(stagedRootKey);2092  zeroKey(trialKey);20932094  // Stage the complete candidate pair in memory before touching storage.2095  const stagedSlotB = parsedSlotB ?? generateNoiseBlob(parsedSlotA.ciphertext.length / 2);20962097  // Drop the trial key; `unlock()` below derives its own copy. Holding two2098  // copies of the master key in memory at once would be a needless surface.20992100  // Snapshot the about-to-be-overwritten on-disk state so the user can revert2101  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.2102  await takeVaultSnapshot("restore");2103  const previousSlotA = await loadSlot(SLOT_A);2104  const previousSlotB = await loadSlot(SLOT_B);2105  let slotACommitted = false;2106  let slotBCommitted = false;2107  try {2108    await writeSlot(SLOT_A, parsedSlotA);2109    slotACommitted = true;2110    await writeSlot(SLOT_B, stagedSlotB);2111    slotBCommitted = true;2112    await chrome.storage.local.set({2113      peach_last_modified_at: String(params.timestamp),2114    });2115  } catch (err) {2116    if (slotBCommitted && previousSlotB) await writeSlot(SLOT_B, previousSlotB);2117    if (slotACommitted && previousSlotA) await writeSlot(SLOT_A, previousSlotA);2118    throw err;2119  }21202121  // Standard unlock path so domain index, settings, auto-lock, and2122  // recovery-key state all populate exactly as a normal unlock would.2123  const ok = await unlock(params.masterPassphrase);2124  if (!ok) {2125    // Should be unreachable — the staged candidate passed the same checks.2126    return { ok: false, reason: "wrong_passphrase" };2127  }2128  return { ok: true, entryCount: getEntries().length };2129}21302131// Returns the current in-memory derived key as a hex string. Only the PIN2132// unlock path needs this value, and it reads module state directly; this2133// accessor exists so unlockWithKey's root-key rehydration is testable.2134export /* @testable */ function getDerivedKeyHex(): string {2135  if (!derivedKey) throw new Error("vault_not_unlocked");2136  return hex(derivedKey);2137}21382139/**2140 * Returns the vault's stable root key as hex, or null when locked / not yet2141 * populated. The root key is random per-vault, generated at creation, escrowed2142 * under the recovery words, and re-wrapped (not regenerated) on every password2143 * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.2144 *2145 * This is the correct input for sync identity derivation (vault_id, the Noise2146 * NK static key, the Ed25519 manifest signing key, and per-entry envelope2147 * keys): paired devices keep the same identity and can still decrypt each2148 * other's entries after either side rotates its master password (SEV-2-B).2149 *2150 * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED2151 * instead of crashing; rootKey is populated on every supported real-mode2152 * unlock via deriveRootKeyFromDerivedKey.2153 */2154export function getRootKeyHex(): string | null {2155  return rootKey ? hex(rootKey) : null;2156}21572158export function getEntries(): VaultEntry[] {2159  if (!derivedKey) throw new Error("vault_locked");2160  return entries.filter((entry) => !entry.deletedAt);2161}21622163const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;21642165export function getIdentities(): SavedIdentity[] {2166  if (!derivedKey) throw new Error("vault_locked");2167  const active = entries.filter((e) => !e.deletedAt && e.type === "login");21682169  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();2170  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();21712172  for (const entry of active) {2173    const domain = extractDomain(entry.url ?? "") ?? "";2174    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;21752176    function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {2177      const existing = map.get(v);2178      if (!existing || modifiedAt > existing.lastUsed) {2179        map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });2180      } else {2181        map.set(v, { ...existing, count: existing.count + 1 });2182      }2183    }21842185    const uVal = entry.username?.trim();2186    if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);21872188    // Also count the dedicated email field — entries created via the inline2189    // popup new-entry form store email here rather than in username.2190    const eVal = entry.email?.trim();2191    if (eVal) tally(emailMap, eVal);2192  }21932194  const toIdentity = (type: "email" | "username") =>2195    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({2196      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,2197    });21982199  return [2200    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2201    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2202  ];2203}22042205export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2206  if (!derivedKey) throw new Error("vault_locked");22072208  const now = Date.now();2209  const added: VaultEntry[] = [];2210  for (const draft of drafts) {2211    try {2212      const sanitized = sanitizeEntryDraft(draft);2213      const nextEntry: VaultEntry = {2214        ...sanitized,2215        id: crypto.randomUUID(),2216        createdAt: now,2217        modifiedAt: monotonicEntryTimestamp(),2218      };2219      entries.push(nextEntry);2220      added.push(nextEntry);2221    } catch {2222      // Skip invalid drafts silently — caller can see diff between input and output length2223    }2224  }22252226  if (added.length > 0) {2227    await persistEntries();2228  }22292230  return added;2231}22322233/**2234 * Restore a set of entries in full fidelity — preserves id, createdAt,2235 * modifiedAt, and every other field unlike `batchAddEntries` which2236 * assigns fresh identity. Used by restore paths where the backup is the2237 * authoritative source of truth. `mode` controls how entries already in2238 * the vault are treated.2239 */2240export async function restoreEntries(2241  incoming: VaultEntry[],2242  mode: "replace" | "merge"2243): Promise<{ added: number; updated: number; skipped: number }> {2244  if (!derivedKey) throw new Error("vault_locked");22452246  // A replace with zero incoming entries is almost certainly a caller bug2247  // (e.g. an import that silently dropped everything). Refuse rather than2248  // wipe the vault.2249  if (mode === "replace" && incoming.length === 0) {2250    throw new Error("refuse to replace with empty entry set");2251  }22522253  let added = 0;2254  let updated = 0;2255  let skipped = 0;22562257  if (mode === "replace") {2258    entries = [];2259    for (const entry of incoming) {2260      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2261      if (!normalized) {2262        skipped += 1;2263        continue;2264      }2265      entries.push(normalized);2266      if (normalized.deletedAt) {2267        tombstones.set(2268          normalized.id,2269          Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2270        );2271      } else {2272        tombstones.delete(normalized.id);2273      }2274      added += 1;2275    }2276  } else {2277    const byId = new Map<string, VaultEntry>();2278    for (const e of entries) byId.set(e.id, e);22792280    for (const entry of incoming) {2281      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2282      if (!normalized) {2283        skipped += 1;2284        continue;2285      }2286      const existing = byId.get(normalized.id);2287      if (!existing) {2288        entries.push(normalized);2289        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2290        else tombstones.delete(normalized.id);2291        added += 1;2292      } else if (normalized.modifiedAt > existing.modifiedAt) {2293        const idx = entries.findIndex((e) => e.id === existing.id);2294        if (idx !== -1) {2295          entries[idx] = normalized;2296          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2297          else tombstones.delete(normalized.id);2298          updated += 1;2299        }2300      } else {2301        skipped += 1;2302      }2303    }2304  }23052306  await persistEntries("import");2307  return { added, updated, skipped };2308}23092310/**2311 * A Peach entry id is a UUID. A restored or imported file carries whatever id2312 * its author used, and no reader bounds it, so one oversized id would ride2313 * into every future sync frame and be rejected there — a permanent, opaque2314 * failure on both platforms, caused by data that was already local.2315 *2316 * The action is mint, not reject: this is the last point where an id is still2317 * ours to choose, so the worst outcome is one duplicate if the same vault is2318 * later restored beside a device still holding the old id. Rejecting would2319 * lose the user's import.2320 *2321 * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries2322 * also calls. Sync has to adopt a peer's id verbatim — that is the only thing2323 * keeping two devices agreed on which entry they are discussing — so reminting2324 * there would fork identity on every device, on every round.2325 */2326const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;23272328function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2329  if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2330  return { ...entry, id: crypto.randomUUID() };2331}23322333/**2334 * Validate + normalise a VaultEntry coming from a backup, import, or sync2335 * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies2336 * only known VaultEntry fields (unknown/attacker keys are dropped), and2337 * nested `passkey`/`canary` shapes are rebuilt field-by-field, never2338 * spread. Returns null for values the live vault shouldn't accept.2339 */2340function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2341  if (!entry || typeof entry !== "object") return null;2342  const title = typeof entry.title === "string" ? entry.title.trim() : "";2343  if (!title) return null;2344  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2345  const now = Date.now();2346  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2347  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;23482349  // normalizeEntry whitelists every known VaultEntry field before the entry2350  // reaches the live vault.2351  const normalized = normalizeEntry({2352    ...entry,2353    id,2354    title,2355    type: entry.type ?? "login",2356    createdAt,2357    modifiedAt,2358  });23592360  if (normalized.passkey) {2361    normalized.passkey = sanitizePasskey(normalized.passkey);2362  }2363  if (normalized.canary) {2364    normalized.canary = sanitizeCanary(normalized.canary);2365  }2366  // PCH-01-016: bound tags on the restore path too, so both import funnels agree.2367  normalized.tags = sanitizeTags(normalized.tags);23682369  // Login entries from backup must carry a password (or be passkey-only,2370  // in which case the passkey sub-object replaces the password requirement).2371  if (2372    normalized.type === "login" &&2373    !normalized.passkey &&2374    typeof normalized.password !== "string"2375  ) {2376    return null;2377  }2378  if (entry.passkey && !normalized.passkey) {2379    // Entry claimed a passkey but the shape didn't validate — refuse the2380    // whole entry rather than silently importing a credential-less husk.2381    return null;2382  }23832384  return normalized;2385}23862387/**2388 * Rebuild a PasskeyData object from untrusted input, field by field.2389 * Returns undefined when required fields are missing/mistyped — a rogue2390 * passkey credential must not activate via import (MV-6 attack scenario).2391 */2392function sanitizePasskey(pk: unknown): PasskeyData | undefined {2393  if (!pk || typeof pk !== "object") return undefined;2394  const r = pk as Record<string, unknown>;2395  if (2396    typeof r.rpId !== "string" || !r.rpId ||2397    typeof r.credentialId !== "string" || !r.credentialId ||2398    typeof r.publicKeyCose !== "string" ||2399    typeof r.userHandle !== "string" ||2400    typeof r.userName !== "string"2401  ) {2402    return undefined;2403  }2404  return {2405    rpId: r.rpId,2406    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2407    credentialId: r.credentialId,2408    publicKeyCose: r.publicKeyCose,2409    privateKeyJwk:2410      r.privateKeyJwk && typeof r.privateKeyJwk === "object"2411        ? (r.privateKeyJwk as JsonWebKey)2412        : undefined,2413    userHandle: r.userHandle,2414    userName: r.userName,2415    userDisplayName:2416      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2417    algorithm: r.algorithm === -8 ? -8 : -7,2418    signCount: typeof r.signCount === "number" ? r.signCount : 0,2419    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2420    backupEligible: r.backupEligible === true,2421    backupState: r.backupState === true,2422    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2423    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2424    transports: Array.isArray(r.transports)2425      ? r.transports.filter((t): t is string => typeof t === "string")2426      : undefined,2427    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2428  };2429}24302431/**2432 * Rebuild a Canary object from untrusted input. The webhook must be a2433 * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing2434 * exfiltration endpoint that the canary subsystem will POST entry titles2435 * to (MV-6 attack scenario). Invalid → the canary is dropped, not the2436 * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire2437 * time (isAllowedCanaryWebhookUrl), so this import-time check is the2438 * outer of two gates.2439 */2440function sanitizeCanary(c: unknown): Canary | undefined {2441  if (!c || typeof c !== "object") return undefined;2442  const r = c as Record<string, unknown>;2443  if (typeof r.webhookUrl !== "string") return undefined;2444  let url: URL;2445  try {2446    url = new URL(r.webhookUrl);2447  } catch {2448    return undefined;2449  }2450  if (url.protocol !== "https:") return undefined;2451  return {2452    webhookUrl: url.toString(),2453    label: typeof r.label === "string" ? r.label : undefined,2454    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2455    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2456  };2457}24582459/**2460 * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted.2461 * Keep only non-empty strings, cap each at 256 chars, and cap the count at 2552462 * (the same per-entry ceiling the export codec enforces, peachPayload.ts).2463 * Non-array or all-invalid input collapses to undefined.2464 */2465function sanitizeTags(tags: unknown): string[] | undefined {2466  if (!Array.isArray(tags)) return undefined;2467  const out: string[] = [];2468  for (const t of tags) {2469    if (typeof t !== "string") continue;2470    const trimmed = t.trim();2471    if (!trimmed) continue;2472    out.push(trimmed.slice(0, 256));2473    if (out.length >= 255) break;2474  }2475  return out.length > 0 ? out : undefined;2476}24772478// Soft-deletes every non-deleted entry in one call. The literal string2479// argument is a tripwire against accidental calls — any caller that would2480// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this2481// to a boolean.2482export async function clearVaultEntries(2483  confirmation: "REPLACE_ALL"2484): Promise<void> {2485  if (confirmation !== "REPLACE_ALL") {2486    throw new Error("clearVaultEntries requires explicit confirmation");2487  }2488  if (!derivedKey) throw new Error("vault_locked");2489  for (const entry of entries) {2490    if (!entry.deletedAt) {2491      const stamp = monotonicEntryTimestamp(Math.max(2492        entry.modifiedAt,2493        tombstones.get(entry.id) ?? 0,2494      ));2495      entry.deletedAt = stamp;2496      entry.modifiedAt = stamp;2497      tombstones.set(entry.id, stamp);2498    }2499  }2500  await persistEntries();2501}25022503export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2504  if (!derivedKey) throw new Error("vault_locked");25052506  const sanitized = sanitizeEntryDraft(entry);2507  const now = Date.now();2508  const stamp = monotonicEntryTimestamp();2509  const nextEntry: VaultEntry = {2510    ...sanitized,2511    id: crypto.randomUUID(),2512    createdAt: now,2513    modifiedAt: stamp,2514    // Stamp passwordChangedAt at creation so age tracking is accurate from2515    // day one. Non-login entries carry the same stamp harmlessly.2516    passwordChangedAt: sanitized.password ? stamp : undefined,2517  };25182519  // Clone-and-replace so a failed persist rolls back the in-memory state2520  // (COR-008). Without this, a quota-full or storage error leaves the2521  // module-level `entries` ahead of what's actually on disk.2522  const rollback = entries;2523  entries = [...entries, nextEntry];2524  try {2525    await persistEntries();2526  } catch (err) {2527    entries = rollback;2528    throw err;2529  }25302531  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)2532  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2533    checkBreach(nextEntry.password).then(async (count) => {2534      if (count > 0) {2535        const idx = entries.findIndex((e) => e.id === nextEntry.id);2536        if (idx !== -1) {2537          entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2538          await persistEntries();2539        }2540      }2541    });2542  }25432544  fireSyncTrigger("vault_change");25452546  return nextEntry;2547}25482549/**2550 * Run the passkey-merge pass on the live in-memory entries and persist if2551 * anything changed. Called immediately after any passkey registration so that2552 * a same-domain TOTP-only entry already in the vault gets consolidated right2553 * away instead of waiting until the next lock/unlock cycle.2554 */2555export async function consolidatePasskeyEntries(): Promise<void> {2556  if (!derivedKey) return;2557  const { merged } = mergePasskeyOnlyIntoLogins(entries);2558  if (merged) {2559    await persistEntries();2560  }2561}25622563export async function updateEntry(2564  id: string,2565  patch: Partial<VaultEntry>2566): Promise<void> {2567  if (!derivedKey) throw new Error("vault_locked");25682569  const idx = entries.findIndex((entry) => entry.id === id);2570  if (idx === -1 || entries[idx].deletedAt) {2571    throw new Error("entry_not_found");2572  }25732574  const current = entries[idx];2575  const sanitizedPatch = sanitizeEntryPatch(patch);2576  // Refresh passwordChangedAt only when the password value actually changes —2577  // a metadata-only update (title, tags, etc.) must not reset the age clock2578  // that drives the Pro health rotation recommendation.2579  const passwordChanged =2580    "password" in patch &&2581    typeof sanitizedPatch.password === "string" &&2582    sanitizedPatch.password !== current.password;2583  const stamp = monotonicEntryTimestamp(current.modifiedAt);2584  // When the password actually changes, push the outgoing one onto the history2585  // (newest first) so the user can reveal/copy/restore it later. We record the2586  // age it carried (passwordChangedAt, falling back to createdAt) rather than2587  // `now`, so the list reflects when each password was last in effect. Only2588  // capture a non-empty prior value — clearing a password leaves no secret to2589  // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)2590  // to avoid noise.2591  const priorPassword = current.password;2592  const shouldCapture =2593    passwordChanged &&2594    typeof priorPassword === "string" &&2595    priorPassword.length > 0 &&2596    current.passwordHistory?.[0]?.password !== priorPassword;2597  const nextHistory = shouldCapture2598    ? [2599        { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2600        ...(current.passwordHistory ?? []),2601      ].slice(0, PASSWORD_HISTORY_MAX)2602    : current.passwordHistory;2603  entries[idx] = {2604    ...current,2605    ...sanitizedPatch,2606    modifiedAt: stamp,2607    ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2608  };2609261026112612  // Card entries must retain their card number2613  if (entries[idx].type === "card" && !entries[idx].cardNumber) {2614    entries[idx] = current;2615    throw new Error("card number is required for card entries");2616  }26172618  // Roll the in-memory entry back to its pre-edit state if the disk write2619  // fails (e.g. storage quota). Without this the module-level `entries` array2620  // would hold an edit that never landed on disk, diverging from the vault2621  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.2622  try {2623    await persistEntries();2624  } catch (err) {2625    entries[idx] = current;2626    throw err;2627  }26282629  // Breach check when password changes: clear immediately so old breach doesn't linger,2630  // then async-check the new password and only re-set if it's also found.2631  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2632    entries[idx] = { ...entries[idx], breach: undefined };2633    await persistEntries();2634    const password = entries[idx].password;2635    if (password) {2636      checkBreach(password).then(async (count) => {2637        const i = entries.findIndex((e) => e.id === id);2638        if (i !== -1 && count > 0) {2639          entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2640          await persistEntries();2641        }2642      });2643    }2644  }26452646  fireSyncTrigger("vault_change");2647}26482649export async function deleteEntry(id: string): Promise<void> {2650  if (!derivedKey) throw new Error("vault_locked");26512652  const entry = entries.find((item) => item.id === id);2653  if (!entry || entry.deletedAt) {2654    throw new Error("entry_not_found");2655  }26562657  // Tombstone timestamp must be strictly greater than the entry's last2658  // modification so the deletion wins LWW against any in-flight edit, even2659  // under clock skew (SEV-2-E).2660  const previousModifiedAt = entry.modifiedAt;2661  const previousTombstone = tombstones.get(id);2662  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2663  entry.deletedAt = stamp;2664  entry.modifiedAt = stamp;2665  tombstones.set(id, stamp);2666  // Roll the tombstone back if the disk write fails, so a quota error doesn't2667  // leave the entry deleted in memory but alive on disk (SEV-3-A).2668  try {2669    await persistEntries();2670  } catch (err) {2671    delete entry.deletedAt;2672    entry.modifiedAt = previousModifiedAt;2673    if (previousTombstone === undefined) tombstones.delete(id);2674    else tombstones.set(id, previousTombstone);2675    throw err;2676  }2677  fireSyncTrigger("vault_change");2678}26792680export function getDeletedEntries(): VaultEntry[] {2681  if (!derivedKey) return [];2682  return entries.filter((e) => !!e.deletedAt);2683}26842685/** Return all durable deletion timestamps, including purged full entries. */2686export function getTombstoneManifest(): Record<string, number> {2687  if (!derivedKey) return {};2688  return Object.fromEntries(tombstones);2689}26902691/**2692 * Returns the vault's monotonic version — the `peach_last_modified_at` value2693 * stamped on every user-initiated write. Remote (sync) writes do NOT advance2694 * this version so a sync round never cascades into a second outbound sync.2695 */2696export async function getVaultVersion(): Promise<number> {2697  try {2698    const raw = await chrome.storage.local.get("peach_last_modified_at");2699    const v = (raw as Record<string, unknown>).peach_last_modified_at;2700    if (typeof v === "number") return v;2701    if (typeof v === "string" && v !== "") {2702      const n = Number.parseInt(v, 10);2703      return Number.isFinite(n) ? n : 0;2704    }2705    return 0;2706  } catch {2707    return 0;2708  }2709}27102711/**2712 * Apply a batch of remote entries + tombstones from a sync peer.2713 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when2714 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and2715 * persists with reason `sync_received` — does NOT fire the sync dispatcher, so2716 * applying a sync round cannot cascade into another outbound sync.2717 *2718 * `applied` counts entries that actually changed local state (new add,2719 * overwrite-by-newer, or new tombstone). `received` is the raw payload size2720 * the caller can surface in the sync result UI.2721 */2722export async function applyMergedRemoteEntries(2723  remoteEntries: VaultEntry[],2724  remoteTombstones: Record<string, number>,2725): Promise<{ received: number; applied: number; conflicts: number }> {2726  // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices2727  // concurrently edit different fields of the same entry, the one with the2728  // newer modifiedAt wins the whole entry and the other device's field edits2729  // are dropped silently. This is the deliberate trade-off for a simple,2730  // conflict-free merge — per-field CRDT merge is out of scope. Guidance for2731  // users: edit a given entry on one device at a time.2732  if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");27332734  const received = remoteEntries.length + Object.keys(remoteTombstones).length;2735  let applied = 0;2736  let conflicts = 0;27372738  const byId = new Map<string, VaultEntry>();2739  for (const e of entries) byId.set(e.id, e);27402741  const mergeNow = Date.now();2742  const remoteTombstoneTimes = new Map<string, number>();2743  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2744    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2745      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2746    }2747  }27482749  for (const remote of remoteEntries) {2750    const sanitized = sanitizeRestoredEntry(remote);2751    if (!sanitized) continue;2752    // HV-1a: a far-future remote modifiedAt must not permanently pin a2753    // malicious value against honest local edits. Clamp BEFORE any2754    // comparison against local timestamps.2755    sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2756    if (typeof sanitized.deletedAt === "number") {2757      sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2758    }2759    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2760    const localTombstoneTs = tombstones.get(sanitized.id);2761    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2762    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2763      continue;2764    }2765    const local = byId.get(sanitized.id);2766    if (!local) {2767      entries.push(sanitized);2768      byId.set(sanitized.id, sanitized);2769      tombstones.delete(sanitized.id);2770      applied += 1;2771    } else if (sanitized.modifiedAt > local.modifiedAt) {2772      const idx = entries.findIndex((e) => e.id === local.id);2773      if (idx !== -1) {2774        entries[idx] = sanitized;2775        byId.set(sanitized.id, sanitized);2776        tombstones.delete(sanitized.id);2777        applied += 1;2778        if (!local.deletedAt) conflicts += 1;2779      }2780    }2781  }27822783  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2784    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2785    // HV-1a/1c: clamp the tombstone timestamp before every comparison.2786    const deletedAt = remoteTombstoneTimes.get(id)!;2787    const knownTombstone = tombstones.get(id);2788    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2789    const local = byId.get(id);2790    if (!local) {2791      tombstones.set(id, deletedAt);2792      applied += 1;2793      continue;2794    }2795    if (local.deletedAt && local.deletedAt >= deletedAt) continue;2796    if (deletedAt < local.modifiedAt) continue;2797    const idx = entries.findIndex((e) => e.id === id);2798    if (idx !== -1) {2799      entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2800      tombstones.set(id, deletedAt);2801      applied += 1;2802    }2803  }28042805  if (applied > 0 && vaultMode === "real") {2806    await persistEntries("sync_received");2807    buildDomainIndexSync(entries);2808  }2809  return { received, applied, conflicts };2810}28112812export async function restoreEntry(id: string): Promise<void> {2813  if (!derivedKey) throw new Error("vault_locked");2814  const entry = entries.find((e) => e.id === id && e.deletedAt);2815  if (!entry) throw new Error("deleted entry not found");2816  const previousDeletedAt = entry.deletedAt;2817  const previousModifiedAt = entry.modifiedAt;2818  const previousTombstone = tombstones.get(id);2819  delete entry.deletedAt;2820  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2821    entry.modifiedAt,2822    previousTombstone ?? 0,2823  ));2824  tombstones.delete(id);2825  try {2826    await persistEntries();2827  } catch (err) {2828    entry.deletedAt = previousDeletedAt;2829    entry.modifiedAt = previousModifiedAt;2830    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2831    throw err;2832  }2833  buildDomainIndexSync(entries);2834}28352836export async function purgeEntry(id: string): Promise<void> {2837  if (!derivedKey) throw new Error("vault_locked");2838  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2839  if (idx === -1) throw new Error("deleted entry not found");2840  const entry = entries[idx]!;2841  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2842  entries.splice(idx, 1);2843  await persistEntries();2844}28452846/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */2847export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2848  if (!derivedKey || retentionMs === 0) return 0;2849  const cutoff = Date.now() - retentionMs;2850  const before = entries.length;2851  for (const entry of entries) {2852    if (entry.deletedAt && entry.deletedAt < cutoff) {2853      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2854    }2855  }2856  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2857  const purged = before - entries.length;2858  if (purged > 0) await persistEntries();2859  return purged;2860}28612862export function queryDomain(domain: string): VaultEntry[] {2863  return domainIndex.get(domain) ?? [];2864}28652866export function getEntryById(id: string): VaultEntry | undefined {2867  return entries.find((entry) => entry.id === id && !entry.deletedAt);2868}28692870async function persistSlotA(2871  reason: VaultSnapshot["reason"] = "local_save",2872): Promise<StoredVault> {2873  if (!derivedKey) throw new Error("vault_locked");2874  const epoch = vaultEpoch;2875  const throwIfStale = () => {2876    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2877  };28782879  const slotA = await loadSlot(SLOT_A);2880  throwIfStale();2881  if (!slotA) throw new Error("missing slot A");28822883  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures2884  // are swallowed by takeVaultSnapshot — history must never block a save.2885  await takeVaultSnapshot(reason);2886  throwIfStale();28872888  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2889  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2890  throwIfStale();28912892  // Preserve existing recovery escrow fields.2893  const nextSlotA: StoredVault = {2894    ...slotA,2895    nonce,2896    ciphertext,2897    tag,2898    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot2899  };29002901  // Slot A (the real vault) is now committed. Wrap every later write so a2902  // mid-persist failure (e.g. a storage error after slot A landed) restores the2903  // prior slot A before rethrowing. Without this, a "failed" save that already2904  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/2905  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces2906  // as a duplicate on the next unlock (observed under storage-quota pressure).2907  let slotACommitted = false;2908  try {2909    throwIfStale();2910    await writeSlot(SLOT_A, nextSlotA);2911    slotACommitted = true;2912    throwIfStale();29132914    // When duress is not configured, keep slot B size-matched with slot A by2915    // regenerating its noise. When duress IS configured, leave slot B alone —2916    // we don't have the decoy key to re-encrypt it. This is a known trade-off.2917    if (!duressConfigured) {2918      throwIfStale();2919      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2920      throwIfStale();2921    }2922    // Only advance the vault version for locally-originated writes. Entries2923    // applied from a sync peer ("sync_received") must NOT bump the counter —2924    // otherwise applying a remote round makes us look newer than the peer and2925    // triggers a redundant outbound sync, defeating the no-op optimisation2926    // and risking a ping-pong between two devices (SEV-2-C).2927    if (reason !== "sync_received") {2928      throwIfStale();2929      await stampLocalVaultMutation();2930      throwIfStale();2931    }2932  } catch (err) {2933    // Best-effort restore — the prior slot A is smaller-or-equal, so this write2934    // fits even when the failure was a quota overflow.2935    if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2936    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2937    throw err;2938  }29392940  return nextSlotA;2941}29422943async function persistSlotB(): Promise<void> {2944  if (!derivedKey) throw new Error("vault_locked");29452946  const slotB = await loadSlot(SLOT_B);2947  if (!slotB) throw new Error("missing slot B");29482949  // Decoy plaintext always reports duressConfigured=false — the decoy user2950  // sees a clean slate. persistSlotB only runs in decoy mode, which only2951  // exists once duress is configured, so pad to the fixed duress ceiling to2952  // keep slot B's length equal to slot A's (audit MEDIUM — deniability).2953  const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2954  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");29552956  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2957  // Symmetric with persistSlotA: if the version stamp fails after the decoy2958  // slot committed, restore the prior slot B so a failed decoy-mode save can't2959  // leave disk ahead of the in-memory rollback.2960  try {2961    await stampLocalVaultMutation();2962  } catch (err) {2963    await writeSlot(SLOT_B, slotB).catch(() => {});2964    throw err;2965  }2966}29672968async function persistEntries(2969  reason: VaultSnapshot["reason"] = "local_save",2970): Promise<void> {2971  if (!derivedKey) throw new Error("vault_locked");29722973  // Reconcile direct soft-delete callers while restoring the previous map if2974  // storage rejects the save.2975  const previousTombstones = tombstones;2976  tombstones = new Map(tombstones);2977  reconcileTombstones();2978  try {2979    if (vaultMode === "decoy") {2980      await persistSlotB();2981    } else {2982      await persistSlotA(reason);2983    }2984  } catch (err) {2985    tombstones = previousTombstones;2986    throw err;2987  }2988  buildDomainIndexSync(entries);2989  await refreshLockedSyncState();2990}29912992// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage2993// boundary. Schemeless values (e.g. "github.com") are inert and kept — they2994// pre-date this guard in existing data. Unparseable values are kept: the2995// reviewed render paths never execute them (favicons via _favicon/, domains2996// via tldts); this is defense-in-depth against future sinks.2997function allowHttpUrl(url: string | undefined): string {2998  const trimmed = url?.trim() ?? "";2999  if (!trimmed) return "";3000  try {3001    const parsed = new URL(trimmed);3002    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";3003  } catch {3004    // No scheme to reject.3005  }3006  return trimmed;3007}30083009function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {3010  if (!Array.isArray(values)) return undefined;3011  const seen = new Set<string>();3012  const cleaned: string[] = [];3013  for (const value of values) {3014    const trimmed = typeof value === "string" ? value.trim() : "";3015    if (!trimmed) continue;3016    if (!allowHttpUrl(trimmed)) continue;3017    const key = trimmed.toLowerCase();3018    if (seen.has(key)) continue;3019    seen.add(key);3020    cleaned.push(trimmed);3021  }3022  return cleaned.length > 0 ? cleaned : undefined;3023}30243025function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {3026  const title = entry.title.trim();3027  const type: VaultEntry["type"] = entry.type ?? "login";30283029  if (!title) throw new Error("title is required");30303031  if (entry.passkey) {3032    const p = entry.passkey;3033    if (3034      !p.rpId ||3035      !p.credentialId ||3036      !p.publicKeyCose ||3037      !p.userHandle ||3038      !p.userName ||3039      (p.algorithm !== -7 && p.algorithm !== -8)3040    ) {3041      throw new Error("invalid passkey credential");3042    }3043  }3044  if (type === "card") {3045    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {3046      throw new Error("cardholder name and card number are required");3047    }3048  }30493050  return {3051    type,3052    title,3053    username: type === "login" ? entry.username?.trim() ?? "" : undefined,3054    email: type === "login" ? entry.email?.trim() || undefined : undefined,3055    password: type === "login" ? entry.password : undefined,3056    url: type === "login" ? allowHttpUrl(entry.url) : undefined,3057    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,3058    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,3059    // PCH-01-019: preserve non-default TOTP params through batch-add/import so3060    // they are not silently stripped (they fall back to SHA-1/6/30 when absent).3061    totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,3062    totpDigits: type === "login" ? entry.totpDigits : undefined,3063    totpPeriod: type === "login" ? entry.totpPeriod : undefined,3064    notes: entry.notes?.trim() || undefined,3065    breach: type === "login" ? entry.breach : undefined,3066    // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags3067    // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here.3068    canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,3069    tags: sanitizeTags(entry.tags),3070    passkey: entry.passkey,3071    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,3072    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,3073    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,3074    expYear: type === "card" ? entry.expYear?.trim() : undefined,3075    cvc: type === "card" ? entry.cvc?.trim() : undefined,3076    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,3077    network: type === "card" ? entry.network : undefined,3078    last4: type === "card" ? entry.last4?.trim() : undefined,3079  };3080}30813082function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {3083  const nextPatch: Partial<VaultEntry> = {};30843085  if ("type" in patch) {3086    nextPatch.type = patch.type;3087  }30883089  if ("title" in patch) {3090    const title = patch.title?.trim() ?? "";3091    if (!title) throw new Error("title is required");3092    nextPatch.title = title;3093  }30943095  if ("url" in patch) {3096    nextPatch.url = allowHttpUrl(patch.url);3097  }30983099  if ("additionalUrls" in patch) {3100    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];3101    const seen = new Set<string>();3102    const cleaned: string[] = [];3103    for (const value of incoming) {3104      const trimmed = typeof value === "string" ? value.trim() : "";3105      if (!trimmed || !allowHttpUrl(trimmed)) continue;3106      const key = trimmed.toLowerCase();3107      if (seen.has(key)) continue;3108      seen.add(key);3109      cleaned.push(trimmed);3110    }3111    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;3112  }31133114  if ("username" in patch) {3115    nextPatch.username = patch.username?.trim() ?? "";3116  }31173118  if ("email" in patch) {3119    nextPatch.email = patch.email?.trim() || undefined;3120  }31213122  if ("password" in patch) {3123    const password = patch.password ?? "";3124    // Allow clearing a password (e.g. on passkey-only entries)3125    nextPatch.password = password || undefined;3126  }31273128  if ("totp" in patch) {3129    nextPatch.totp = patch.totp?.trim() || undefined;3130  }31313132  if ("totpAlgorithm" in patch) {3133    nextPatch.totpAlgorithm = patch.totpAlgorithm;3134  }31353136  if ("totpDigits" in patch) {3137    nextPatch.totpDigits = patch.totpDigits;3138  }31393140  if ("totpPeriod" in patch) {3141    nextPatch.totpPeriod = patch.totpPeriod;3142  }31433144  if ("notes" in patch) {3145    nextPatch.notes = patch.notes?.trim() || undefined;3146  }31473148  if ("breach" in patch) {3149    nextPatch.breach = patch.breach;3150  }31513152  if ("canary" in patch) {3153    // PCH-02-001: route update patches through the same canary whitelist used3154    // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.3155    nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;3156  }31573158  if ("passkey" in patch) {3159    // A patch with `passkey: undefined` removes the credential from the entry3160    // (used by "remove passkey" without deleting the whole entry).3161    if (patch.passkey === undefined || patch.passkey === null) {3162      nextPatch.passkey = undefined;3163    } else {3164      const sanitized = sanitizePasskey(patch.passkey);3165      if (!sanitized) throw new Error("invalid passkey credential");3166      nextPatch.passkey = sanitized;3167    }3168  }31693170  if ("tags" in patch) {3171    // PCH-02-001: bound tags exactly like both import funnels.3172    nextPatch.tags = sanitizeTags(patch.tags);3173  }31743175  if ("cardholderName" in patch) {3176    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;3177  }3178  if ("cardNumber" in patch) {3179    const n = patch.cardNumber ?? "";3180    nextPatch.cardNumber = n || undefined;3181  }3182  if ("expMonth" in patch) {3183    nextPatch.expMonth = patch.expMonth?.trim() || undefined;3184  }3185  if ("expYear" in patch) {3186    nextPatch.expYear = patch.expYear?.trim() || undefined;3187  }3188  if ("cvc" in patch) {3189    nextPatch.cvc = patch.cvc?.trim() || undefined;3190  }3191  if ("cardNickname" in patch) {3192    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;3193  }3194  if ("network" in patch) {3195    nextPatch.network = patch.network;3196  }3197  if ("last4" in patch) {3198    nextPatch.last4 = patch.last4?.trim() || undefined;3199  }32003201  return nextPatch;3202}32033204function normalizeEntry(entry: VaultEntry): VaultEntry {3205  const base: VaultEntry = {3206    id: entry.id,3207    type: entry.type,3208    title: entry.title,3209    username: entry.username,3210    email: entry.email,3211    password: entry.password,3212    url: entry.url,3213    additionalUrls:3214      "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3215        ? entry.additionalUrls3216        : undefined,3217    totp: entry.totp,3218    // PCH-01-019: carry TOTP params through the normalize/restore path too.3219    totpAlgorithm: entry.totpAlgorithm,3220    totpDigits: entry.totpDigits,3221    totpPeriod: entry.totpPeriod,3222    notes: entry.notes,3223    breach: entry.breach,3224    // Preserve the password-age clock across lock/unlock. Without this the3225    // field is dropped on every decode and the advanced-health "aged password"3226    // signal silently resets to createdAt on each unlock (SEV-2-A).3227    passwordChangedAt:3228      "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3229    // Preserve past-password history across lock/unlock. normalizeEntry is a3230    // field whitelist, so an unlisted field is silently dropped on every decode3231    // (same class as the passwordChangedAt SEV-2-A above).3232    passwordHistory:3233      "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3234        ? entry.passwordHistory3235        : undefined,3236    canary: entry.canary,3237    passkey: entry.passkey,3238    createdAt: entry.createdAt,3239    modifiedAt: entry.modifiedAt,3240    deletedAt: entry.deletedAt,3241    tags: entry.tags,3242    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3243    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3244    expMonth: "expMonth" in entry ? entry.expMonth : undefined,3245    expYear: "expYear" in entry ? entry.expYear : undefined,3246    cvc: "cvc" in entry ? entry.cvc : undefined,3247    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3248    network: "network" in entry ? entry.network : undefined,3249    last4: "last4" in entry ? entry.last4 : undefined,3250  };32513252  return base;3253}325432553256// ---------------------------------------------------------------------------3257// Deduplication3258// ---------------------------------------------------------------------------32593260function entryDupeKey(e: VaultEntry): string {3261  if (e.type === "note") return `note|${e.title.toLowerCase()}`;3262  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins3263  // on different sites that happen to share a username are still distinct;3264  // two passkey credentials for the same RP+user are duplicates regardless3265  // of any login fields they may also carry.3266  if (e.passkey) {3267    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3268  }3269  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3270}32713272/** Returns the number of duplicate entries (those that would be removed). */3273export function countDuplicates(): number {3274  const best = new Map<string, VaultEntry>();3275  let dupeCount = 0;3276  for (const e of entries.filter((e) => !e.deletedAt)) {3277    const key = entryDupeKey(e);3278    const existing = best.get(key);3279    if (!existing) {3280      best.set(key, e);3281    } else {3282      dupeCount++;3283      // Keep the more recently modified one in the map3284      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3285        best.set(key, e);3286      }3287    }3288  }3289  return dupeCount;3290}32913292/**3293 * Soft-deletes duplicate entries, keeping the most recently modified copy of3294 * each group. Returns the number of entries moved to trash.3295 */3296export async function deduplicateEntries(): Promise<number> {3297  if (!derivedKey) throw new Error("vault_locked");3298  // Dedup reshapes slot B permanently under the decoy key, which is3299  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.3300  if (vaultMode === "decoy") {3301    throw new Error("deduplicate disabled in decoy mode");3302  }33033304  const best = new Map<string, VaultEntry>();3305  for (const e of entries.filter((en) => !en.deletedAt)) {3306    const key = entryDupeKey(e);3307    const existing = best.get(key);3308    if (!existing) {3309      best.set(key, e);3310    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3311      best.set(key, e);3312    }3313  }33143315  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3316  let removed = 0;3317  entries = entries.map((e) => {3318    if (!e.deletedAt && !keepIds.has(e.id)) {3319      removed++;3320      const deletedAt = monotonicEntryTimestamp(Math.max(3321        e.modifiedAt,3322        tombstones.get(e.id) ?? 0,3323      ));3324      tombstones.set(e.id, deletedAt);3325      return { ...e, deletedAt };3326    }3327    return e;3328  });33293330  if (removed > 0) await persistEntries();3331  return removed;3332}33333334// ---------------------------------------------------------------------------3335// Vault history — snapshot BEFORE every vault write so the saved blob is3336// what is about to be lost, not what was just written. Capped at 5. The3337// stored blobs are AES-256-GCM ciphertext under the master password, so3338// history adds zero security surface.3339// ---------------------------------------------------------------------------33403341const HISTORY_KEY = "peach_vault_history";3342const HISTORY_MAX = 5;33433344// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the3345// plaintext snapshot field cannot reveal exact vault size or single-entry change3346// deltas. Buckets widen as the vault grows so absolute leakage stays roughly3347// proportional rather than exact.3348function bucketEntryCount(n: number): number {3349  if (n <= 0) return 0;3350  if (n <= 10) return 10;3351  if (n <= 25) return 25;3352  if (n <= 50) return 50;3353  if (n <= 100) return 100;3354  if (n <= 250) return 250;3355  if (n <= 500) return 500;3356  return Math.ceil(n / 500) * 500;3357}33583359/**3360 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE3361 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost3362 * state. Identical-to-most-recent blobs are skipped to avoid filling history3363 * with no-op writes.3364 */3365export async function takeVaultSnapshot(3366  reason: VaultSnapshot["reason"],3367): Promise<void> {3368  // The history store is real-mode state by construction, so it must not be3369  // written from a decoy session either. The guard lives here rather than only3370  // at the call sites so the invariant survives a new caller (PB-26-915).3371  if (vaultMode === "decoy") return;3372  try {3373    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3374    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3375    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];33763377    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,3378    // pre-createVault). createVault skips snapshots intentionally — there is3379    // no prior state to preserve.3380    if (!slotA) return;33813382    const vault_a = JSON.stringify(slotA);3383    const vault_b = JSON.stringify(slotB ?? slotA);33843385    // Best-effort entry count: only meaningful when the in-memory state is3386    // unlocked AND in real mode. Decoy mode would leak the decoy entry3387    // count; locked mode has no entries to count. Both fall through to null.3388    // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage3389    // .local sits next to the AES-GCM blobs in plaintext, so an exact count would3390    // leak vault size and per-write deltas (the metadata padding hides this).3391    let entryCount: number | null = null;3392    if (derivedKey && vaultMode === "real") {3393      try {3394        entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3395      } catch {3396        entryCount = null;3397      }3398    }33993400    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3401    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3402      ? existingRaw3403      : { snapshots: [] };34043405    // Deduplication: if the most recent snapshot has byte-identical blobs,3406    // skip — saves the same vault N times in a row would otherwise burn the3407    // entire history window with no recovery value.3408    const latest = history.snapshots[0];3409    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3410      return;3411    }34123413    const snapshot: VaultSnapshot = {3414      id: crypto.randomUUID(),3415      savedAt: Date.now(),3416      reason,3417      vault_a,3418      vault_b,3419      entryCount,3420    };34213422    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3423    await chrome.storage.local.set({ [HISTORY_KEY]: history });3424  } catch (err) {3425    // Non-fatal — history must never block a save. A failed snapshot just3426    // means the user loses one rung of the safety ladder for this write.3427    // M4/QUA-006: log so the failure is visible; if it looks like a quota3428    // problem, notify the popup so the user can prune history themselves.3429    console.warn('[Peach] takeVaultSnapshot failed:', err);3430    if (String(err).includes('QUOTA') || String(err).includes('quota')) {3431      try {3432        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3433      } catch {3434        // popup may be closed; surfaced via console.warn above3435      }3436    }3437  }3438}34393440/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */3441export async function clearVaultHistory(): Promise<void> {3442  try {3443    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3444  } catch {3445    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().3446  }3447}34483449/** Read all snapshots, newest first, for the Settings vault history view. */3450export async function getVaultHistory(): Promise<VaultSnapshot[]> {3451  if (vaultMode === "decoy") return [];3452  const raw = await chrome.storage.local.get(HISTORY_KEY);3453  const value = (raw as Record<string, unknown>)[HISTORY_KEY];3454  return sanitizeHistory(value);3455}34563457/**3458 * Replace the on-disk vault with the chosen snapshot's blobs and lock the3459 * vault. The current state is itself snapshotted before replacement.3460 */3461export async function restoreVaultSnapshot(3462  snapshotId: string,3463): Promise<{ ok: true } | { ok: false; error: string }> {3464  if (vaultMode === "decoy") return { ok: false, error: "vault_locked" };3465  const history = await getVaultHistory();3466  const snapshot = history.find((s) => s.id === snapshotId);3467  if (!snapshot) return { ok: false, error: "snapshot_not_found" };34683469  const restoredA = parseHistorySlot(snapshot.vault_a);3470  const restoredB = parseHistorySlot(snapshot.vault_b);3471  if (!restoredA || !restoredB) return { ok: false, error: "snapshot_corrupt" };34723473  await takeVaultSnapshot("restore");3474  const current = await chrome.storage.local.get([SLOT_A, SLOT_B]);3475  const previousA = (current as Record<string, unknown>)[SLOT_A];3476  const previousB = (current as Record<string, unknown>)[SLOT_B];3477  try {3478    await chrome.storage.local.set({ [SLOT_A]: restoredA, [SLOT_B]: restoredB });3479    await stampLocalVaultMutation();3480  } catch {3481    try {3482      await chrome.storage.local.set({ [SLOT_A]: previousA, [SLOT_B]: previousB });3483    } catch {3484      // Preserve the restore failure if storage cannot complete the rollback.3485    }3486    return { ok: false, error: "snapshot_corrupt" };3487  }34883489  await lock();3490  return { ok: true };3491}34923493/**3494 * Rotate the vault's sync identity by generating a FRESH ROOT KEY and3495 * re-wrapping everything under it (MV-8). Because every sync credential —3496 * vault_id, the Noise static keypair, the Ed25519 manifest signing key,3497 * and the per-entry envelope keys — derives from the root key, rotation3498 * makes a lost/stolen device's retained key material useless. All paired3499 * devices must re-pair afterwards.3500 *3501 * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from3502 * the root key, so rotation INVALIDATES the old recovery phrase. The flow3503 * sets peach_recovery_pending so the popup re-presents the new words for3504 * confirmation, same as the initial recovery-word confirmation path.3505 */3506export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3507  if (!derivedKey || vaultMode !== "real") {3508    return { ok: false, error: "vault_locked" };3509  }3510  const slotA = await loadSlot(SLOT_A);3511  if (!slotA) return { ok: false, error: "missing_slot_a" };35123513  // 1. Fresh root key + recovery escrow (new words).3514  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3515    await generateAndWrapRootKey();35163517  // 2. Re-wrap the new root key under the current password-derived key.3518  const newRootKey = unhex(rawRootKey);3519  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);35203521  // 3. Snapshot, then single atomic slot write — both escrow wrappings of3522  //    the new root key land together (same transactional rule as3523  //    changePassword). Entries are untouched: they are sealed under the3524  //    password-derived key, not the root key.3525  await takeVaultSnapshot("local_save");3526  const nextSlotA: StoredVault = {3527    ...slotA,3528    recovery_salt: recoverySalt,3529    recovery_wrapped_root_key: wrappedRootKey,3530    recovery_nonce: wrappedNonce,3531    recovery_tag: wrappedTag,3532    ...pwRoot,3533  };3534  await writeSlot(SLOT_A, nextSlotA);35353536  // 4. Swap in memory only after disk committed.3537  if (rootKey) zeroKey(rootKey);3538  rootKey = newRootKey;35393540  // 4b. The identity's root wrap was encrypted under the dead root. Without3541  //     this the identity survives only until the unlock key also changes.3542  await notifyIdentityKeysRotated();35433544  // 5. Old peers' identities derive from the dead root key — drop them all,3545  // with their rendezvous secrets (PB-26-213: rotation clears the map, so3546  // there is nothing to rewrap under the new key).3547  await clearPairedDevices();3548  await clearPairSecrets();35493550  // 6. Force re-presentation of the NEW recovery words on next popup view.3551  await setRecoveryConfirmPending(); // same flag the create-vault flow uses35523553  await stampLocalVaultMutation();3554  return { ok: true };3555}35563557/**3558 * Change the master password. Re-encrypts slot A with a fresh salt + new3559 * derived key. Slot B (duress or noise) is left untouched when duress is3560 * configured — it uses an independent key derived from the duress password —3561 * and regenerated as noise when duress is not configured.3562 */3563export async function changePassword(3564  currentPassword: string,3565  newPassword: string3566): Promise<boolean> {3567  const epoch = vaultEpoch;3568  const stale = () => epoch !== vaultEpoch;3569  if (!derivedKey || vaultMode !== "real") return false;35703571  // Verify current password before allowing any change3572  const valid = await verifyMainPassword(currentPassword);3573  if (!valid) return false;35743575  const slotA = await loadSlot(SLOT_A);3576  if (!slotA) return false;3577  if (!rootKey) return false;35783579  // Derive fresh key from new password + new random salt3580  const newSalt = generateSalt();3581  const newKey = await deriveKey(newPassword, newSalt);3582  if (stale()) {3583    zeroKey(newKey);3584    return false;3585  }35863587  // Re-encrypt the in-memory entries with the new key3588  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3589  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");35903591  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey3592  // under recovery words and remain untouched — recovery phrase keeps working3593  // across password changes (H4/COR-002).3594  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);35953596  const finalSlotA: StoredVault = {3597    ...slotA,3598    salt: hex(newSalt),3599    nonce,3600    ciphertext,3601    tag,3602    ...pwRoot,3603  };36043605  // Snapshot the OLD-key blob before overwriting it. If the user later3606  // restores from this snapshot they will need the OLD password — the3607  // post-restore lock surfaces that immediately.3608  await takeVaultSnapshot("local_save");36093610  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in3611  // one storage transaction. An SW kill before this point leaves the old3612  // slot intact; after this point everything tied to the new password is3613  // already on disk together (M6/COR-004).3614  await writeSlot(SLOT_A, finalSlotA);3615  if (stale()) {3616    zeroKey(newKey);3617    return false;3618  }36193620  // Swap the in-memory key synchronously before any further await. If we3621  // threw between the Slot A write and this swap, the next persistEntries()3622  // would overwrite Slot A with old-key ciphertext, locking the user out.3623  zeroKey(derivedKey);3624  derivedKey = newKey;36253626  // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,3627  // the next service-worker restore decrypts the new-key Slot A with the stale3628  // key, fails tryDecryptSlotResult, and silently drops the user to a lock3629  // screen. Re-escrow the new key so restore survives a password change.3630  if (stale()) {3631    await lock();3632    return false;3633  }3634  await saveSessionKey(newKey, "real", epoch);3635  if (stale()) return false;36363637  // The identity envelope holds one wrap per recovering key. Swapping the3638  // unlock key kills the local wrap, so re-wrap now or the next key swap3639  // orphans the identity -- and until then the old password still opens it.3640  await notifyIdentityKeysRotated();3641  if (stale()) return false;36423643  // Regenerate noise for slot B when duress is not in use. Runs after the3644  // key swap so a failure here is a disposable noise regeneration, not a3645  // lockout.3646  if (!duressConfigured) {3647    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3648  }3649  await clearPinUnlockStorage();3650  await stampLocalVaultMutation();36513652  return true;3653}