vault.ts

131.4 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}12631264export type TryDecryptResult =1265  | { status: "ok"; payload: DecryptedPayload }1266  | { status: "wrong_key" }1267  | { status: "corrupted"; reason: string };12681269// Exported solely for `unlockFromCodex` below, which needs to verify a1270// supplied passphrase against a slotA blob *without* committing anything to1271// storage. Keep this internal otherwise — callers crossing the messages.ts1272// boundary should always go through `unlock` / `verifyMainPassword` so the1273// constant-time invariants in Projects.md §3.4 are not bypassed.1274export async function tryDecryptSlot(1275  vault: StoredVault,1276  key: Uint8Array,1277  aadTag?: string,1278): Promise<DecryptedPayload | null> {1279  const res = await tryDecryptSlotResult(vault, key, aadTag);1280  return res.status === "ok" ? res.payload : null;1281}12821283export async function tryDecryptSlotResult(1284  vault: StoredVault,1285  key: Uint8Array,1286  aadTag?: string,1287): Promise<TryDecryptResult> {1288  if (!hasValidVaultSchema(vault)) {1289    return { status: "corrupted", reason: "invalid_slot_schema" };1290  }1291  let plain: string;1292  try {1293    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1294  } catch {1295    return { status: "wrong_key" };1296  }1297  try {1298    const parsed = JSON.parse(plain) as unknown;1299    if (typeof parsed !== "object" || parsed === null) {1300      return { status: "corrupted", reason: "invalid_payload_shape" };1301    }1302    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1303      return { status: "corrupted", reason: "invalid_payload_shape" };1304    }1305    return { status: "ok", payload: decodePayload(plain) };1306  } catch {1307    return { status: "corrupted", reason: "invalid_payload_json" };1308  }1309}13101311// ---------------------------------------------------------------------------1312// Core operations1313// ---------------------------------------------------------------------------13141315function buildDomainIndexSync(all: VaultEntry[]): void {1316  domainIndex = new Map();13171318  for (const entry of all) {1319    if (entry.deletedAt) continue;1320    if (entry.type === "note" || entry.type === "card") continue;13211322    // An entry can match multiple sites via `additionalUrls`. Index each1323    // hostname so `queryDomain` returns the entry regardless of which alias1324    // the user is currently visiting. Dedupe per-entry so a single entry1325    // never appears twice in one bucket.1326    const domains = new Set<string>();1327    const primary = extractDomain(entry.url ?? "");1328    if (primary) domains.add(primary);1329    for (const extra of entry.additionalUrls ?? []) {1330      const d = extractDomain(extra);1331      if (d) domains.add(d);1332    }1333    if (domains.size === 0) continue;13341335    for (const domain of domains) {1336      const bucket = domainIndex.get(domain) ?? [];1337      bucket.push(entry);1338      domainIndex.set(domain, bucket);1339    }1340  }1341}13421343export async function createVault(password: string): Promise<void> {1344  const salt = generateSalt();1345  derivedKey = await deriveKey(password, salt);1346  entries = [];1347  tombstones = new Map();1348  vaultMode = "real";1349  duressConfigured = false;1350  buildDomainIndexSync(entries);13511352  // Generate and wrap the root key for recovery word escrow.1353  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1354    await generateAndWrapRootKey();1355  rootKey = unhex(rawRootKey);13561357  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1358  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");13591360  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate1361  // it without recovery words (H2/COR-006).1362  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);13631364  const slotA: StoredVault = {1365    version: 1,1366    salt: hex(salt),1367    nonce,1368    ciphertext,1369    tag,1370    recovery_salt: recoverySalt,1371    recovery_wrapped_root_key: wrappedRootKey,1372    recovery_nonce: wrappedNonce,1373    recovery_tag: wrappedTag,1374    ...pwRoot,1375  };1376  await writeSlot(SLOT_A, slotA);13771378  // Matching-size noise blob for slot B, so a fresh install shows two1379  // similarly-shaped records regardless of whether duress is ever set up.1380  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1381  await stampLocalVaultMutation();13821383  // An identity created while this install had no vault was sealed under the1384  // password alone; the vault keys exist now, so re-seal it under them.1385  await identityHooks?.onVaultCreated(password).catch((error) => {1386    console.warn("[Peach] local identity promotion failed:", error);1387  });13881389  touchAutoLock();1390}13911392/**1393 * Creates a local vault from an existing vault root key received through the1394 * SAS-gated adoption protocol. The password is local to this device; it wraps1395 * the adopted root key at rest and does not need to match the source device.1396 */1397export async function createVaultFromRootKey(1398  password: string,1399  rootKeyHex: string,1400): Promise<{ vaultId: string }> {1401  if (typeof password !== "string" || password.length === 0) {1402    throw new Error("ADOPT_INVALID_PASSWORD");1403  }1404  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1405    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1406  }14071408  const rootKeyBytes = unhex(rootKeyHex);1409  if (rootKeyBytes.length !== 32) {1410    rootKeyBytes.fill(0);1411    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1412  }14131414  let nextDerivedKey: Uint8Array | null = null;1415  let sessionRootKey: Uint8Array | null = null;1416  try {1417    if ((await loadSlot(SLOT_A)) !== null) {1418      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1419    }14201421    const nextEntries: VaultEntry[] = [];1422    const nextDuressConfigured = false;1423    const vaultId = await computeVaultId(rootKeyBytes);1424    const salt = generateSalt();1425    nextDerivedKey = await deriveKey(password, salt);1426    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);14271428    sessionRootKey = new Uint8Array(rootKeyBytes);1429    const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1430    const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1431    const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1432    const slotA: StoredVault = {1433      version: 1,1434      salt: hex(salt),1435      nonce,1436      ciphertext,1437      tag,1438      recovery_salt: recovery.recoverySalt,1439      recovery_wrapped_root_key: recovery.wrappedRootKey,1440      recovery_nonce: recovery.wrappedNonce,1441      recovery_tag: recovery.wrappedTag,1442      ...pwRoot,1443    };1444    const slotB = generateNoiseBlob(ciphertext.length / 2);1445    await commitCreatedVaultSlots(slotA, slotB);14461447    const previousDerivedKey = derivedKey;1448    const previousRootKey = rootKey;1449    derivedKey = nextDerivedKey;1450    rootKey = sessionRootKey;1451    nextDerivedKey = null;1452    sessionRootKey = null;1453    entries = nextEntries;1454    tombstones = new Map();1455    vaultMode = "real";1456    duressConfigured = nextDuressConfigured;1457    buildDomainIndexSync(entries);1458    if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1459    if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1460    touchAutoLock();14611462    // The receiver's identity was minted before this vault existed (adoption v21463    // needs it in its first frame), so re-seal it under the vault keys now.1464    await identityHooks?.onVaultCreated(password).catch((error) => {1465      console.warn("[Peach] local identity promotion failed:", error);1466    });14671468    return { vaultId };1469  } finally {1470    rootKeyBytes.fill(0);1471    if (nextDerivedKey) zeroKey(nextDerivedKey);1472    if (sessionRootKey) sessionRootKey.fill(0);1473  }1474}14751476export async function hasVault(): Promise<boolean> {1477  return (await loadSlot(SLOT_A)) !== null;1478}14791480/**1481 * Returns whether the real vault has recorded that a duress passphrase is1482 * configured. Reliable only after a real-mode unlock — before unlock or in1483 * decoy mode, returns false because we cannot read slot A's plaintext.1484 */1485export async function isDuressEnabled(): Promise<boolean> {1486  return vaultMode === "real" && duressConfigured;1487}14881489export async function setupDuress(duressPassword: string): Promise<void> {1490  if (!derivedKey || vaultMode !== "real") {1491    throw new Error("vault must be unlocked in real mode");1492  }1493  // Encrypt the decoy payload (empty entries) under the duress key. Pad to1494  // at least the current slot A ciphertext size so the two slots look the1495  // same at rest at setup time.1496  const slotA = await loadSlot(SLOT_A);1497  if (!slotA) throw new Error("missing slot A");14981499  const decoySalt = generateSalt();1500  const decoyKey = await deriveKey(duressPassword, decoySalt);1501  try {1502    // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1503    // same ceiling by the persistSlotA() call below (duressConfigured is now1504    // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1505    // lengths cannot reveal that duress is configured (audit MEDIUM).1506    const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1507    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1508    await writeSlot(SLOT_B, {1509      version: 1,1510      salt: hex(decoySalt),1511      nonce,1512      ciphertext,1513      tag,1514    });1515  } finally {1516    zeroKey(decoyKey);1517  }15181519  // Re-encrypt slot A with the duressConfigured flag set to true. No external1520  // flag is written — the bit lives inside the encrypted envelope.1521  duressConfigured = true;1522  await persistSlotA();1523}15241525export async function disableDuress(): Promise<void> {1526  if (!derivedKey || vaultMode !== "real") {1527    throw new Error("vault must be unlocked in real mode");1528  }1529  // Overwrite slot B with fresh noise that matches slot A's size. The slot1530  // remains occupied so its mere existence does not signal that duress was1531  // ever configured.1532  const slotA = await loadSlot(SLOT_A);1533  if (slotA) {1534    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1535  }15361537  duressConfigured = false;1538  await persistSlotA();1539}15401541// L14/COR-015: this function intentionally checks slot A (real vault) only.1542// A duress-mode user attempting changePassword is rejected earlier by the1543// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1544// distinction here is by design — duress users cannot rotate the real1545// password by guessing it, and the asymmetry in error timing is acceptable1546// because duress mode is opt-in and the user knows their own decoy key.1547export async function verifyMainPassword(password: string): Promise<boolean> {1548  const slotA = await loadSlot(SLOT_A);1549  if (!slotA) return false;1550  const key = await deriveKey(password, unhex(slotA.salt));1551  try {1552    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1553  } finally {1554    zeroKey(key);1555  }1556}15571558export async function unlock(password: string): Promise<boolean> {1559  return attemptUnlockWithDerive(password, deriveKey);1560}15611562async function attemptUnlockWithDerive(1563  password: string,1564  derive: typeof deriveKey,1565): Promise<boolean> {1566  const epoch = vaultEpoch;1567  const stale = () => epoch !== vaultEpoch;1568  // Load both current-format slots in parallel. Both exist on a supported1569  // install; the1570  // parallel load and the parallel derivation below keep wall-clock time1571  // constant regardless of which slot's passphrase (real or decoy) the1572  // caller supplied, so a timing side channel cannot distinguish the1573  // duress path from the real-unlock path (DUR-002).1574  const [slotA, slotB] = await Promise.all([1575    loadSlot(SLOT_A),1576    loadSlot(SLOT_B),1577  ]);15781579  // Derive both keys in parallel, unconditionally.1580  const [keyA, keyB] = await Promise.all([1581    slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1582    slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1583  ]);15841585  // Attempt both decrypts in parallel. The Result variant lets us distinguish1586  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1587  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1588  const [resultA, resultB] = await Promise.all([1589    keyA && slotA1590      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1591      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1592    keyB && slotB1593      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1594      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1595  ]);1596  const payloadA = resultA.status === "ok" ? resultA.payload : null;1597  const payloadB = resultB.status === "ok" ? resultB.payload : null;15981599  if (stale()) {1600    if (keyA) zeroKey(keyA);1601    if (keyB) zeroKey(keyB);1602    return false;1603  }16041605  if (payloadA && keyA) {1606    if (stale()) {1607      zeroKey(keyA);1608      if (keyB) zeroKey(keyB);1609      return false;1610    }1611    derivedKey = keyA;1612    vaultMode = "real";1613    entries = payloadA.entries;1614    tombstones = payloadA.tombstones;1615    duressConfigured = payloadA.duressConfigured;1616    buildDomainIndexSync(entries);1617    await loadSettings();1618    if (stale()) {1619      await lock();1620      return false;1621    }1622    touchAutoLock();1623    if (keyB) zeroKey(keyB);16241625    // Current vaults always carry a password-wrapped root key. Older vaults1626    // without this escrow are outside the supported storage format.1627    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1628    if (!rootKey) {1629      await lock();1630      return false;1631    }16321633    // PB-26-213: adopt legacy plaintext pair secrets into the encrypted1634    // record and refresh the trusted-session operational copy.1635    await reconcilePairSecrets(rootKey);1636    if (stale()) {1637      await lock();1638      return false;1639    }1640    await lockedSyncUnlockHook?.().catch((error) => {1641      console.warn("[Peach sync] locked inbox drain failed:", error);1642    });1643    if (stale()) {1644      await lock();1645      return false;1646    }1647    await resetPinFailedAttempts();1648    if (stale()) {1649      await lock();1650      return false;1651    }1652    await saveSessionKey(keyA, "real", epoch);1653    if (stale()) return false;1654    return true;1655  }16561657  if (payloadB && keyB) {1658    if (stale()) {1659      zeroKey(keyB);1660      if (keyA) zeroKey(keyA);1661      return false;1662    }1663    derivedKey = keyB;1664    vaultMode = "decoy";1665    decoySyncTeardownHook?.();1666    // A duress session must not retain the real vault's root key. It gates the1667    // recovery words, the sync identity, and the root wrap of the local device1668    // identity, so leaving it live would expose all three in duress mode.1669    if (rootKey) {1670      rootKey.fill(0);1671      rootKey = null;1672    }1673    entries = payloadB.entries;1674    tombstones = payloadB.tombstones;1675    duressConfigured = false;1676    buildDomainIndexSync(entries);1677    await loadSettings();1678    touchAutoLock();1679    if (keyA) zeroKey(keyA);1680    // PB-26-213: a duress session must never serve the real pair-secret map.1681    await dropSessionCache();1682    // Nor the real device identity: a duress session is not a real one.1683    await notifyIdentitySessionEnded();1684    if (stale()) {1685      await lock();1686      return false;1687    }1688    await saveSessionKey(keyB, "decoy", epoch);1689    if (stale()) return false;1690    return true;1691  }16921693  if (keyA) zeroKey(keyA);1694  if (keyB) zeroKey(keyB);1695  // Surface corruption (good AES tag, broken schema) distinctly from1696  // wrong-password so the popup can offer history-restore (M7/COR-007).1697  if (resultA.status === "corrupted") {1698    lastUnlockFailureReason = resultA.reason;1699  } else if (resultB.status === "corrupted") {1700    lastUnlockFailureReason = resultB.reason;1701  }1702  return false;1703}17041705// ---------------------------------------------------------------------------1706// MV3 service worker session key — survives SW restarts within a browser session1707// ---------------------------------------------------------------------------17081709// chrome.storage.session is cleared on browser close; survives SW restart.1710// Firefox does not implement it (persistent background script, no restart risk).1711const SESSION_KEY_STORAGE = "peach_session_dk";17121713// Order writes and removals so lock's awaited removal is a barrier after1714// every escrow write that started before it.1715let sessionKeyMutation = Promise.resolve();17161717function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1718  const task = sessionKeyMutation.then(mutation, mutation);1719  sessionKeyMutation = task.catch(() => undefined);1720  return task;1721}17221723// PB-26-203: the session-key escrow may only operate where startup positively1724// established trusted-only session storage. Defaults to available so direct1725// vault imports (tests, non-entry bootstraps) keep working; entry.ts applies1726// the real hardening result before the first restore.1727let sessionEscrowAvailable = true;17281729/**1730 * Apply the storage-hardening result at startup. When the trusted-only1731 * session state cannot be established, any stored session key is removed and1732 * the escrow stays off: the user unlocks again after a worker restart1733 * instead of trusting an unproven boundary.1734 */1735export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1736  setSessionAllowed(sessionEscrow);1737  if (sessionEscrow) return;1738  sessionEscrowAvailable = false;1739  await clearSessionKey();1740}17411742async function saveSessionKey(1743  key: Uint8Array,1744  mode: "real" | "decoy",1745  expectedEpoch = vaultEpoch,1746): Promise<void> {1747  const session = chrome.storage.session;1748  if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1749  const stored = { keyHex: hex(key), mode };1750  await mutateSessionKey(async () => {1751    if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1752    await session.set({ [SESSION_KEY_STORAGE]: stored });1753  });1754}17551756async function clearSessionKey(): Promise<void> {1757  const session = chrome.storage.session;1758  if (!session) return;1759  await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1760}17611762// A lock that only holds while the service worker stays alive is no lock at1763// all: a cold worker's restoreFromSession() would find the surviving session1764// key and silently re-unlock ~a minute after the user locked. So lock()1765// bumps this epoch synchronously at entry and zeroes keys/indexes before any1766// await; a restore that started earlier sees the mismatch after its next1767// await and aborts instead of publishing.1768let vaultEpoch = 0;17691770// One in-flight restore shared by concurrent callers: storage is read once1771// and the locked-sync unlock hook runs once.1772let restoreInFlight: Promise<boolean> | null = null;17731774/**1775 * On SW restart, re-derive all in-memory vault state from the session key1776 * stored in chrome.storage.session (set at unlock time, cleared by lock();1777 * also cleared on browser close or when restoration fails).1778 * Returns true if the vault was restored, false if no session key was found.1779 * No-ops if the vault is already unlocked.1780 */1781export async function restoreFromSession(): Promise<boolean> {1782  if (derivedKey !== null) return true;1783  if (restoreInFlight) return restoreInFlight;1784  const task = restoreFromSessionInner();1785  restoreInFlight = task;1786  try {1787    return await task;1788  } finally {1789    restoreInFlight = null;1790  }1791}17921793async function restoreFromSessionInner(): Promise<boolean> {1794  const epoch = vaultEpoch;1795  const stale = () => epoch !== vaultEpoch;1796  if (derivedKey !== null) return true;1797  if (!sessionEscrowAvailable) return false;1798  if (!chrome.storage.session) return false;1799  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1800  if (stale()) return false;1801  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1802  if (!entry?.keyHex) return false;18031804  const key = unhex(entry.keyHex);1805  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1806  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1807  const slot = await loadSlot(slotKey);1808  if (stale()) { zeroKey(key); return false; }1809  if (!slot) { await clearSessionKey(); return false; }18101811  const result = await tryDecryptSlotResult(slot, key, aadTag);1812  if (stale()) { zeroKey(key); return false; }1813  if (result.status !== "ok") { await clearSessionKey(); return false; }18141815  // Decrypted payload stays in locals until the final epoch-checked commit1816  // below; nothing observable is published before then.1817  const payload = result.payload;1818  await loadSettings();1819  if (stale()) { zeroKey(key); return false; }18201821  // Rehydrate the root key used by sync identity derivation from the password1822  // escrow stored on the current vault format.1823  let freshRoot: Uint8Array | null = null;1824  if (entry.mode === "real") {1825    freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1826    if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1827    if (!freshRoot) {1828      await lock();1829      return false;1830    }1831  }18321833  if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1834  if (freshRoot) {1835    // PB-26-213: encrypted pair-secret record + session copy, before publish.1836    await reconcilePairSecrets(freshRoot);1837    if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1838  } else {1839    // PB-26-213: a duress restore must never serve the real pair-secret map.1840    await dropSessionCache();1841    // Nor the real device identity.1842    await notifyIdentitySessionEnded();1843    if (stale()) { zeroKey(key); return false; }1844  }1845  derivedKey = key;1846  vaultMode = entry.mode;1847  if (entry.mode === "decoy") decoySyncTeardownHook?.();1848  entries = payload.entries;1849  tombstones = payload.tombstones;1850  duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1851  if (freshRoot) rootKey = freshRoot;1852  buildDomainIndexSync(entries);1853  touchAutoLock();18541855  await lockedSyncUnlockHook?.().catch((error) => {1856    console.warn("[Peach sync] locked inbox drain failed:", error);1857  });1858  if (stale()) return false;18591860  return true;1861}18621863export async function lock(): Promise<void> {1864  // A user-initiated lock must be durable across MV3 service-worker restarts.1865  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1866  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1867  // If the session key survived lock(), that restore would silently re-unlock1868  // the vault ~a minute after the user locked it — a lock that only held1869  // while the SW happened to stay alive. The background-sync convenience the1870  // session key once bought (sync runner re-waking to a restored vault) is1871  // not worth an unlocked vault the user never asked to unlock; when locked,1872  // sync-runner correctly reports VAULT_LOCKED instead.1873  vaultEpoch++;1874  if (derivedKey) {1875    zeroKey(derivedKey);1876    derivedKey = null;1877  }1878  if (rootKey) {1879    rootKey.fill(0);1880    rootKey = null;1881  }18821883  entries = [];1884  tombstones = new Map();1885  domainIndex = new Map();1886  vaultMode = "real";1887  duressConfigured = false;1888  clearAutoLock();1889  await clearSessionKey();1890  // The decrypted identity is session state as well, so a locked vault must not1891  // leave one behind; clear it after the in-memory state is gone.1892  await notifyIdentitySessionEnded();1893  await clearRecoveryConfirmPending();1894}18951896export function isUnlocked(): boolean {1897  return derivedKey !== null;1898}1899/** True only while the active session is unlocked through the decoy slot. */1900export function isDecoyMode(): boolean {1901  return derivedKey !== null && vaultMode === "decoy";1902}19031904// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1905// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1906// always derives from scratch via Argon2id.1907export async function unlockWithKey(1908  masterKeyHex: string,1909): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1910  const epoch = vaultEpoch;1911  const stale = () => epoch !== vaultEpoch;1912  let masterKeyBytes: Uint8Array | null = null;1913  try {1914    masterKeyBytes = unhex(masterKeyHex);1915    const stored = await loadSlot(SLOT_A);1916    if (!stored) return { ok: false, error: "invalid_key" };1917    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1918    if (!payload || stale()) {1919      zeroKey(masterKeyBytes);1920      return { ok: false, error: "invalid_key" };1921    }19221923    derivedKey = masterKeyBytes;1924    vaultMode = "real";1925    entries = payload.entries;1926    tombstones = payload.tombstones;1927    duressConfigured = payload.duressConfigured;1928    buildDomainIndexSync(entries);1929    await loadSettings();1930    touchAutoLock();19311932    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1933    if (!rootKey) {1934      await lock();1935      return { ok: false, error: "invalid_key" };1936    }1937    await reconcilePairSecrets(rootKey);1938    if (stale()) {1939      await lock();1940      return { ok: false, error: "invalid_key" };1941    }1942    await saveSessionKey(masterKeyBytes, "real", epoch);1943    if (stale()) return { ok: false, error: "invalid_key" };1944    return { ok: true, entries };1945  } catch {1946    if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1947    return { ok: false, error: "invalid_key" };1948  }1949}19501951// --- Adoption duress-slot helpers (passthrough, no decrypt) ---19521953/**1954 * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1955 * duress is configured and slot B exists, so the inline snapshot can ship it1956 * to the receiver. Returns null otherwise. Never decrypts.1957 */1958export async function exportDuressSlotForAdoption(): Promise<string | null> {1959  if (!(await isDuressEnabled())) return null;1960  const slotB = await loadSlot(SLOT_B);1961  if (!slotB) return null;1962  return JSON.stringify(slotB);1963}19641965/**1966 * Adoption RECEIVER helper: installs a duress slot received from a source1967 * device. Validates the StoredVault shape (same guard as unlockFromCodex)1968 * and writes it to slot B. Throws on invalid shape so the caller can treat1969 * it as a non-fatal snapshot failure. Never decrypts.1970 */1971export async function installAdoptedDuressSlot(json: string): Promise<void> {1972  let parsed: StoredVault;1973  try {1974    const raw = JSON.parse(json) as unknown;1975    if (!hasValidVaultSchema(raw)) {1976      throw new Error("invalid_duress_slot_shape");1977    }1978    parsed = raw;1979  } catch (err) {1980    if (err instanceof SyntaxError) {1981      throw new Error("invalid_duress_slot_shape");1982    }1983    throw err;1984  }1985  await writeSlot(SLOT_B, parsed);1986}19871988/**1989 * Restore the vault from a parsed Peach Codex payload.1990 *1991 * The supplied passphrase is verified against the Codex's slotA *before* any1992 * storage mutation — wrong passphrases return without touching disk. On1993 * success, the current vault state is snapshotted (reason: "restore") so the1994 * user can revert via Settings -> Vault History, then slotA/slotB are written1995 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A1996 * regular `unlock()` follows so the rest of the SW sees the standard1997 * post-unlock state.1998 *1999 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex2000 * printed by a Pro user must always be restorable, even on a free device.2001 *2002 * Timing is not held constant here (in contrast to `unlock`): the Codex flow2003 * is an explicit user action with a known passphrase, not a passive lock2004 * screen, so the duress-vs-real timing side channel does not apply.2005 */2006export async function unlockFromCodex(params: {2007  slotA: string; // JSON string of StoredVault for slot A2008  slotB: string | null; // JSON string of StoredVault for slot B, or null2009  timestamp: number;2010  masterPassphrase: string;2011}): Promise<2012  | { ok: true; entryCount: number }2013  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" | "missing_root_escrow" }2014> {2015  let parsedSlotA: StoredVault;2016  let parsedSlotB: StoredVault | null = null;2017  try {2018    const rawA = JSON.parse(params.slotA) as unknown;2019    if (!hasValidVaultSchema(rawA)) {2020      return { ok: false, reason: "invalid_slot_format" };2021    }2022    parsedSlotA = rawA;2023    if (params.slotB !== null) {2024      const rawB = JSON.parse(params.slotB) as unknown;2025      if (!hasValidVaultSchema(rawB)) {2026        return { ok: false, reason: "invalid_slot_format" };2027      }2028      parsedSlotB = rawB;2029    }2030  } catch {2031    return { ok: false, reason: "invalid_slot_format" };2032  }20332034  // Verify the passphrase and the password-wrapped root escrow against the2035  // staged Slot A WITHOUT writing anything. Payload decoding is part of the2036  // trial decrypt, so unlock() cannot be the first place these checks run.2037  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));2038  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");2039  if (!trialPayload) {2040    zeroKey(trialKey);2041    return { ok: false, reason: "wrong_passphrase" };2042  }2043  const hasEscrow =2044    !!parsedSlotA.pw_root_ciphertext && !!parsedSlotA.pw_root_nonce && !!parsedSlotA.pw_root_tag;2045  if (!hasEscrow) {2046    zeroKey(trialKey);2047    return { ok: false, reason: "missing_root_escrow" };2048  }2049  const stagedRootKey = await deriveRootKeyFromDerivedKey(trialKey, parsedSlotA);2050  if (!stagedRootKey) {2051    zeroKey(trialKey);2052    return { ok: false, reason: "wrong_passphrase" };2053  }2054  zeroKey(stagedRootKey);2055  zeroKey(trialKey);20562057  // Stage the complete candidate pair in memory before touching storage.2058  const stagedSlotB = parsedSlotB ?? generateNoiseBlob(parsedSlotA.ciphertext.length / 2);20592060  // Drop the trial key; `unlock()` below derives its own copy. Holding two2061  // copies of the master key in memory at once would be a needless surface.20622063  // Snapshot the about-to-be-overwritten on-disk state so the user can revert2064  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.2065  await takeVaultSnapshot("restore");2066  const previousSlotA = await loadSlot(SLOT_A);2067  const previousSlotB = await loadSlot(SLOT_B);2068  let slotACommitted = false;2069  let slotBCommitted = false;2070  try {2071    await writeSlot(SLOT_A, parsedSlotA);2072    slotACommitted = true;2073    await writeSlot(SLOT_B, stagedSlotB);2074    slotBCommitted = true;2075    await chrome.storage.local.set({2076      peach_last_modified_at: String(params.timestamp),2077    });2078  } catch (err) {2079    if (slotBCommitted && previousSlotB) await writeSlot(SLOT_B, previousSlotB);2080    if (slotACommitted && previousSlotA) await writeSlot(SLOT_A, previousSlotA);2081    throw err;2082  }20832084  // Standard unlock path so domain index, settings, auto-lock, and2085  // recovery-key state all populate exactly as a normal unlock would.2086  const ok = await unlock(params.masterPassphrase);2087  if (!ok) {2088    // Should be unreachable — the staged candidate passed the same checks.2089    return { ok: false, reason: "wrong_passphrase" };2090  }2091  return { ok: true, entryCount: getEntries().length };2092}20932094// Returns the current in-memory derived key as a hex string. Only the PIN2095// unlock path needs this value, and it reads module state directly; this2096// accessor exists so unlockWithKey's root-key rehydration is testable.2097export /* @testable */ function getDerivedKeyHex(): string {2098  if (!derivedKey) throw new Error("vault_not_unlocked");2099  return hex(derivedKey);2100}21012102/**2103 * Returns the vault's stable root key as hex, or null when locked / not yet2104 * populated. The root key is random per-vault, generated at creation, escrowed2105 * under the recovery words, and re-wrapped (not regenerated) on every password2106 * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.2107 *2108 * This is the correct input for sync identity derivation (vault_id, the Noise2109 * NK static key, the Ed25519 manifest signing key, and per-entry envelope2110 * keys): paired devices keep the same identity and can still decrypt each2111 * other's entries after either side rotates its master password (SEV-2-B).2112 *2113 * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED2114 * instead of crashing; rootKey is populated on every supported real-mode2115 * unlock via deriveRootKeyFromDerivedKey.2116 */2117export function getRootKeyHex(): string | null {2118  return rootKey ? hex(rootKey) : null;2119}21202121export function getEntries(): VaultEntry[] {2122  if (!derivedKey) throw new Error("vault_locked");2123  return entries.filter((entry) => !entry.deletedAt);2124}21252126const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;21272128export function getIdentities(): SavedIdentity[] {2129  if (!derivedKey) throw new Error("vault_locked");2130  const active = entries.filter((e) => !e.deletedAt && e.type === "login");21312132  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();2133  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();21342135  for (const entry of active) {2136    const domain = extractDomain(entry.url ?? "") ?? "";2137    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;21382139    function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {2140      const existing = map.get(v);2141      if (!existing || modifiedAt > existing.lastUsed) {2142        map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });2143      } else {2144        map.set(v, { ...existing, count: existing.count + 1 });2145      }2146    }21472148    const uVal = entry.username?.trim();2149    if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);21502151    // Also count the dedicated email field — entries created via the inline2152    // popup new-entry form store email here rather than in username.2153    const eVal = entry.email?.trim();2154    if (eVal) tally(emailMap, eVal);2155  }21562157  const toIdentity = (type: "email" | "username") =>2158    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({2159      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,2160    });21612162  return [2163    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2164    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2165  ];2166}21672168export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2169  if (!derivedKey) throw new Error("vault_locked");21702171  const now = Date.now();2172  const added: VaultEntry[] = [];2173  for (const draft of drafts) {2174    try {2175      const sanitized = sanitizeEntryDraft(draft);2176      const nextEntry: VaultEntry = {2177        ...sanitized,2178        id: crypto.randomUUID(),2179        createdAt: now,2180        modifiedAt: monotonicEntryTimestamp(),2181      };2182      entries.push(nextEntry);2183      added.push(nextEntry);2184    } catch {2185      // Skip invalid drafts silently — caller can see diff between input and output length2186    }2187  }21882189  if (added.length > 0) {2190    await persistEntries();2191  }21922193  return added;2194}21952196/**2197 * Restore a set of entries in full fidelity — preserves id, createdAt,2198 * modifiedAt, and every other field unlike `batchAddEntries` which2199 * assigns fresh identity. Used by restore paths where the backup is the2200 * authoritative source of truth. `mode` controls how entries already in2201 * the vault are treated.2202 */2203export async function restoreEntries(2204  incoming: VaultEntry[],2205  mode: "replace" | "merge"2206): Promise<{ added: number; updated: number; skipped: number }> {2207  if (!derivedKey) throw new Error("vault_locked");22082209  // A replace with zero incoming entries is almost certainly a caller bug2210  // (e.g. an import that silently dropped everything). Refuse rather than2211  // wipe the vault.2212  if (mode === "replace" && incoming.length === 0) {2213    throw new Error("refuse to replace with empty entry set");2214  }22152216  let added = 0;2217  let updated = 0;2218  let skipped = 0;22192220  if (mode === "replace") {2221    entries = [];2222    for (const entry of incoming) {2223      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2224      if (!normalized) {2225        skipped += 1;2226        continue;2227      }2228      entries.push(normalized);2229      if (normalized.deletedAt) {2230        tombstones.set(2231          normalized.id,2232          Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2233        );2234      } else {2235        tombstones.delete(normalized.id);2236      }2237      added += 1;2238    }2239  } else {2240    const byId = new Map<string, VaultEntry>();2241    for (const e of entries) byId.set(e.id, e);22422243    for (const entry of incoming) {2244      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2245      if (!normalized) {2246        skipped += 1;2247        continue;2248      }2249      const existing = byId.get(normalized.id);2250      if (!existing) {2251        entries.push(normalized);2252        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2253        else tombstones.delete(normalized.id);2254        added += 1;2255      } else if (normalized.modifiedAt > existing.modifiedAt) {2256        const idx = entries.findIndex((e) => e.id === existing.id);2257        if (idx !== -1) {2258          entries[idx] = normalized;2259          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2260          else tombstones.delete(normalized.id);2261          updated += 1;2262        }2263      } else {2264        skipped += 1;2265      }2266    }2267  }22682269  await persistEntries("import");2270  return { added, updated, skipped };2271}22722273/**2274 * A Peach entry id is a UUID. A restored or imported file carries whatever id2275 * its author used, and no reader bounds it, so one oversized id would ride2276 * into every future sync frame and be rejected there — a permanent, opaque2277 * failure on both platforms, caused by data that was already local.2278 *2279 * The action is mint, not reject: this is the last point where an id is still2280 * ours to choose, so the worst outcome is one duplicate if the same vault is2281 * later restored beside a device still holding the old id. Rejecting would2282 * lose the user's import.2283 *2284 * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries2285 * also calls. Sync has to adopt a peer's id verbatim — that is the only thing2286 * keeping two devices agreed on which entry they are discussing — so reminting2287 * there would fork identity on every device, on every round.2288 */2289const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;22902291function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2292  if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2293  return { ...entry, id: crypto.randomUUID() };2294}22952296/**2297 * Validate + normalise a VaultEntry coming from a backup, import, or sync2298 * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies2299 * only known VaultEntry fields (unknown/attacker keys are dropped), and2300 * nested `passkey`/`canary` shapes are rebuilt field-by-field, never2301 * spread. Returns null for values the live vault shouldn't accept.2302 */2303function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2304  if (!entry || typeof entry !== "object") return null;2305  const title = typeof entry.title === "string" ? entry.title.trim() : "";2306  if (!title) return null;2307  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2308  const now = Date.now();2309  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2310  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;23112312  // normalizeEntry whitelists every known VaultEntry field before the entry2313  // reaches the live vault.2314  const normalized = normalizeEntry({2315    ...entry,2316    id,2317    title,2318    type: entry.type ?? "login",2319    createdAt,2320    modifiedAt,2321  });23222323  if (normalized.passkey) {2324    normalized.passkey = sanitizePasskey(normalized.passkey);2325  }2326  if (normalized.canary) {2327    normalized.canary = sanitizeCanary(normalized.canary);2328  }2329  // PCH-01-016: bound tags on the restore path too, so both import funnels agree.2330  normalized.tags = sanitizeTags(normalized.tags);23312332  // Login entries from backup must carry a password (or be passkey-only,2333  // in which case the passkey sub-object replaces the password requirement).2334  if (2335    normalized.type === "login" &&2336    !normalized.passkey &&2337    typeof normalized.password !== "string"2338  ) {2339    return null;2340  }2341  if (entry.passkey && !normalized.passkey) {2342    // Entry claimed a passkey but the shape didn't validate — refuse the2343    // whole entry rather than silently importing a credential-less husk.2344    return null;2345  }23462347  return normalized;2348}23492350/**2351 * Rebuild a PasskeyData object from untrusted input, field by field.2352 * Returns undefined when required fields are missing/mistyped — a rogue2353 * passkey credential must not activate via import (MV-6 attack scenario).2354 */2355function sanitizePasskey(pk: unknown): PasskeyData | undefined {2356  if (!pk || typeof pk !== "object") return undefined;2357  const r = pk as Record<string, unknown>;2358  if (2359    typeof r.rpId !== "string" || !r.rpId ||2360    typeof r.credentialId !== "string" || !r.credentialId ||2361    typeof r.publicKeyCose !== "string" ||2362    typeof r.userHandle !== "string" ||2363    typeof r.userName !== "string"2364  ) {2365    return undefined;2366  }2367  return {2368    rpId: r.rpId,2369    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2370    credentialId: r.credentialId,2371    publicKeyCose: r.publicKeyCose,2372    privateKeyJwk:2373      r.privateKeyJwk && typeof r.privateKeyJwk === "object"2374        ? (r.privateKeyJwk as JsonWebKey)2375        : undefined,2376    userHandle: r.userHandle,2377    userName: r.userName,2378    userDisplayName:2379      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2380    algorithm: r.algorithm === -8 ? -8 : -7,2381    signCount: typeof r.signCount === "number" ? r.signCount : 0,2382    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2383    backupEligible: r.backupEligible === true,2384    backupState: r.backupState === true,2385    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2386    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2387    transports: Array.isArray(r.transports)2388      ? r.transports.filter((t): t is string => typeof t === "string")2389      : undefined,2390    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2391  };2392}23932394/**2395 * Rebuild a Canary object from untrusted input. The webhook must be a2396 * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing2397 * exfiltration endpoint that the canary subsystem will POST entry titles2398 * to (MV-6 attack scenario). Invalid → the canary is dropped, not the2399 * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire2400 * time (isAllowedCanaryWebhookUrl), so this import-time check is the2401 * outer of two gates.2402 */2403function sanitizeCanary(c: unknown): Canary | undefined {2404  if (!c || typeof c !== "object") return undefined;2405  const r = c as Record<string, unknown>;2406  if (typeof r.webhookUrl !== "string") return undefined;2407  let url: URL;2408  try {2409    url = new URL(r.webhookUrl);2410  } catch {2411    return undefined;2412  }2413  if (url.protocol !== "https:") return undefined;2414  return {2415    webhookUrl: url.toString(),2416    label: typeof r.label === "string" ? r.label : undefined,2417    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2418    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2419  };2420}24212422/**2423 * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted.2424 * Keep only non-empty strings, cap each at 256 chars, and cap the count at 2552425 * (the same per-entry ceiling the export codec enforces, peachPayload.ts).2426 * Non-array or all-invalid input collapses to undefined.2427 */2428function sanitizeTags(tags: unknown): string[] | undefined {2429  if (!Array.isArray(tags)) return undefined;2430  const out: string[] = [];2431  for (const t of tags) {2432    if (typeof t !== "string") continue;2433    const trimmed = t.trim();2434    if (!trimmed) continue;2435    out.push(trimmed.slice(0, 256));2436    if (out.length >= 255) break;2437  }2438  return out.length > 0 ? out : undefined;2439}24402441// Soft-deletes every non-deleted entry in one call. The literal string2442// argument is a tripwire against accidental calls — any caller that would2443// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this2444// to a boolean.2445export async function clearVaultEntries(2446  confirmation: "REPLACE_ALL"2447): Promise<void> {2448  if (confirmation !== "REPLACE_ALL") {2449    throw new Error("clearVaultEntries requires explicit confirmation");2450  }2451  if (!derivedKey) throw new Error("vault_locked");2452  for (const entry of entries) {2453    if (!entry.deletedAt) {2454      const stamp = monotonicEntryTimestamp(Math.max(2455        entry.modifiedAt,2456        tombstones.get(entry.id) ?? 0,2457      ));2458      entry.deletedAt = stamp;2459      entry.modifiedAt = stamp;2460      tombstones.set(entry.id, stamp);2461    }2462  }2463  await persistEntries();2464}24652466export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2467  if (!derivedKey) throw new Error("vault_locked");24682469  const sanitized = sanitizeEntryDraft(entry);2470  const now = Date.now();2471  const stamp = monotonicEntryTimestamp();2472  const nextEntry: VaultEntry = {2473    ...sanitized,2474    id: crypto.randomUUID(),2475    createdAt: now,2476    modifiedAt: stamp,2477    // Stamp passwordChangedAt at creation so age tracking is accurate from2478    // day one. Non-login entries carry the same stamp harmlessly.2479    passwordChangedAt: sanitized.password ? stamp : undefined,2480  };24812482  // Clone-and-replace so a failed persist rolls back the in-memory state2483  // (COR-008). Without this, a quota-full or storage error leaves the2484  // module-level `entries` ahead of what's actually on disk.2485  const rollback = entries;2486  entries = [...entries, nextEntry];2487  try {2488    await persistEntries();2489  } catch (err) {2490    entries = rollback;2491    throw err;2492  }24932494  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)2495  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2496    checkBreach(nextEntry.password).then(async (count) => {2497      if (count > 0) {2498        const idx = entries.findIndex((e) => e.id === nextEntry.id);2499        if (idx !== -1) {2500          entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2501          await persistEntries();2502        }2503      }2504    });2505  }25062507  fireSyncTrigger("vault_change");25082509  return nextEntry;2510}25112512/**2513 * Run the passkey-merge pass on the live in-memory entries and persist if2514 * anything changed. Called immediately after any passkey registration so that2515 * a same-domain TOTP-only entry already in the vault gets consolidated right2516 * away instead of waiting until the next lock/unlock cycle.2517 */2518export async function consolidatePasskeyEntries(): Promise<void> {2519  if (!derivedKey) return;2520  const { merged } = mergePasskeyOnlyIntoLogins(entries);2521  if (merged) {2522    await persistEntries();2523  }2524}25252526export async function updateEntry(2527  id: string,2528  patch: Partial<VaultEntry>2529): Promise<void> {2530  if (!derivedKey) throw new Error("vault_locked");25312532  const idx = entries.findIndex((entry) => entry.id === id);2533  if (idx === -1 || entries[idx].deletedAt) {2534    throw new Error("entry_not_found");2535  }25362537  const current = entries[idx];2538  const sanitizedPatch = sanitizeEntryPatch(patch);2539  // Refresh passwordChangedAt only when the password value actually changes —2540  // a metadata-only update (title, tags, etc.) must not reset the age clock2541  // that drives the Pro health rotation recommendation.2542  const passwordChanged =2543    "password" in patch &&2544    typeof sanitizedPatch.password === "string" &&2545    sanitizedPatch.password !== current.password;2546  const stamp = monotonicEntryTimestamp(current.modifiedAt);2547  // When the password actually changes, push the outgoing one onto the history2548  // (newest first) so the user can reveal/copy/restore it later. We record the2549  // age it carried (passwordChangedAt, falling back to createdAt) rather than2550  // `now`, so the list reflects when each password was last in effect. Only2551  // capture a non-empty prior value — clearing a password leaves no secret to2552  // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)2553  // to avoid noise.2554  const priorPassword = current.password;2555  const shouldCapture =2556    passwordChanged &&2557    typeof priorPassword === "string" &&2558    priorPassword.length > 0 &&2559    current.passwordHistory?.[0]?.password !== priorPassword;2560  const nextHistory = shouldCapture2561    ? [2562        { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2563        ...(current.passwordHistory ?? []),2564      ].slice(0, PASSWORD_HISTORY_MAX)2565    : current.passwordHistory;2566  entries[idx] = {2567    ...current,2568    ...sanitizedPatch,2569    modifiedAt: stamp,2570    ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2571  };2572257325742575  // Card entries must retain their card number2576  if (entries[idx].type === "card" && !entries[idx].cardNumber) {2577    entries[idx] = current;2578    throw new Error("card number is required for card entries");2579  }25802581  // Roll the in-memory entry back to its pre-edit state if the disk write2582  // fails (e.g. storage quota). Without this the module-level `entries` array2583  // would hold an edit that never landed on disk, diverging from the vault2584  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.2585  try {2586    await persistEntries();2587  } catch (err) {2588    entries[idx] = current;2589    throw err;2590  }25912592  // Breach check when password changes: clear immediately so old breach doesn't linger,2593  // then async-check the new password and only re-set if it's also found.2594  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2595    entries[idx] = { ...entries[idx], breach: undefined };2596    await persistEntries();2597    const password = entries[idx].password;2598    if (password) {2599      checkBreach(password).then(async (count) => {2600        const i = entries.findIndex((e) => e.id === id);2601        if (i !== -1 && count > 0) {2602          entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2603          await persistEntries();2604        }2605      });2606    }2607  }26082609  fireSyncTrigger("vault_change");2610}26112612export async function deleteEntry(id: string): Promise<void> {2613  if (!derivedKey) throw new Error("vault_locked");26142615  const entry = entries.find((item) => item.id === id);2616  if (!entry || entry.deletedAt) {2617    throw new Error("entry_not_found");2618  }26192620  // Tombstone timestamp must be strictly greater than the entry's last2621  // modification so the deletion wins LWW against any in-flight edit, even2622  // under clock skew (SEV-2-E).2623  const previousModifiedAt = entry.modifiedAt;2624  const previousTombstone = tombstones.get(id);2625  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2626  entry.deletedAt = stamp;2627  entry.modifiedAt = stamp;2628  tombstones.set(id, stamp);2629  // Roll the tombstone back if the disk write fails, so a quota error doesn't2630  // leave the entry deleted in memory but alive on disk (SEV-3-A).2631  try {2632    await persistEntries();2633  } catch (err) {2634    delete entry.deletedAt;2635    entry.modifiedAt = previousModifiedAt;2636    if (previousTombstone === undefined) tombstones.delete(id);2637    else tombstones.set(id, previousTombstone);2638    throw err;2639  }2640  fireSyncTrigger("vault_change");2641}26422643export function getDeletedEntries(): VaultEntry[] {2644  if (!derivedKey) return [];2645  return entries.filter((e) => !!e.deletedAt);2646}26472648/** Return all durable deletion timestamps, including purged full entries. */2649export function getTombstoneManifest(): Record<string, number> {2650  if (!derivedKey) return {};2651  return Object.fromEntries(tombstones);2652}26532654/**2655 * Returns the vault's monotonic version — the `peach_last_modified_at` value2656 * stamped on every user-initiated write. Remote (sync) writes do NOT advance2657 * this version so a sync round never cascades into a second outbound sync.2658 */2659export async function getVaultVersion(): Promise<number> {2660  try {2661    const raw = await chrome.storage.local.get("peach_last_modified_at");2662    const v = (raw as Record<string, unknown>).peach_last_modified_at;2663    if (typeof v === "number") return v;2664    if (typeof v === "string" && v !== "") {2665      const n = Number.parseInt(v, 10);2666      return Number.isFinite(n) ? n : 0;2667    }2668    return 0;2669  } catch {2670    return 0;2671  }2672}26732674/**2675 * Apply a batch of remote entries + tombstones from a sync peer.2676 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when2677 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and2678 * persists with reason `sync_received` — does NOT fire the sync dispatcher, so2679 * applying a sync round cannot cascade into another outbound sync.2680 *2681 * `applied` counts entries that actually changed local state (new add,2682 * overwrite-by-newer, or new tombstone). `received` is the raw payload size2683 * the caller can surface in the sync result UI.2684 */2685export async function applyMergedRemoteEntries(2686  remoteEntries: VaultEntry[],2687  remoteTombstones: Record<string, number>,2688): Promise<{ received: number; applied: number; conflicts: number }> {2689  // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices2690  // concurrently edit different fields of the same entry, the one with the2691  // newer modifiedAt wins the whole entry and the other device's field edits2692  // are dropped silently. This is the deliberate trade-off for a simple,2693  // conflict-free merge — per-field CRDT merge is out of scope. Guidance for2694  // users: edit a given entry on one device at a time.2695  if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");26962697  const received = remoteEntries.length + Object.keys(remoteTombstones).length;2698  let applied = 0;2699  let conflicts = 0;27002701  const byId = new Map<string, VaultEntry>();2702  for (const e of entries) byId.set(e.id, e);27032704  const mergeNow = Date.now();2705  const remoteTombstoneTimes = new Map<string, number>();2706  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2707    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2708      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2709    }2710  }27112712  for (const remote of remoteEntries) {2713    const sanitized = sanitizeRestoredEntry(remote);2714    if (!sanitized) continue;2715    // HV-1a: a far-future remote modifiedAt must not permanently pin a2716    // malicious value against honest local edits. Clamp BEFORE any2717    // comparison against local timestamps.2718    sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2719    if (typeof sanitized.deletedAt === "number") {2720      sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2721    }2722    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2723    const localTombstoneTs = tombstones.get(sanitized.id);2724    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2725    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2726      continue;2727    }2728    const local = byId.get(sanitized.id);2729    if (!local) {2730      entries.push(sanitized);2731      byId.set(sanitized.id, sanitized);2732      tombstones.delete(sanitized.id);2733      applied += 1;2734    } else if (sanitized.modifiedAt > local.modifiedAt) {2735      const idx = entries.findIndex((e) => e.id === local.id);2736      if (idx !== -1) {2737        entries[idx] = sanitized;2738        byId.set(sanitized.id, sanitized);2739        tombstones.delete(sanitized.id);2740        applied += 1;2741        if (!local.deletedAt) conflicts += 1;2742      }2743    }2744  }27452746  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2747    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2748    // HV-1a/1c: clamp the tombstone timestamp before every comparison.2749    const deletedAt = remoteTombstoneTimes.get(id)!;2750    const knownTombstone = tombstones.get(id);2751    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2752    const local = byId.get(id);2753    if (!local) {2754      tombstones.set(id, deletedAt);2755      applied += 1;2756      continue;2757    }2758    if (local.deletedAt && local.deletedAt >= deletedAt) continue;2759    if (deletedAt < local.modifiedAt) continue;2760    const idx = entries.findIndex((e) => e.id === id);2761    if (idx !== -1) {2762      entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2763      tombstones.set(id, deletedAt);2764      applied += 1;2765    }2766  }27672768  if (applied > 0 && vaultMode === "real") {2769    await persistEntries("sync_received");2770    buildDomainIndexSync(entries);2771  }2772  return { received, applied, conflicts };2773}27742775export async function restoreEntry(id: string): Promise<void> {2776  if (!derivedKey) throw new Error("vault_locked");2777  const entry = entries.find((e) => e.id === id && e.deletedAt);2778  if (!entry) throw new Error("deleted entry not found");2779  const previousDeletedAt = entry.deletedAt;2780  const previousModifiedAt = entry.modifiedAt;2781  const previousTombstone = tombstones.get(id);2782  delete entry.deletedAt;2783  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2784    entry.modifiedAt,2785    previousTombstone ?? 0,2786  ));2787  tombstones.delete(id);2788  try {2789    await persistEntries();2790  } catch (err) {2791    entry.deletedAt = previousDeletedAt;2792    entry.modifiedAt = previousModifiedAt;2793    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2794    throw err;2795  }2796  buildDomainIndexSync(entries);2797}27982799export async function purgeEntry(id: string): Promise<void> {2800  if (!derivedKey) throw new Error("vault_locked");2801  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2802  if (idx === -1) throw new Error("deleted entry not found");2803  const entry = entries[idx]!;2804  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2805  entries.splice(idx, 1);2806  await persistEntries();2807}28082809/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */2810export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2811  if (!derivedKey || retentionMs === 0) return 0;2812  const cutoff = Date.now() - retentionMs;2813  const before = entries.length;2814  for (const entry of entries) {2815    if (entry.deletedAt && entry.deletedAt < cutoff) {2816      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2817    }2818  }2819  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2820  const purged = before - entries.length;2821  if (purged > 0) await persistEntries();2822  return purged;2823}28242825export function queryDomain(domain: string): VaultEntry[] {2826  return domainIndex.get(domain) ?? [];2827}28282829export function getEntryById(id: string): VaultEntry | undefined {2830  return entries.find((entry) => entry.id === id && !entry.deletedAt);2831}28322833async function persistSlotA(2834  reason: VaultSnapshot["reason"] = "local_save",2835): Promise<StoredVault> {2836  if (!derivedKey) throw new Error("vault_locked");2837  const epoch = vaultEpoch;2838  const throwIfStale = () => {2839    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2840  };28412842  const slotA = await loadSlot(SLOT_A);2843  throwIfStale();2844  if (!slotA) throw new Error("missing slot A");28452846  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures2847  // are swallowed by takeVaultSnapshot — history must never block a save.2848  await takeVaultSnapshot(reason);2849  throwIfStale();28502851  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2852  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2853  throwIfStale();28542855  // Preserve existing recovery escrow fields.2856  const nextSlotA: StoredVault = {2857    ...slotA,2858    nonce,2859    ciphertext,2860    tag,2861    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot2862  };28632864  // Slot A (the real vault) is now committed. Wrap every later write so a2865  // mid-persist failure (e.g. a storage error after slot A landed) restores the2866  // prior slot A before rethrowing. Without this, a "failed" save that already2867  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/2868  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces2869  // as a duplicate on the next unlock (observed under storage-quota pressure).2870  let slotACommitted = false;2871  try {2872    throwIfStale();2873    await writeSlot(SLOT_A, nextSlotA);2874    slotACommitted = true;2875    throwIfStale();28762877    // When duress is not configured, keep slot B size-matched with slot A by2878    // regenerating its noise. When duress IS configured, leave slot B alone —2879    // we don't have the decoy key to re-encrypt it. This is a known trade-off.2880    if (!duressConfigured) {2881      throwIfStale();2882      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2883      throwIfStale();2884    }2885    // Only advance the vault version for locally-originated writes. Entries2886    // applied from a sync peer ("sync_received") must NOT bump the counter —2887    // otherwise applying a remote round makes us look newer than the peer and2888    // triggers a redundant outbound sync, defeating the no-op optimisation2889    // and risking a ping-pong between two devices (SEV-2-C).2890    if (reason !== "sync_received") {2891      throwIfStale();2892      await stampLocalVaultMutation();2893      throwIfStale();2894    }2895  } catch (err) {2896    // Best-effort restore — the prior slot A is smaller-or-equal, so this write2897    // fits even when the failure was a quota overflow.2898    if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2899    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2900    throw err;2901  }29022903  return nextSlotA;2904}29052906async function persistSlotB(): Promise<void> {2907  if (!derivedKey) throw new Error("vault_locked");29082909  const slotB = await loadSlot(SLOT_B);2910  if (!slotB) throw new Error("missing slot B");29112912  // Decoy plaintext always reports duressConfigured=false — the decoy user2913  // sees a clean slate. persistSlotB only runs in decoy mode, which only2914  // exists once duress is configured, so pad to the fixed duress ceiling to2915  // keep slot B's length equal to slot A's (audit MEDIUM — deniability).2916  const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2917  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");29182919  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2920  // Symmetric with persistSlotA: if the version stamp fails after the decoy2921  // slot committed, restore the prior slot B so a failed decoy-mode save can't2922  // leave disk ahead of the in-memory rollback.2923  try {2924    await stampLocalVaultMutation();2925  } catch (err) {2926    await writeSlot(SLOT_B, slotB).catch(() => {});2927    throw err;2928  }2929}29302931async function persistEntries(2932  reason: VaultSnapshot["reason"] = "local_save",2933): Promise<void> {2934  if (!derivedKey) throw new Error("vault_locked");29352936  // Reconcile direct soft-delete callers while restoring the previous map if2937  // storage rejects the save.2938  const previousTombstones = tombstones;2939  tombstones = new Map(tombstones);2940  reconcileTombstones();2941  try {2942    if (vaultMode === "decoy") {2943      await persistSlotB();2944    } else {2945      await persistSlotA(reason);2946    }2947  } catch (err) {2948    tombstones = previousTombstones;2949    throw err;2950  }2951  buildDomainIndexSync(entries);2952  await refreshLockedSyncState();2953}29542955// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage2956// boundary. Schemeless values (e.g. "github.com") are inert and kept — they2957// pre-date this guard in existing data. Unparseable values are kept: the2958// reviewed render paths never execute them (favicons via _favicon/, domains2959// via tldts); this is defense-in-depth against future sinks.2960function allowHttpUrl(url: string | undefined): string {2961  const trimmed = url?.trim() ?? "";2962  if (!trimmed) return "";2963  try {2964    const parsed = new URL(trimmed);2965    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2966  } catch {2967    // No scheme to reject.2968  }2969  return trimmed;2970}29712972function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2973  if (!Array.isArray(values)) return undefined;2974  const seen = new Set<string>();2975  const cleaned: string[] = [];2976  for (const value of values) {2977    const trimmed = typeof value === "string" ? value.trim() : "";2978    if (!trimmed) continue;2979    if (!allowHttpUrl(trimmed)) continue;2980    const key = trimmed.toLowerCase();2981    if (seen.has(key)) continue;2982    seen.add(key);2983    cleaned.push(trimmed);2984  }2985  return cleaned.length > 0 ? cleaned : undefined;2986}29872988function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2989  const title = entry.title.trim();2990  const type: VaultEntry["type"] = entry.type ?? "login";29912992  if (!title) throw new Error("title is required");29932994  if (entry.passkey) {2995    const p = entry.passkey;2996    if (2997      !p.rpId ||2998      !p.credentialId ||2999      !p.publicKeyCose ||3000      !p.userHandle ||3001      !p.userName ||3002      (p.algorithm !== -7 && p.algorithm !== -8)3003    ) {3004      throw new Error("invalid passkey credential");3005    }3006  }3007  if (type === "card") {3008    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {3009      throw new Error("cardholder name and card number are required");3010    }3011  }30123013  return {3014    type,3015    title,3016    username: type === "login" ? entry.username?.trim() ?? "" : undefined,3017    email: type === "login" ? entry.email?.trim() || undefined : undefined,3018    password: type === "login" ? entry.password : undefined,3019    url: type === "login" ? allowHttpUrl(entry.url) : undefined,3020    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,3021    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,3022    // PCH-01-019: preserve non-default TOTP params through batch-add/import so3023    // they are not silently stripped (they fall back to SHA-1/6/30 when absent).3024    totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,3025    totpDigits: type === "login" ? entry.totpDigits : undefined,3026    totpPeriod: type === "login" ? entry.totpPeriod : undefined,3027    notes: entry.notes?.trim() || undefined,3028    breach: type === "login" ? entry.breach : undefined,3029    // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags3030    // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here.3031    canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,3032    tags: sanitizeTags(entry.tags),3033    passkey: entry.passkey,3034    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,3035    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,3036    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,3037    expYear: type === "card" ? entry.expYear?.trim() : undefined,3038    cvc: type === "card" ? entry.cvc?.trim() : undefined,3039    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,3040    network: type === "card" ? entry.network : undefined,3041    last4: type === "card" ? entry.last4?.trim() : undefined,3042  };3043}30443045function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {3046  const nextPatch: Partial<VaultEntry> = {};30473048  if ("type" in patch) {3049    nextPatch.type = patch.type;3050  }30513052  if ("title" in patch) {3053    const title = patch.title?.trim() ?? "";3054    if (!title) throw new Error("title is required");3055    nextPatch.title = title;3056  }30573058  if ("url" in patch) {3059    nextPatch.url = allowHttpUrl(patch.url);3060  }30613062  if ("additionalUrls" in patch) {3063    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];3064    const seen = new Set<string>();3065    const cleaned: string[] = [];3066    for (const value of incoming) {3067      const trimmed = typeof value === "string" ? value.trim() : "";3068      if (!trimmed || !allowHttpUrl(trimmed)) continue;3069      const key = trimmed.toLowerCase();3070      if (seen.has(key)) continue;3071      seen.add(key);3072      cleaned.push(trimmed);3073    }3074    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;3075  }30763077  if ("username" in patch) {3078    nextPatch.username = patch.username?.trim() ?? "";3079  }30803081  if ("email" in patch) {3082    nextPatch.email = patch.email?.trim() || undefined;3083  }30843085  if ("password" in patch) {3086    const password = patch.password ?? "";3087    // Allow clearing a password (e.g. on passkey-only entries)3088    nextPatch.password = password || undefined;3089  }30903091  if ("totp" in patch) {3092    nextPatch.totp = patch.totp?.trim() || undefined;3093  }30943095  if ("totpAlgorithm" in patch) {3096    nextPatch.totpAlgorithm = patch.totpAlgorithm;3097  }30983099  if ("totpDigits" in patch) {3100    nextPatch.totpDigits = patch.totpDigits;3101  }31023103  if ("totpPeriod" in patch) {3104    nextPatch.totpPeriod = patch.totpPeriod;3105  }31063107  if ("notes" in patch) {3108    nextPatch.notes = patch.notes?.trim() || undefined;3109  }31103111  if ("breach" in patch) {3112    nextPatch.breach = patch.breach;3113  }31143115  if ("canary" in patch) {3116    // PCH-02-001: route update patches through the same canary whitelist used3117    // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.3118    nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;3119  }31203121  if ("passkey" in patch) {3122    // A patch with `passkey: undefined` removes the credential from the entry3123    // (used by "remove passkey" without deleting the whole entry).3124    if (patch.passkey === undefined || patch.passkey === null) {3125      nextPatch.passkey = undefined;3126    } else {3127      const sanitized = sanitizePasskey(patch.passkey);3128      if (!sanitized) throw new Error("invalid passkey credential");3129      nextPatch.passkey = sanitized;3130    }3131  }31323133  if ("tags" in patch) {3134    // PCH-02-001: bound tags exactly like both import funnels.3135    nextPatch.tags = sanitizeTags(patch.tags);3136  }31373138  if ("cardholderName" in patch) {3139    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;3140  }3141  if ("cardNumber" in patch) {3142    const n = patch.cardNumber ?? "";3143    nextPatch.cardNumber = n || undefined;3144  }3145  if ("expMonth" in patch) {3146    nextPatch.expMonth = patch.expMonth?.trim() || undefined;3147  }3148  if ("expYear" in patch) {3149    nextPatch.expYear = patch.expYear?.trim() || undefined;3150  }3151  if ("cvc" in patch) {3152    nextPatch.cvc = patch.cvc?.trim() || undefined;3153  }3154  if ("cardNickname" in patch) {3155    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;3156  }3157  if ("network" in patch) {3158    nextPatch.network = patch.network;3159  }3160  if ("last4" in patch) {3161    nextPatch.last4 = patch.last4?.trim() || undefined;3162  }31633164  return nextPatch;3165}31663167function normalizeEntry(entry: VaultEntry): VaultEntry {3168  const base: VaultEntry = {3169    id: entry.id,3170    type: entry.type,3171    title: entry.title,3172    username: entry.username,3173    email: entry.email,3174    password: entry.password,3175    url: entry.url,3176    additionalUrls:3177      "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3178        ? entry.additionalUrls3179        : undefined,3180    totp: entry.totp,3181    // PCH-01-019: carry TOTP params through the normalize/restore path too.3182    totpAlgorithm: entry.totpAlgorithm,3183    totpDigits: entry.totpDigits,3184    totpPeriod: entry.totpPeriod,3185    notes: entry.notes,3186    breach: entry.breach,3187    // Preserve the password-age clock across lock/unlock. Without this the3188    // field is dropped on every decode and the advanced-health "aged password"3189    // signal silently resets to createdAt on each unlock (SEV-2-A).3190    passwordChangedAt:3191      "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3192    // Preserve past-password history across lock/unlock. normalizeEntry is a3193    // field whitelist, so an unlisted field is silently dropped on every decode3194    // (same class as the passwordChangedAt SEV-2-A above).3195    passwordHistory:3196      "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3197        ? entry.passwordHistory3198        : undefined,3199    canary: entry.canary,3200    passkey: entry.passkey,3201    createdAt: entry.createdAt,3202    modifiedAt: entry.modifiedAt,3203    deletedAt: entry.deletedAt,3204    tags: entry.tags,3205    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3206    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3207    expMonth: "expMonth" in entry ? entry.expMonth : undefined,3208    expYear: "expYear" in entry ? entry.expYear : undefined,3209    cvc: "cvc" in entry ? entry.cvc : undefined,3210    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3211    network: "network" in entry ? entry.network : undefined,3212    last4: "last4" in entry ? entry.last4 : undefined,3213  };32143215  return base;3216}321732183219// ---------------------------------------------------------------------------3220// Deduplication3221// ---------------------------------------------------------------------------32223223function entryDupeKey(e: VaultEntry): string {3224  if (e.type === "note") return `note|${e.title.toLowerCase()}`;3225  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins3226  // on different sites that happen to share a username are still distinct;3227  // two passkey credentials for the same RP+user are duplicates regardless3228  // of any login fields they may also carry.3229  if (e.passkey) {3230    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3231  }3232  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3233}32343235/** Returns the number of duplicate entries (those that would be removed). */3236export function countDuplicates(): number {3237  const best = new Map<string, VaultEntry>();3238  let dupeCount = 0;3239  for (const e of entries.filter((e) => !e.deletedAt)) {3240    const key = entryDupeKey(e);3241    const existing = best.get(key);3242    if (!existing) {3243      best.set(key, e);3244    } else {3245      dupeCount++;3246      // Keep the more recently modified one in the map3247      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3248        best.set(key, e);3249      }3250    }3251  }3252  return dupeCount;3253}32543255/**3256 * Soft-deletes duplicate entries, keeping the most recently modified copy of3257 * each group. Returns the number of entries moved to trash.3258 */3259export async function deduplicateEntries(): Promise<number> {3260  if (!derivedKey) throw new Error("vault_locked");3261  // Dedup reshapes slot B permanently under the decoy key, which is3262  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.3263  if (vaultMode === "decoy") {3264    throw new Error("deduplicate disabled in decoy mode");3265  }32663267  const best = new Map<string, VaultEntry>();3268  for (const e of entries.filter((en) => !en.deletedAt)) {3269    const key = entryDupeKey(e);3270    const existing = best.get(key);3271    if (!existing) {3272      best.set(key, e);3273    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3274      best.set(key, e);3275    }3276  }32773278  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3279  let removed = 0;3280  entries = entries.map((e) => {3281    if (!e.deletedAt && !keepIds.has(e.id)) {3282      removed++;3283      const deletedAt = monotonicEntryTimestamp(Math.max(3284        e.modifiedAt,3285        tombstones.get(e.id) ?? 0,3286      ));3287      tombstones.set(e.id, deletedAt);3288      return { ...e, deletedAt };3289    }3290    return e;3291  });32923293  if (removed > 0) await persistEntries();3294  return removed;3295}32963297// ---------------------------------------------------------------------------3298// Vault history — snapshot BEFORE every vault write so the saved blob is3299// what is about to be lost, not what was just written. Capped at 5. The3300// stored blobs are AES-256-GCM ciphertext under the master password, so3301// history adds zero security surface.3302// ---------------------------------------------------------------------------33033304const HISTORY_KEY = "peach_vault_history";3305const HISTORY_MAX = 5;33063307// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the3308// plaintext snapshot field cannot reveal exact vault size or single-entry change3309// deltas. Buckets widen as the vault grows so absolute leakage stays roughly3310// proportional rather than exact.3311function bucketEntryCount(n: number): number {3312  if (n <= 0) return 0;3313  if (n <= 10) return 10;3314  if (n <= 25) return 25;3315  if (n <= 50) return 50;3316  if (n <= 100) return 100;3317  if (n <= 250) return 250;3318  if (n <= 500) return 500;3319  return Math.ceil(n / 500) * 500;3320}33213322/**3323 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE3324 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost3325 * state. Identical-to-most-recent blobs are skipped to avoid filling history3326 * with no-op writes.3327 */3328export async function takeVaultSnapshot(3329  reason: VaultSnapshot["reason"],3330): Promise<void> {3331  try {3332    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3333    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3334    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];33353336    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,3337    // pre-createVault). createVault skips snapshots intentionally — there is3338    // no prior state to preserve.3339    if (!slotA) return;33403341    const vault_a = JSON.stringify(slotA);3342    const vault_b = JSON.stringify(slotB ?? slotA);33433344    // Best-effort entry count: only meaningful when the in-memory state is3345    // unlocked AND in real mode. Decoy mode would leak the decoy entry3346    // count; locked mode has no entries to count. Both fall through to null.3347    // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage3348    // .local sits next to the AES-GCM blobs in plaintext, so an exact count would3349    // leak vault size and per-write deltas (the metadata padding hides this).3350    let entryCount: number | null = null;3351    if (derivedKey && vaultMode === "real") {3352      try {3353        entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3354      } catch {3355        entryCount = null;3356      }3357    }33583359    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3360    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3361      ? existingRaw3362      : { snapshots: [] };33633364    // Deduplication: if the most recent snapshot has byte-identical blobs,3365    // skip — saves the same vault N times in a row would otherwise burn the3366    // entire history window with no recovery value.3367    const latest = history.snapshots[0];3368    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3369      return;3370    }33713372    const snapshot: VaultSnapshot = {3373      id: crypto.randomUUID(),3374      savedAt: Date.now(),3375      reason,3376      vault_a,3377      vault_b,3378      entryCount,3379    };33803381    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3382    await chrome.storage.local.set({ [HISTORY_KEY]: history });3383  } catch (err) {3384    // Non-fatal — history must never block a save. A failed snapshot just3385    // means the user loses one rung of the safety ladder for this write.3386    // M4/QUA-006: log so the failure is visible; if it looks like a quota3387    // problem, notify the popup so the user can prune history themselves.3388    console.warn('[Peach] takeVaultSnapshot failed:', err);3389    if (String(err).includes('QUOTA') || String(err).includes('quota')) {3390      try {3391        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3392      } catch {3393        // popup may be closed; surfaced via console.warn above3394      }3395    }3396  }3397}33983399/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */3400export async function clearVaultHistory(): Promise<void> {3401  try {3402    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3403  } catch {3404    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().3405  }3406}34073408/** Read all snapshots, newest first, for the Settings vault history view. */3409export async function getVaultHistory(): Promise<VaultSnapshot[]> {3410  // The invariant lives here as well as at the message handlers, so it does not3411  // depend on every caller remembering it — mirroring how takeVaultSnapshot3412  // already refuses to record while in decoy mode (PB-26-915).3413  if (vaultMode === "decoy") return [];3414  const raw = await chrome.storage.local.get(HISTORY_KEY);3415  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3416  if (!history || !Array.isArray(history.snapshots)) return [];3417  return history.snapshots;3418}34193420/**3421 * Replace the on-disk vault with the chosen snapshot's blobs and lock the3422 * vault. The current state is itself snapshotted with reason 'restore' first3423 * so the restore is reversible. After this returns, the user must unlock3424 * with the password that matches the restored blob.3425 */3426export async function restoreVaultSnapshot(3427  snapshotId: string,3428): Promise<{ ok: true } | { ok: false; error: string }> {3429  // A decoy session holds no real-mode authority: refusing here means a coerced3430  // user cannot be made to roll the real vault back to an earlier snapshot3431  // (PB-26-915).3432  if (vaultMode === "decoy") return { ok: false, error: "vault_locked" };3433  const history = await getVaultHistory();3434  const snapshot = history.find((s) => s.id === snapshotId);3435  if (!snapshot) return { ok: false, error: "snapshot_not_found" };34363437  // Snapshot the about-to-be-replaced current state so the restore is3438  // reversible. takeVaultSnapshot is no-op when the most recent entry is3439  // byte-identical, so a restore-of-a-restore stays clean.3440  await takeVaultSnapshot("restore");34413442  let restoredA: StoredVault;3443  let restoredB: StoredVault;3444  try {3445    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3446    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3447  } catch {3448    return { ok: false, error: "snapshot_corrupt" };3449  }34503451  await writeSlot(SLOT_A, restoredA);3452  await writeSlot(SLOT_B, restoredB);3453  await stampLocalVaultMutation();34543455  // Lock so the user must prove they can decrypt the restored blob. If they3456  // restored a pre-password-change snapshot they discover it immediately at3457  // the lock screen rather than silently holding an inaccessible vault.3458  await lock();34593460  return { ok: true };3461}34623463/**3464 * Rotate the vault's sync identity by generating a FRESH ROOT KEY and3465 * re-wrapping everything under it (MV-8). Because every sync credential —3466 * vault_id, the Noise static keypair, the Ed25519 manifest signing key,3467 * and the per-entry envelope keys — derives from the root key, rotation3468 * makes a lost/stolen device's retained key material useless. All paired3469 * devices must re-pair afterwards.3470 *3471 * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from3472 * the root key, so rotation INVALIDATES the old recovery phrase. The flow3473 * sets peach_recovery_pending so the popup re-presents the new words for3474 * confirmation, same as the initial recovery-word confirmation path.3475 */3476export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3477  if (!derivedKey || vaultMode !== "real") {3478    return { ok: false, error: "vault_locked" };3479  }3480  const slotA = await loadSlot(SLOT_A);3481  if (!slotA) return { ok: false, error: "missing_slot_a" };34823483  // 1. Fresh root key + recovery escrow (new words).3484  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3485    await generateAndWrapRootKey();34863487  // 2. Re-wrap the new root key under the current password-derived key.3488  const newRootKey = unhex(rawRootKey);3489  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);34903491  // 3. Snapshot, then single atomic slot write — both escrow wrappings of3492  //    the new root key land together (same transactional rule as3493  //    changePassword). Entries are untouched: they are sealed under the3494  //    password-derived key, not the root key.3495  await takeVaultSnapshot("local_save");3496  const nextSlotA: StoredVault = {3497    ...slotA,3498    recovery_salt: recoverySalt,3499    recovery_wrapped_root_key: wrappedRootKey,3500    recovery_nonce: wrappedNonce,3501    recovery_tag: wrappedTag,3502    ...pwRoot,3503  };3504  await writeSlot(SLOT_A, nextSlotA);35053506  // 4. Swap in memory only after disk committed.3507  if (rootKey) zeroKey(rootKey);3508  rootKey = newRootKey;35093510  // 4b. The identity's root wrap was encrypted under the dead root. Without3511  //     this the identity survives only until the unlock key also changes.3512  await notifyIdentityKeysRotated();35133514  // 5. Old peers' identities derive from the dead root key — drop them all,3515  // with their rendezvous secrets (PB-26-213: rotation clears the map, so3516  // there is nothing to rewrap under the new key).3517  await clearPairedDevices();3518  await clearPairSecrets();35193520  // 6. Force re-presentation of the NEW recovery words on next popup view.3521  await setRecoveryConfirmPending(); // same flag the create-vault flow uses35223523  await stampLocalVaultMutation();3524  return { ok: true };3525}35263527/**3528 * Change the master password. Re-encrypts slot A with a fresh salt + new3529 * derived key. Slot B (duress or noise) is left untouched when duress is3530 * configured — it uses an independent key derived from the duress password —3531 * and regenerated as noise when duress is not configured.3532 */3533export async function changePassword(3534  currentPassword: string,3535  newPassword: string3536): Promise<boolean> {3537  const epoch = vaultEpoch;3538  const stale = () => epoch !== vaultEpoch;3539  if (!derivedKey || vaultMode !== "real") return false;35403541  // Verify current password before allowing any change3542  const valid = await verifyMainPassword(currentPassword);3543  if (!valid) return false;35443545  const slotA = await loadSlot(SLOT_A);3546  if (!slotA) return false;3547  if (!rootKey) return false;35483549  // Derive fresh key from new password + new random salt3550  const newSalt = generateSalt();3551  const newKey = await deriveKey(newPassword, newSalt);3552  if (stale()) {3553    zeroKey(newKey);3554    return false;3555  }35563557  // Re-encrypt the in-memory entries with the new key3558  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3559  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");35603561  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey3562  // under recovery words and remain untouched — recovery phrase keeps working3563  // across password changes (H4/COR-002).3564  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);35653566  const finalSlotA: StoredVault = {3567    ...slotA,3568    salt: hex(newSalt),3569    nonce,3570    ciphertext,3571    tag,3572    ...pwRoot,3573  };35743575  // Snapshot the OLD-key blob before overwriting it. If the user later3576  // restores from this snapshot they will need the OLD password — the3577  // post-restore lock surfaces that immediately.3578  await takeVaultSnapshot("local_save");35793580  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in3581  // one storage transaction. An SW kill before this point leaves the old3582  // slot intact; after this point everything tied to the new password is3583  // already on disk together (M6/COR-004).3584  await writeSlot(SLOT_A, finalSlotA);3585  if (stale()) {3586    zeroKey(newKey);3587    return false;3588  }35893590  // Swap the in-memory key synchronously before any further await. If we3591  // threw between the Slot A write and this swap, the next persistEntries()3592  // would overwrite Slot A with old-key ciphertext, locking the user out.3593  zeroKey(derivedKey);3594  derivedKey = newKey;35953596  // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,3597  // the next service-worker restore decrypts the new-key Slot A with the stale3598  // key, fails tryDecryptSlotResult, and silently drops the user to a lock3599  // screen. Re-escrow the new key so restore survives a password change.3600  if (stale()) {3601    await lock();3602    return false;3603  }3604  await saveSessionKey(newKey, "real", epoch);3605  if (stale()) return false;36063607  // The identity envelope holds one wrap per recovering key. Swapping the3608  // unlock key kills the local wrap, so re-wrap now or the next key swap3609  // orphans the identity -- and until then the old password still opens it.3610  await notifyIdentityKeysRotated();3611  if (stale()) return false;36123613  // Regenerate noise for slot B when duress is not in use. Runs after the3614  // key swap so a failure here is a disposable noise regeneration, not a3615  // lockout.3616  if (!duressConfigured) {3617    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3618  }3619  await clearPinUnlockStorage();3620  await stampLocalVaultMutation();36213622  return true;3623}