vault.ts

133.2 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 { setRecoveryConfirmPending } from "./recovery-pending";39import {40  clearPairSecrets,41  dropSessionCache,42  reconcilePairSecrets,43  setSessionAllowed,44} from "./pair-secrets";4546type EntryDraft = Omit<47  VaultEntry,48  "id" | "createdAt" | "modifiedAt" | "deletedAt"49>;5051// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B52// either holds the decoy vault (when duress is configured) or random noise53// that looks identical at rest. No external indicator is stored — duress54// configuration is recorded only inside the encrypted payload of slot A.55const SLOT_A = "peach_vault_a";56const SLOT_B = "peach_vault_b";5758// Plaintext is padded to the next multiple of this size before encryption.59// This coarsens ciphertext-length leakage into 1 KiB buckets.60const PAD_BLOCK = 1024;6162// How many past passwords to retain per entry. Generous enough to be useful as63// a recovery aid while bounding the secret material we hold (and the ciphertext64// growth) for an entry whose password is rotated frequently.65const PASSWORD_HISTORY_MAX = 50;6667// When duress is configured, BOTH slots are padded to this fixed ceiling so68// their ciphertext lengths are identical and cannot reveal that a hidden decoy69// vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably70// holds a very large vault (thousands of entries incl. passkeys). The decoy71// slot can't be re-encrypted on every real-vault save (the real session has no72// decoy key), so a fixed ceiling — rather than size-matching on each write — is73// what keeps the two slots equal. A real vault whose plaintext exceeds this74// would grow past the ceiling and re-expose the bit; that is an extreme,75// documented edge well beyond normal use.76const DURESS_SLOT_SIZE = 262_144;7778let derivedKey: Uint8Array | null = null;79// 32-byte random root key, separate from the derivedKey used for vault encryption.80// The root key is wrapped (encrypted) under the recovery key derived from the81// 12-word phrase. When the user changes their password, only the wrapping changes;82// the root key and recovery words remain stable.83let rootKey: Uint8Array | null = null;84let entries: VaultEntry[] = [];85// Tombstones outlive their full entry records so a peer that was offline86// during a purge cannot reintroduce an old ciphertext envelope. This map is87// serialized only inside the encrypted vault payload.88let tombstones = new Map<string, number>();89let domainIndex: Map<string, VaultEntry[]> = new Map();90let vaultMode: "real" | "decoy" = "real";91// Cached post-unlock view of whether the real vault records that duress is92// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock93// this stays false because we never see slot A's plaintext.94let duressConfigured = false;9596// Set to a corruption reason during the last unlock() attempt when both slots97// decrypted but failed schema validation. Consumed and cleared by98// consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted99// distinctly from wrong-password (M7/COR-007).100//101// NOTE: module-level state — if vault.ts is ever moved to a worker context,102// this must be replaced with a structured return value from unlock() or a103// postMessage channel. The signal is invisible across module-instance104// boundaries.105let lastUnlockFailureReason: string | null = null;106107export function consumeLastUnlockFailureReason(): string | null {108  const r = lastUnlockFailureReason;109  lastUnlockFailureReason = null;110  return r;111}112113// ---------------------------------------------------------------------------114// Settings (persisted inside the encrypted vault store as a sidecar key)115// ---------------------------------------------------------------------------116117export type TextSize = "small" | "medium" | "large";118119export interface VaultSettings {120  accentColor?: AccentColorName;121  theme?: "dark" | "light";122  autoLockMinutes?: number;123  clipboardClearSeconds?: number;124  lockOnClose?: boolean;125  breachMonitoring?: boolean;126  passkeyProviderEnabled?: boolean;127  trashRetentionDays?: number;128  manualSummonDomains?: Record<string, ManualSummonRecord>;129  manualSummonSalt?: string;130  declinedCardFingerprints?: string[];131132  /** Metadata is bounded because it remains outside the encrypted vault blob. */133  metadataVersion?: 1;134  /**135   * Text size scale for body copy and entry titles. Stops at "large" by136   * design — XL clips dense screens (Vault, Health) inside the fixed137   * 330×480 popup surface.138   */139  textSize?: TextSize;140  /**141   * When true, all entrance/exit transitions and ambient animations are142   * collapsed to instant. Honoured alongside the user's OS-level143   * `prefers-reduced-motion`.144   */145  reduceMotion?: boolean;146  /** Require an unlock after a worker restart before locked Sync resumes. */147  lockedSyncRequiresUnlock?: boolean;148  /** Whether this vault has a local PIN unlock wrapper configured. */149  pinEnabled?: boolean;150  /** Whether the lock screen should open on PIN instead of the password. */151  pinPreferred?: boolean;152  /**153   * User overrides for keyboard shortcuts. Keys are ShortcutAction names154   * (see src/shared/shortcuts.ts). Missing actions fall through to155   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the156   * canonical lookup. Storing only the deltas keeps the vault payload157   * small and lets default updates land for users who never rebind.158   */159  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;160}161162interface StoredPinWrap {163  version: 1;164  salt: string;165  nonce: string;166  ciphertext: string;167  tag: string;168}169170const PIN_WRAP_STORAGE = "peach_pin_wrap";171const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";172const PIN_MIN_LENGTH = 4;173const PIN_MAX_LENGTH = 12;174const PIN_MAX_ATTEMPTS = 3;175176let settings: VaultSettings = {};177const CARD_DECLINE_MAX = 256;178/** Hex length of a keyed card-decline digest. Legacy records are 16 hex. */179const CARD_DECLINE_DIGEST_HEX = 32;180181async function metadataDigest(value: string, key: string): Promise<string> {182  const bytes = new TextEncoder().encode(`${key}:${value}`);183  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));184  return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");185}186187let inMemoryPinWrap: StoredPinWrap | null = null;188let inMemoryPinFailedAttempts = 0;189let sessionPinStorageFailed = false;190191export function getSettings(): VaultSettings {192  return settings;193}194195export async function ensureSettingsLoaded(): Promise<VaultSettings> {196  await loadSettings();197  return settings;198}199200export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {201  await loadSettings();202  // LV-7: cheap insurance against prototype pollution if a future caller ever203  // feeds parsed external JSON here (native spread keeps __proto__ an own-204  // property today, but Object.assign-style consumers and JSON revivers have205  // been burned before).206  const safe = Object.fromEntries(207    Object.entries(patch).filter(208      ([k]) => !["__proto__", "constructor", "prototype"].includes(k),209    ),210  ) as Partial<VaultSettings>;211  settings = { ...settings, ...safe };212  await persistSettings();213}214215function isValidPin(pin: string): boolean {216  return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);217}218219/** Set up a local PIN wrapper after proving the current master password. */220export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {221  if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;222  if (!(await verifyMainPassword(currentPassword))) return false;223224  const salt = generateSalt();225  const pinKey = await deriveKey(pin, salt);226  try {227    const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");228    await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });229    await updateSettings({ pinEnabled: true });230    return true;231  } finally {232    zeroKey(pinKey);233  }234}235236async function clearPinUnlockStorage(): Promise<void> {237  inMemoryPinWrap = null;238  inMemoryPinFailedAttempts = 0;239  sessionPinStorageFailed = false;240  if (chrome.storage.session) {241    await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);242  }243  await updateSettings({ pinEnabled: false });244}245246/** Remove the PIN wrapper after proving the current master password. */247export async function disablePinUnlock(currentPassword: string): Promise<boolean> {248  if (!derivedKey || vaultMode !== "real") return false;249  if (!(await verifyMainPassword(currentPassword))) return false;250  await clearPinUnlockStorage();251  return true;252}253254/** Unlock the real vault from its locally stored PIN-wrapped derived key. */255export async function unlockWithPin(pin: string): Promise<boolean> {256  if (!isValidPin(pin)) return false;257  await loadSettings();258  if (!settings.pinEnabled) return false;259260  const { stored, failedAttempts } = await loadPinState();261  if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;262  if (263    !stored ||264    stored.version !== 1 ||265    !stored.salt ||266    !stored.nonce ||267    !stored.ciphertext ||268    !stored.tag269  ) return false;270271  const pinKey = await deriveKey(pin, unhex(stored.salt));272  let unwrappedKey: Uint8Array | null = null;273  try {274    const masterKeyHex = await decrypt(275      pinKey,276      stored.nonce,277      stored.ciphertext,278      stored.tag,279      "pin_wrap_v1",280    );281    if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;282    unwrappedKey = unhex(masterKeyHex);283    const result = await unlockWithKey(masterKeyHex);284    if (!result.ok) return false;285    await resetPinFailedAttempts();286    return true;287  } catch {288    await recordPinFailure(failedAttempts + 1);289    return false;290  } finally {291    zeroKey(pinKey);292    if (unwrappedKey) zeroKey(unwrappedKey);293  }294}295296async function savePinWrap(stored: StoredPinWrap): Promise<void> {297  inMemoryPinWrap = stored;298  inMemoryPinFailedAttempts = 0;299  sessionPinStorageFailed = !chrome.storage.session;300  if (!chrome.storage.session) return;301  try {302    await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });303    sessionPinStorageFailed = false;304  } catch {305    // Firefox fallback: keep the wrapper only in this background context.306    sessionPinStorageFailed = true;307  }308}309310async function loadPinState(): Promise<{311  stored: StoredPinWrap | undefined;312  failedAttempts: number;313}> {314  if (!chrome.storage.session) {315    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };316  }317  try {318    const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);319    return {320      stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??321        (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),322      failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"323        ? result[PIN_ATTEMPTS_STORAGE]324        : 0,325    };326  } catch {327    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };328  }329}330331async function recordPinFailure(failedAttempts: number): Promise<void> {332  inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);333  if (!chrome.storage.session) return;334  try {335    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });336  } catch { /* in-memory counter remains the fallback */ }337}338339async function resetPinFailedAttempts(): Promise<void> {340  inMemoryPinFailedAttempts = 0;341  if (!chrome.storage.session) return;342  try {343    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });344  } catch { /* in-memory counter remains the fallback */ }345}346347export async function getPinUnlockStatus(): Promise<{348  configured: boolean;349  available: boolean;350  attemptsRemaining: number;351}> {352  await loadSettings();353  if (!settings.pinEnabled) {354    return { configured: false, available: false, attemptsRemaining: 0 };355  }356  const { stored, failedAttempts } = await loadPinState();357  const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;358  return {359    configured: true,360    available,361    attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,362  };363}364365async function persistSettings(): Promise<void> {366  try {367    await chrome.storage.local.set({368      peach_settings: {369        ...settings,370        manualSummonDomains: undefined,371        manualSummonSalt: undefined,372        cardFingerprintSalt: undefined,373        declinedCardFingerprints: settings.declinedCardFingerprints ?? [],374        metadataVersion: 1,375      },376    });377  } catch (err) {378    throw mapStorageError(err);379  }380}381382function mapStorageError(err: unknown): Error {383  const message = err instanceof Error ? err.message : String(err);384  if (/QUOTA_BYTES|quota/i.test(message)) {385    return new Error("STORAGE_QUOTA_EXCEEDED");386  }387  return err instanceof Error ? err : new Error(message);388}389390async function loadSettings(): Promise<void> {391  const result = await chrome.storage.local.get("peach_settings");392  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};393}394395// Fire-and-forget sync trigger after a committed vault write. A failure here396// must never fail the write that caused it.397function fireSyncTrigger(reason: "vault_change"): void {398  void Promise.resolve()399    .then(() => triggerSyncIfReachable({ reason }))400    .catch(() => {401      /* no sync runner registered, or nothing reachable — non-fatal */402    });403}404export function lockedSyncRequiresUnlock(): boolean {405  return settings.lockedSyncRequiresUnlock === true;406}407408let lockedSyncRefreshHook: (() => Promise<void>) | null = null;409let lockedSyncUnlockHook: (() => Promise<void>) | null = null;410let decoySyncTeardownHook: (() => void) | null = null;411export type IdentityHooks = {412  /** Drop the cached identity: the session is no longer a real unlocked one. */413  onSessionEnded: () => Promise<void>;414  /** Re-wrap the identity under the keys now in force, after a vault key swap. */415  onKeysRotated: () => Promise<void>;416  /** Promote a pre-vault identity once a vault exists, given the password that created it. */417  onVaultCreated: (password: string) => Promise<void>;418};419420let identityHooks: IdentityHooks | null = null;421422export function setIdentityHooks(hooks: IdentityHooks): void {423  identityHooks = hooks;424}425426async function notifyIdentitySessionEnded(): Promise<void> {427  await identityHooks?.onSessionEnded().catch((error) => {428    console.warn("[Peach] local identity cache clear failed:", error);429  });430}431432async function notifyIdentityKeysRotated(): Promise<void> {433  await identityHooks?.onKeysRotated().catch((error) => {434    console.warn("[Peach] local identity re-wrap failed:", error);435  });436}437438export type SealedBox = { nonce: string; ciphertext: string; tag: string };439export type IdentityKeyEnvelope = {440  root?: SealedBox;441  local?: SealedBox;442  pending?: SealedBox;443  pendingSalt?: string;444};445446export function setLockedSyncHooks(hooks: {447  refresh: () => Promise<void>;448  afterUnlock: () => Promise<void>;449  onDecoy?: () => void;450}): void {451  lockedSyncRefreshHook = hooks.refresh;452  lockedSyncUnlockHook = hooks.afterUnlock;453  decoySyncTeardownHook = hooks.onDecoy ?? null;454}455456async function refreshLockedSyncState(): Promise<void> {457  if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;458  await lockedSyncRefreshHook().catch((error) => {459    console.warn("[Peach sync] locked spool refresh failed:", error);460  });461}462463// ---------------------------------------------------------------------------464// HIBP breach check (k-anonymity model)465// ---------------------------------------------------------------------------466467export async function checkBreach(password: string): Promise<number> {468  // HIBP k-anonymity API is keyed on SHA-1 prefixes — this is dictated by469  // the public API, not a security choice. Do not migrate to SHA-256 here.470  const encoder = new TextEncoder();471  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));472  const hashHex = Array.from(new Uint8Array(hashBuffer))473    .map((b) => b.toString(16).padStart(2, "0"))474    .join("")475    .toUpperCase();476477  const prefix = hashHex.slice(0, 5);478  const suffix = hashHex.slice(5);479480  try {481    // PCH-02-007: request HIBP response padding so response length does not482    // reveal how many suffixes a queried prefix returned.483    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {484      headers: { "Add-Padding": "true" },485    });486    if (!res.ok) return 0;487    const text = await res.text();488    const match = text.split("\r\n").find((l) => l.startsWith(suffix));489    return match ? parseInt(match.split(":")[1], 10) : 0;490  } catch {491    return 0; // Network unavailable — don't block492  }493}494495export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {496  // Unreachable from the message layer, which gates on isUnlocked first.497  const key = await cardDeclineKey();498  if (!key) return;499  const fingerprint = await cardFingerprint(last4, expMonth, expYear);500  await loadSettings();501  settings = {502    ...settings,503    declinedCardFingerprints: [504      // Legacy records predate the keyed digest and are silently dropped here:505      // they are 16 hex characters rather than 32, and are no longer accepted.506      ...(settings.declinedCardFingerprints ?? []).filter(507        (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,508      ),509      await metadataDigest(fingerprint, key),510    ].slice(-CARD_DECLINE_MAX),511  };512  await persistSettings();513}514515export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {516  await loadSettings();517  const key = await cardDeclineKey();518  if (!key) return false;519  const fingerprint = await cardFingerprint(last4, expMonth, expYear);520  const candidate = await metadataDigest(fingerprint, key);521  return (settings.declinedCardFingerprints ?? []).some(522    (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,523  );524}525526/**527 * Keying material for the declined-card digest, derived from the vault root528 * key so it is available only while unlocked and never written to storage.529 *530 * PB-26-108: the previous scheme stored a random salt beside the records. A531 * salt defeats precomputed tables, not an attacker holding the file — the532 * candidate space here is ~10^4 last-four values x 12 months x a few years, so533 * a stored salt left the card's last four and expiry recoverable offline in534 * seconds. `peach_settings` is unencrypted and directly readable by a content535 * script on Firefox, so the key has to live outside storage entirely.536 */537async function cardDeclineKey(): Promise<string | null> {538  const root = getRootKeyHex();539  if (!root) return null;540  const digest = new Uint8Array(541    await crypto.subtle.digest(542      "SHA-256",543      new TextEncoder().encode(`peach-card-decline-v1:${root}`),544    ),545  );546  return hex(digest);547}548549async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {550  const encoder = new TextEncoder();551  const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));552  return Array.from(new Uint8Array(buf).slice(0, 8))553    .map((b) => b.toString(16).padStart(2, "0"))554    .join("");555}556557function hex(buf: Uint8Array): string {558  return Array.from(buf)559    .map((b) => b.toString(16).padStart(2, "0"))560    .join("");561}562563function unhex(s: string): Uint8Array {564  const out = new Uint8Array(s.length / 2);565  for (let i = 0; i < s.length; i += 2) {566    out[i / 2] = parseInt(s.slice(i, i + 2), 16);567  }568  return out;569}570571// ---------------------------------------------------------------------------572// Root key generation and recovery word wrapping573// ---------------------------------------------------------------------------574575/**576 * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key577 * derived from the recovery word material via HKDF-SHA256. The wrapped blob578 * fields are stored in slot A alongside the password-wrapped ciphertext. The579 * root key itself is kept in memory for the session so getRecoveryWords() can580 * encode it without re-deriving.581 *582 * SECURITY MODEL (PCH-01-005): the recovery KEK is derived from the 12-word583 * material, which encodes only rootKey[0..16] (recovery-words.ts). The KEK is thus584 * derived from a PREFIX of the very plaintext it wraps, and effective recovery585 * security is 128-bit (standard for a 12-word phrase), NOT 256-bit. This is safe —586 * unwrapping requires the words, and the GCM ciphertext leaks nothing without them —587 * but it is intentional, not an oversight. A future escrow version wanting 256-bit588 * recovery must derive the words from INDEPENDENT entropy, not from the root key.589 */590async function generateAndWrapRootKey(): Promise<{591  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey592  recoverySalt: string;593  wrappedRootKey: string;594  wrappedNonce: string;595  wrappedTag: string;596}> {597  const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));598599  // Derive the recovery key from the BIP39 words encoding of the root key.600  const words = await rootKeyToWords(rootKeyBytes);601  const wordMaterial = await wordsToRootKeyMaterial(words);602  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));603  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);604605  // Wrap the root key with the recovery key using AES-256-GCM directly.606  // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.607  const nonce = crypto.getRandomValues(new Uint8Array(12));608  const encoded = new TextEncoder().encode(hex(rootKeyBytes));609  const raw = await crypto.subtle.encrypt(610    {611      name: "AES-GCM",612      iv: nonce,613      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,614      tagLength: 128,615    },616    recoveryKey,617    encoded618  );619  // WebCrypto returns ciphertext || auth tag concatenated; split them.620  const rawU8 = new Uint8Array(raw);621  const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));622  const tag = hex(rawU8.slice(rawU8.length - 16));623624  return {625    rawRootKey: hex(rootKeyBytes),626    recoverySalt: hex(recoverySalt),627    wrappedRootKey: ciphertext,628    wrappedNonce: hex(nonce),629    wrappedTag: tag,630  };631}632633async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{634  recoverySalt: string;635  wrappedRootKey: string;636  wrappedNonce: string;637  wrappedTag: string;638}> {639  if (rootKeyBytes.length !== 32) {640    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");641  }642643  const words = await rootKeyToWords(rootKeyBytes);644  const wordMaterial = await wordsToRootKeyMaterial(words);645  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));646  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);647  const nonce = crypto.getRandomValues(new Uint8Array(12));648  const encoded = new TextEncoder().encode(hex(rootKeyBytes));649  const raw = await crypto.subtle.encrypt(650    {651      name: "AES-GCM",652      iv: nonce,653      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,654      tagLength: 128,655    },656    recoveryKey,657    encoded,658  );659  const rawU8 = new Uint8Array(raw);660  return {661    recoverySalt: hex(recoverySalt),662    wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),663    wrappedNonce: hex(nonce),664    wrappedTag: hex(rawU8.slice(rawU8.length - 16)),665  };666}667668/**669 * Unwrap the root key using recovery words. Called during recovery flow when670 * the user provides their 12-word phrase and a new password.671 * Exported for use by the RECOVER_WITH_WORDS message handler.672 */673export async function unwrapRootKeyForRecovery(674  wrappedRootKey: string,675  wrappedNonce: string,676  wrappedTag: string,677  recoverySaltHex: string,678  words: string[]679): Promise<void> {680  const wordMaterial = await wordsToRootKeyMaterial(words);681  const recoverySalt = unhex(recoverySaltHex);682  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);683684  // Decrypt and cache the root key. The root key bytes were hex-encoded before685  // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt686  // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.687  // Each component is stored separately in the vault slot; decode them individually688  // so the nonce is always exactly the 12 bytes from wrappedNonce.689  const nonceRaw = unhex(wrappedNonce);690  const ciphertextRaw = unhex(wrappedRootKey);691  const tagRaw = unhex(wrappedTag);692  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).693  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.694  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;695  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);696  combined.set(ciphertextRaw, 0);697  combined.set(tagRaw, ciphertextRaw.length);698  const raw = await crypto.subtle.decrypt(699    { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },700    recoveryKey,701    combined702  );703  const decryptedHex = new TextDecoder().decode(raw);704  rootKey = unhex(decryptedHex);705}706707/**708 * Re-wrap the cached root key under a new derived key (from the user's new709 * password). Called after password change and after recovery re-key so the710 * same recovery words continue to work with the updated password — and so711 * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.712 *713 * recovery_* fields are intentionally not touched: those wrap rootKey under714 * the recovery words, which do not change when the password changes.715 */716export async function rewrapRootKeyForPassword(717  newDerivedKey: Uint8Array,718): Promise<void> {719  if (!rootKey) throw new Error("root key not set");720  const slotA = await loadSlot(SLOT_A);721  if (!slotA) throw new Error("missing slot A");722  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);723  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });724}725726/**727 * Change the vault encryption to a new derived key (already derived from728 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.729 * Caller is responsible for key swap and snapshot.730 */731async function reencryptVaultForNewKey(732  newKey: Uint8Array,733  slotA: StoredVault734): Promise<void> {735  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);736  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");737  await writeSlot(SLOT_A, {738    ...slotA,739    nonce,740    ciphertext,741    tag,742  });743}744745/**746 * Full password change with an already-derived key (used after recovery).747 * Unlike changePassword(), this skips re-verification of current password748 * since recovery flow has already validated the user's identity via words.749 */750export async function changePasswordWithNewKey(751  newSalt: Uint8Array,752  newKey: Uint8Array753): Promise<void> {754  const epoch = vaultEpoch;755  const throwIfStale = () => {756    if (epoch === vaultEpoch) return;757    zeroKey(newKey);758    throw new Error("vault_locked");759  };760  const slotA = await loadSlot(SLOT_A);761  throwIfStale();762  if (!slotA) throw new Error("missing slot A");763  // reencryptVaultForNewKey must receive the updated salt so that subsequent764  // UNLOCK calls with the new password can re-derive the correct key.765  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });766  throwIfStale();767  if (derivedKey) zeroKey(derivedKey);768  derivedKey = newKey;769  // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow770  // reaches this path, so a post-recovery SW restart would otherwise lock out771  // a user who just reset their password.772  await saveSessionKey(newKey, "real", epoch);773  throwIfStale();774  // Same reason as changePassword: the unlock key just changed, so the775  // identity's local wrap has to be re-encrypted under it.776  await notifyIdentityKeysRotated();777  throwIfStale();778  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works779  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow780  // should have populated it — fall back silently rather than blowing up the781  // recovery path; the escrow is already present on every supported vault.782  if (rootKey) {783    const slotAfter = await loadSlot(SLOT_A);784    throwIfStale();785    if (slotAfter) {786      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);787      throwIfStale();788      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });789      throwIfStale();790    }791  }792  if (!duressConfigured) {793    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));794    throwIfStale();795  }796  await clearPinUnlockStorage();797  throwIfStale();798  await stampLocalVaultMutation();799  throwIfStale();800}801802// ---------------------------------------------------------------------------803// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive804// a new key after password reset without re-importing the crypto module.805// ---------------------------------------------------------------------------806807export { deriveKey, generateSalt };808809export async function loadSlotA(): Promise<StoredVault | null> {810  return loadSlot(SLOT_A);811}812813/**814 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields815 * (a second wrapping of the root key under the password-derived key). This816 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without817 * the user re-entering their recovery phrase: the same root key bytes —818 * and therefore the same 12 words — survive every unlock.819 *820 * Returns null when the escrow is absent or decryption fails.821 */822async function deriveRootKeyFromDerivedKey(823  derivedKeyBytes: Uint8Array,824  slotA: StoredVault,825): Promise<Uint8Array | null> {826  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {827    return null;828  }829  try {830    const rootHex = await decrypt(831      derivedKeyBytes,832      slotA.pw_root_nonce,833      slotA.pw_root_ciphertext,834      slotA.pw_root_tag,835      "pw_root_v1",836    );837    return unhex(rootHex);838  } catch {839    return null;840  }841}842843export async function sealIdentityKeyUnderVault(ik: Uint8Array): Promise<IdentityKeyEnvelope> {844  if (vaultMode !== "real" || !rootKey || !derivedKey) throw new Error("vault_locked");845  // The root wrap survives password change and recovery-words re-key; the846  // local wrap is available during normal unlock. Pending is only pre-vault.847  const [root, local] = await Promise.all([848    encrypt(rootKey, hex(ik), "peach-local-identity-ik-root-v1"),849    encrypt(derivedKey, hex(ik), "peach-local-identity-ik-local-v1"),850  ]);851  return { root, local };852}853854export async function sealIdentityKeyPreVault(855  password: string,856  ik: Uint8Array,857): Promise<IdentityKeyEnvelope> {858  const salt = generateSalt();859  const key = await deriveKey(password, salt);860  try {861    const pending = await encrypt(key, hex(ik), "peach-local-identity-ik-pending-v1");862    return { pending, pendingSalt: hex(salt) };863  } finally {864    zeroKey(key);865  }866}867868/**869 * Open the identity key with any wrapping key that is currently available:870 * the live vault-unlock key, then the vault root key, then the pre-vault871 * password. Each attempt is awaited only after the previous one fails, so a872 * successful early unwrap never leaves a later decrypt rejection unobserved,873 * and the pre-vault Argon2id is not paid when a vault key already opens the874 * envelope.875 */876export async function openIdentityKey(877  env: IdentityKeyEnvelope,878  password?: string,879): Promise<Uint8Array | null> {880  if (env.local && derivedKey) {881    const ik = await openSealedKey(derivedKey, env.local, "peach-local-identity-ik-local-v1");882    if (ik) return ik;883  }884  if (env.root && rootKey) {885    const ik = await openSealedKey(rootKey, env.root, "peach-local-identity-ik-root-v1");886    if (ik) return ik;887  }888  if (!env.pending || !env.pendingSalt || password === undefined) return null;889  let key: Uint8Array;890  try {891    const salt = unhex(env.pendingSalt);892    key = await deriveKey(password, salt);893    salt.fill(0);894  } catch {895    return null;896  }897  try {898    return await openSealedKey(key, env.pending, "peach-local-identity-ik-pending-v1");899  } finally {900    zeroKey(key);901  }902}903904/** Decrypt a sealed 32-byte key; null for a wrong key, a bad shape, or corrupt bytes. */905async function openSealedKey(906  key: Uint8Array,907  box: SealedBox,908  info: string,909): Promise<Uint8Array | null> {910  try {911    const value = unhex(await decrypt(key, box.nonce, box.ciphertext, box.tag, info));912    if (value.length === 32) return value;913    value.fill(0);914    return null;915  } catch {916    return null;917  }918}919920// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the921// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.922async function wrapRootKeyForDerivedKey(923  derivedKeyBytes: Uint8Array,924  rootKeyBytes: Uint8Array,925): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {926  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");927  return {928    pw_root_ciphertext: ciphertext,929    pw_root_nonce: nonce,930    pw_root_tag: tag,931  };932}933934/** Returns the 12 recovery words for the current session's root key. */935export async function getRecoveryWords(): Promise<string[]> {936  if (!rootKey) {937    throw new Error("recovery not set up");938  }939  return rootKeyToWords(rootKey);940}941942async function loadSlot(key: string): Promise<StoredVault | null> {943  const result = await chrome.storage.local.get(key);944  return (result as Record<string, StoredVault | undefined>)[key] ?? null;945}946947async function writeSlot(key: string, vault: StoredVault): Promise<void> {948  try {949    await chrome.storage.local.set({ [key]: vault });950  } catch (err) {951    throw mapStorageError(err);952  }953}954955/**956 * Roll back a vault that was created by [createVaultFromRootKey] but957 * couldn't be paired (e.g. the subsequent license activation, identity958 * derivation, or paired-device upsert failed). Without this, a failed959 * receiver session leaves behind an empty vault — the user has to960 * re-pair to get a working setup, but the empty vault sits in the way.961 *962 * The call is idempotent: removing keys that aren't there is a no-op.963 */964export async function rollbackAdoptedVault(): Promise<void> {965  await lock();966  try {967    await chrome.storage.local.remove([968      SLOT_A,969      SLOT_B,970      "peach_last_modified_at",971    ]);972  } catch (err) {973    throw mapStorageError(err);974  }975}976977async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {978  const raw = await chrome.storage.local.get("peach_last_modified_at");979  const previous = Number(980    (raw as Record<string, unknown>).peach_last_modified_at,981  );982  const next = Math.max(983    Date.now(),984    Number.isFinite(previous) ? previous + 1 : 0,985  );986  try {987    await chrome.storage.local.set({988      [SLOT_A]: slotA,989      [SLOT_B]: slotB,990      peach_last_modified_at: String(next),991    });992  } catch (err) {993    throw mapStorageError(err);994  }995}996997// Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the998// sync layer's last-write-wins merge against wall-clock regressions (NTP step,999// manual clock change, DST glitch): a backwards Date.now() must never produce1000// an entry timestamp that's <= the entry's existing modifiedAt, or a genuine1001// local edit would lose to a stale remote copy. The returned value is always1002// strictly greater than both the entry's current modifiedAt and the previous1003// call's result (SEV-2-E).1004let lastEntryTimestamp = 0;1005function monotonicEntryTimestamp(currentModifiedAt?: number): number {1006  const base = Math.max(1007    Date.now(),1008    (currentModifiedAt ?? 0) + 1,1009    lastEntryTimestamp + 1,1010  );1011  lastEntryTimestamp = base;1012  return base;1013}10141015async function stampLocalVaultMutation(): Promise<void> {1016  const raw = await chrome.storage.local.get("peach_last_modified_at");1017  const previous = Number(1018    (raw as Record<string, unknown>).peach_last_modified_at,1019  );1020  const next = Math.max(1021    Date.now(),1022    Number.isFinite(previous) ? previous + 1 : 0,1023  );1024  await chrome.storage.local.set({1025    peach_last_modified_at: String(next),1026  });1027}10281029// ---------------------------------------------------------------------------1030// Padding + noise helpers1031// ---------------------------------------------------------------------------10321033/**1034 * Pads the plaintext string with trailing spaces so that the encoded byte1035 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing1036 * whitespace after a complete document, so the padding is transparent on1037 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.1038 */1039function padPlaintext(json: string, minBytes = 0): string {1040  const encoded = new TextEncoder().encode(json);1041  const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);1042  // minBytes lets the duress path force a fixed ceiling so both slots match.1043  const target = Math.max(bucket, minBytes);1044  const padLen = target - encoded.length;1045  return json + " ".repeat(padLen);1046}10471048/**1049 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The1050 * ciphertext length matches the given byte count. Because GCM ciphertext is1051 * pseudo-random, this blob is indistinguishable at rest from a real1052 * encrypted vault without the key.1053 *1054 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto1055 * spec hard limit). Chunk the fill so vaults larger than that — common once1056 * the user has dozens of entries or any passkey — don't throw.1057 */1058const CSPRNG_CHUNK = 65_536;10591060function fillRandom(buf: Uint8Array): Uint8Array {1061  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {1062    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);1063    const chunk = new Uint8Array(end - offset);1064    crypto.getRandomValues(chunk);1065    buf.set(chunk, offset);1066  }1067  return buf;1068}10691070function generateNoiseBlob(ciphertextBytes: number): StoredVault {1071  const salt = crypto.getRandomValues(new Uint8Array(16));1072  const nonce = crypto.getRandomValues(new Uint8Array(12));1073  const cipher = fillRandom(new Uint8Array(ciphertextBytes));1074  const tag = crypto.getRandomValues(new Uint8Array(16));1075  return {1076    version: 1,1077    salt: hex(salt),1078    nonce: hex(nonce),1079    ciphertext: hex(cipher),1080    tag: hex(tag),1081  };1082}10831084// ---------------------------------------------------------------------------1085// Encrypted plaintext envelope1086// ---------------------------------------------------------------------------10871088type VaultPayload = {1089  entries: VaultEntry[];1090  tombstones?: Record<string, number>;1091  duressConfigured?: boolean;1092};10931094type DecryptedPayload = {1095  entries: VaultEntry[];1096  tombstones: Map<string, number>;1097  duressConfigured: boolean;1098};10991100function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {1101  const envelope: VaultPayload = {1102    entries,1103    tombstones: Object.fromEntries(tombstones),1104    duressConfigured: duress,1105  };1106  return padPlaintext(JSON.stringify(envelope), minBytes);1107}11081109function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {1110  const result = new Map<string, number>();1111  if (raw && typeof raw === "object" && !Array.isArray(raw)) {1112    for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {1113      if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {1114        result.set(id, timestamp);1115      }1116    }1117  }1118  // Older payloads had no ledger. Rebuild the durable portion from any1119  // soft-deleted entries before they are eligible for a later purge.1120  for (const entry of allEntries) {1121    if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1122      continue;1123    }1124    if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1125      result.set(entry.id, entry.deletedAt);1126    }1127  }1128  return result;1129}11301131function reconcileTombstones(): void {1132  const byId = new Map(entries.map((entry) => [entry.id, entry]));1133  for (const [id] of tombstones) {1134    if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1135  }1136  for (const entry of entries) {1137    if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1138      if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1139        tombstones.set(entry.id, entry.deletedAt);1140      }1141    }1142  }1143}11441145function decodePayload(plain: string): DecryptedPayload {1146  const parsed = JSON.parse(plain) as VaultPayload;1147  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1148    throw new Error("invalid vault payload");1149  }1150  const normalized = parsed.entries.map(normalizeEntry);1151  return {1152    entries: normalized,1153    tombstones: decodeTombstones(parsed.tombstones, normalized),1154    duressConfigured: parsed.duressConfigured === true,1155  };1156}11571158/**1159 * Consolidate duplicate passkey records after registration.1160 *1161 * Multiple registrations for one RP can leave duplicate passkey records. This1162 * pass collapses the duplicates:1163 *1164 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.1165 *   2. Within each group, the most recently-active credential wins (prefer1166 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).1167 *   3. If a NON-passkey login entry exists for that RP (matched by url or1168 *      additionalUrls hostname), attach the winning passkey to it and1169 *      soft-delete every passkey-bearing entry in the group.1170 *   4. Otherwise, soft-delete every loser in the group, keeping the winner1171 *      as a passkey-only entry.1172 *1173 * Soft delete (deletedAt) rather than splice — the trash flow keeps the1174 * record around for retention and the user can restore if the heuristic1175 * picked wrong. The `merged` flag goes back to decodePayload so the1176 * unlock path persists the consolidated shape immediately.1177 */1178function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1179  let merged = false;1180  const now = Date.now();11811182  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1183  const byRpId = new Map<string, VaultEntry[]>();1184  for (const e of candidates) {1185    const rpId = e.passkey!.rpId;1186    const list = byRpId.get(rpId);1187    if (list) list.push(e);1188    else byRpId.set(rpId, [e]);1189  }11901191  for (const [rpId, group] of byRpId) {1192    if (group.length === 0) continue;1193    group.sort((a, b) => {1194      const aT =1195        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1196      const bT =1197        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1198      return bT - aT;1199    });1200    const winner = group[0]!;12011202    // Look for a NON-passkey login entry matching this RP. Use the URL1203    // hostname rather than a substring search so a "login.foo.com" entry1204    // doesn't accidentally absorb a passkey for "foo.com".1205    const loginTarget = entries.find((e) => {1206      if (e.deletedAt || e.passkey || e.type !== "login") return false;1207      if (e.url && extractDomain(e.url) === rpId) return true;1208      for (const extra of e.additionalUrls ?? []) {1209        if (extractDomain(extra) === rpId) return true;1210      }1211      return false;1212    });12131214    if (loginTarget) {1215      loginTarget.passkey = winner.passkey;1216      // A passkey entry may also carry the site's TOTP seed. Preserve it when1217      // the passkey is consolidated onto an existing login instead of1218      // soft-deleting the only record that held the 2FA configuration.1219      if (!loginTarget.totp && winner.totp) {1220        loginTarget.totp = winner.totp;1221        loginTarget.totpAlgorithm = winner.totpAlgorithm;1222        loginTarget.totpDigits = winner.totpDigits;1223        loginTarget.totpPeriod = winner.totpPeriod;1224      }1225      loginTarget.modifiedAt = now;1226      for (const dup of group) {1227        if (!dup.deletedAt) {1228          dup.deletedAt = now;1229          dup.modifiedAt = now;1230        }1231      }1232      merged = true;1233    } else if (group.length > 1) {1234      for (let i = 1; i < group.length; i += 1) {1235        const dup = group[i]!;1236        if (!dup.deletedAt) {1237          dup.deletedAt = now;1238          dup.modifiedAt = now;1239        }1240      }1241      merged = true;1242    }1243  }12441245  return { merged };1246}12471248// Validates that a `StoredVault` record on disk has the field shape we expect1249// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only1250// authenticates the static VERSION_BYTE AAD, not the `version` field on the1251// outer record — a corrupted `version: 42` slot still decrypts cleanly. This1252// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a1253// history-restore path instead of falsely reporting "wrong password".1254function hasValidVaultSchema(v: unknown): v is StoredVault {1255  if (typeof v !== "object" || v === null) return false;1256  const r = v as Record<string, unknown>;1257  return (1258    r.version === 1 &&1259    typeof r.salt === "string" &&1260    typeof r.nonce === "string" &&1261    typeof r.ciphertext === "string" &&1262    typeof r.tag === "string"1263  );1264}1265const MAX_HISTORY_BLOB_BYTES = 1_024 * 1_024;12661267function hasValidHistorySlot(value: unknown): value is StoredVault {1268  if (!hasValidVaultSchema(value)) return false;1269  return /^[0-9a-f]{32}$/i.test(value.salt) &&1270    /^[0-9a-f]{24}$/i.test(value.nonce) &&1271    /^[0-9a-f]+$/i.test(value.ciphertext) &&1272    value.ciphertext.length % 2 === 0 &&1273    value.ciphertext.length / 2 <= MAX_HISTORY_BLOB_BYTES &&1274    /^[0-9a-f]{32}$/i.test(value.tag);1275}12761277function parseHistorySlot(value: string): StoredVault | null {1278  try {1279    const parsed: unknown = JSON.parse(value);1280    return hasValidHistorySlot(parsed) ? parsed : null;1281  } catch {1282    return null;1283  }1284}12851286function isHistoryRecord(value: unknown): value is VaultSnapshot {1287  if (typeof value !== "object" || value === null) return false;1288  const record = value as Record<string, unknown>;1289  return typeof record.id === "string" && record.id.length > 0 && record.id.length <= 128 &&1290    typeof record.savedAt === "number" && Number.isFinite(record.savedAt) &&1291    typeof record.reason === "string" &&1292    typeof record.vault_a === "string" && record.vault_a.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1293    typeof record.vault_b === "string" && record.vault_b.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1294    (record.entryCount === null || typeof record.entryCount === "number");1295}12961297function sanitizeHistory(value: unknown): VaultSnapshot[] {1298  if (typeof value !== "object" || value === null) return [];1299  if (!("snapshots" in value) || !Array.isArray(value.snapshots)) return [];1300  return value.snapshots.filter(isHistoryRecord).slice(0, HISTORY_MAX);1301}13021303export type TryDecryptResult =1304  | { status: "ok"; payload: DecryptedPayload }1305  | { status: "wrong_key" }1306  | { status: "corrupted"; reason: string };13071308// Exported solely for `unlockFromCodex` below, which needs to verify a1309// supplied passphrase against a slotA blob *without* committing anything to1310// storage. Keep this internal otherwise — callers crossing the messages.ts1311// boundary should always go through `unlock` / `verifyMainPassword` so the1312// constant-time invariants in Projects.md §3.4 are not bypassed.1313export async function tryDecryptSlot(1314  vault: StoredVault,1315  key: Uint8Array,1316  aadTag?: string,1317): Promise<DecryptedPayload | null> {1318  const res = await tryDecryptSlotResult(vault, key, aadTag);1319  return res.status === "ok" ? res.payload : null;1320}13211322export async function tryDecryptSlotResult(1323  vault: StoredVault,1324  key: Uint8Array,1325  aadTag?: string,1326): Promise<TryDecryptResult> {1327  if (!hasValidVaultSchema(vault)) {1328    return { status: "corrupted", reason: "invalid_slot_schema" };1329  }1330  let plain: string;1331  try {1332    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1333  } catch {1334    return { status: "wrong_key" };1335  }1336  try {1337    const parsed = JSON.parse(plain) as unknown;1338    if (typeof parsed !== "object" || parsed === null) {1339      return { status: "corrupted", reason: "invalid_payload_shape" };1340    }1341    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1342      return { status: "corrupted", reason: "invalid_payload_shape" };1343    }1344    return { status: "ok", payload: decodePayload(plain) };1345  } catch {1346    return { status: "corrupted", reason: "invalid_payload_json" };1347  }1348}13491350// ---------------------------------------------------------------------------1351// Core operations1352// ---------------------------------------------------------------------------13531354function buildDomainIndexSync(all: VaultEntry[]): void {1355  domainIndex = new Map();13561357  for (const entry of all) {1358    if (entry.deletedAt) continue;1359    if (entry.type === "note" || entry.type === "card") continue;13601361    // An entry can match multiple sites via `additionalUrls`. Index each1362    // hostname so `queryDomain` returns the entry regardless of which alias1363    // the user is currently visiting. Dedupe per-entry so a single entry1364    // never appears twice in one bucket.1365    const domains = new Set<string>();1366    const primary = extractDomain(entry.url ?? "");1367    if (primary) domains.add(primary);1368    for (const extra of entry.additionalUrls ?? []) {1369      const d = extractDomain(extra);1370      if (d) domains.add(d);1371    }1372    if (domains.size === 0) continue;13731374    for (const domain of domains) {1375      const bucket = domainIndex.get(domain) ?? [];1376      bucket.push(entry);1377      domainIndex.set(domain, bucket);1378    }1379  }1380}13811382export async function createVault(password: string): Promise<void> {1383  const salt = generateSalt();1384  derivedKey = await deriveKey(password, salt);1385  entries = [];1386  tombstones = new Map();1387  vaultMode = "real";1388  duressConfigured = false;1389  buildDomainIndexSync(entries);13901391  // Generate and wrap the root key for recovery word escrow.1392  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1393    await generateAndWrapRootKey();1394  rootKey = unhex(rawRootKey);13951396  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1397  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");13981399  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate1400  // it without recovery words (H2/COR-006).1401  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);14021403  const slotA: StoredVault = {1404    version: 1,1405    salt: hex(salt),1406    nonce,1407    ciphertext,1408    tag,1409    recovery_salt: recoverySalt,1410    recovery_wrapped_root_key: wrappedRootKey,1411    recovery_nonce: wrappedNonce,1412    recovery_tag: wrappedTag,1413    ...pwRoot,1414  };1415  await writeSlot(SLOT_A, slotA);14161417  // Matching-size noise blob for slot B, so a fresh install shows two1418  // similarly-shaped records regardless of whether duress is ever set up.1419  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1420  await stampLocalVaultMutation();14211422  // An identity created while this install had no vault was sealed under the1423  // password alone; the vault keys exist now, so re-seal it under them.1424  await identityHooks?.onVaultCreated(password).catch((error) => {1425    console.warn("[Peach] local identity promotion failed:", error);1426  });14271428  touchAutoLock();1429}14301431/**1432 * Creates a local vault from an existing vault root key received through the1433 * SAS-gated adoption protocol. The password is local to this device; it wraps1434 * the adopted root key at rest and does not need to match the source device.1435 */1436export async function createVaultFromRootKey(1437  password: string,1438  rootKeyHex: string,1439): Promise<{ vaultId: string }> {1440  if (typeof password !== "string" || password.length === 0) {1441    throw new Error("ADOPT_INVALID_PASSWORD");1442  }1443  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1444    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1445  }14461447  const rootKeyBytes = unhex(rootKeyHex);1448  if (rootKeyBytes.length !== 32) {1449    rootKeyBytes.fill(0);1450    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1451  }14521453  let nextDerivedKey: Uint8Array | null = null;1454  let sessionRootKey: Uint8Array | null = null;1455  try {1456    if ((await loadSlot(SLOT_A)) !== null) {1457      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1458    }14591460    const nextEntries: VaultEntry[] = [];1461    const nextDuressConfigured = false;1462    const vaultId = await computeVaultId(rootKeyBytes);1463    const salt = generateSalt();1464    nextDerivedKey = await deriveKey(password, salt);1465    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);14661467    sessionRootKey = new Uint8Array(rootKeyBytes);1468    const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1469    const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1470    const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1471    const slotA: StoredVault = {1472      version: 1,1473      salt: hex(salt),1474      nonce,1475      ciphertext,1476      tag,1477      recovery_salt: recovery.recoverySalt,1478      recovery_wrapped_root_key: recovery.wrappedRootKey,1479      recovery_nonce: recovery.wrappedNonce,1480      recovery_tag: recovery.wrappedTag,1481      ...pwRoot,1482    };1483    const slotB = generateNoiseBlob(ciphertext.length / 2);1484    await commitCreatedVaultSlots(slotA, slotB);14851486    const previousDerivedKey = derivedKey;1487    const previousRootKey = rootKey;1488    derivedKey = nextDerivedKey;1489    rootKey = sessionRootKey;1490    nextDerivedKey = null;1491    sessionRootKey = null;1492    entries = nextEntries;1493    tombstones = new Map();1494    vaultMode = "real";1495    duressConfigured = nextDuressConfigured;1496    buildDomainIndexSync(entries);1497    if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1498    if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1499    touchAutoLock();15001501    // The receiver's identity was minted before this vault existed (adoption v21502    // needs it in its first frame), so re-seal it under the vault keys now.1503    await identityHooks?.onVaultCreated(password).catch((error) => {1504      console.warn("[Peach] local identity promotion failed:", error);1505    });15061507    return { vaultId };1508  } finally {1509    rootKeyBytes.fill(0);1510    if (nextDerivedKey) zeroKey(nextDerivedKey);1511    if (sessionRootKey) sessionRootKey.fill(0);1512  }1513}15141515export async function hasVault(): Promise<boolean> {1516  return (await loadSlot(SLOT_A)) !== null;1517}15181519/**1520 * Returns whether the real vault has recorded that a duress passphrase is1521 * configured. Reliable only after a real-mode unlock — before unlock or in1522 * decoy mode, returns false because we cannot read slot A's plaintext.1523 */1524export async function isDuressEnabled(): Promise<boolean> {1525  return vaultMode === "real" && duressConfigured;1526}15271528export async function setupDuress(duressPassword: string): Promise<void> {1529  if (!derivedKey || vaultMode !== "real") {1530    throw new Error("vault must be unlocked in real mode");1531  }1532  // Encrypt the decoy payload (empty entries) under the duress key. Pad to1533  // at least the current slot A ciphertext size so the two slots look the1534  // same at rest at setup time.1535  const slotA = await loadSlot(SLOT_A);1536  if (!slotA) throw new Error("missing slot A");15371538  const decoySalt = generateSalt();1539  const decoyKey = await deriveKey(duressPassword, decoySalt);1540  try {1541    // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1542    // same ceiling by the persistSlotA() call below (duressConfigured is now1543    // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1544    // lengths cannot reveal that duress is configured (audit MEDIUM).1545    const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1546    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1547    await writeSlot(SLOT_B, {1548      version: 1,1549      salt: hex(decoySalt),1550      nonce,1551      ciphertext,1552      tag,1553    });1554  } finally {1555    zeroKey(decoyKey);1556  }15571558  // Re-encrypt slot A with the duressConfigured flag set to true. No external1559  // flag is written — the bit lives inside the encrypted envelope.1560  duressConfigured = true;1561  await persistSlotA();1562}15631564export async function disableDuress(): Promise<void> {1565  if (!derivedKey || vaultMode !== "real") {1566    throw new Error("vault must be unlocked in real mode");1567  }1568  // Overwrite slot B with fresh noise that matches slot A's size. The slot1569  // remains occupied so its mere existence does not signal that duress was1570  // ever configured.1571  const slotA = await loadSlot(SLOT_A);1572  if (slotA) {1573    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1574  }15751576  duressConfigured = false;1577  await persistSlotA();1578}15791580// L14/COR-015: this function intentionally checks slot A (real vault) only.1581// A duress-mode user attempting changePassword is rejected earlier by the1582// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1583// distinction here is by design — duress users cannot rotate the real1584// password by guessing it, and the asymmetry in error timing is acceptable1585// because duress mode is opt-in and the user knows their own decoy key.1586export async function verifyMainPassword(password: string): Promise<boolean> {1587  const slotA = await loadSlot(SLOT_A);1588  if (!slotA) return false;1589  const key = await deriveKey(password, unhex(slotA.salt));1590  try {1591    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1592  } finally {1593    zeroKey(key);1594  }1595}15961597export async function unlock(password: string): Promise<boolean> {1598  return attemptUnlockWithDerive(password, deriveKey);1599}16001601async function attemptUnlockWithDerive(1602  password: string,1603  derive: typeof deriveKey,1604): Promise<boolean> {1605  const epoch = vaultEpoch;1606  const stale = () => epoch !== vaultEpoch;1607  // Load both current-format slots in parallel. Both exist on a supported1608  // install; the1609  // parallel load and the parallel derivation below keep wall-clock time1610  // constant regardless of which slot's passphrase (real or decoy) the1611  // caller supplied, so a timing side channel cannot distinguish the1612  // duress path from the real-unlock path (DUR-002).1613  const [slotA, slotB] = await Promise.all([1614    loadSlot(SLOT_A),1615    loadSlot(SLOT_B),1616  ]);16171618  // Derive both keys in parallel, unconditionally.1619  const [keyA, keyB] = await Promise.all([1620    slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1621    slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1622  ]);16231624  // Attempt both decrypts in parallel. The Result variant lets us distinguish1625  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1626  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1627  const [resultA, resultB] = await Promise.all([1628    keyA && slotA1629      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1630      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1631    keyB && slotB1632      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1633      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1634  ]);1635  const payloadA = resultA.status === "ok" ? resultA.payload : null;1636  const payloadB = resultB.status === "ok" ? resultB.payload : null;16371638  if (stale()) {1639    if (keyA) zeroKey(keyA);1640    if (keyB) zeroKey(keyB);1641    return false;1642  }16431644  if (payloadA && keyA) {1645    if (stale()) {1646      zeroKey(keyA);1647      if (keyB) zeroKey(keyB);1648      return false;1649    }1650    derivedKey = keyA;1651    vaultMode = "real";1652    entries = payloadA.entries;1653    tombstones = payloadA.tombstones;1654    duressConfigured = payloadA.duressConfigured;1655    buildDomainIndexSync(entries);1656    await loadSettings();1657    if (stale()) {1658      await lock();1659      return false;1660    }1661    touchAutoLock();1662    if (keyB) zeroKey(keyB);16631664    // Current vaults always carry a password-wrapped root key. Older vaults1665    // without this escrow are outside the supported storage format.1666    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1667    if (!rootKey) {1668      await lock();1669      return false;1670    }16711672    // PB-26-213: adopt legacy plaintext pair secrets into the encrypted1673    // record and refresh the trusted-session operational copy.1674    await reconcilePairSecrets(rootKey);1675    if (stale()) {1676      await lock();1677      return false;1678    }1679    await lockedSyncUnlockHook?.().catch((error) => {1680      console.warn("[Peach sync] locked inbox drain failed:", error);1681    });1682    if (stale()) {1683      await lock();1684      return false;1685    }1686    await resetPinFailedAttempts();1687    if (stale()) {1688      await lock();1689      return false;1690    }1691    await saveSessionKey(keyA, "real", epoch);1692    if (stale()) return false;1693    return true;1694  }16951696  if (payloadB && keyB) {1697    if (stale()) {1698      zeroKey(keyB);1699      if (keyA) zeroKey(keyA);1700      return false;1701    }1702    derivedKey = keyB;1703    vaultMode = "decoy";1704    decoySyncTeardownHook?.();1705    // A duress session must not retain the real vault's root key. It gates the1706    // recovery words, the sync identity, and the root wrap of the local device1707    // identity, so leaving it live would expose all three in duress mode.1708    if (rootKey) {1709      rootKey.fill(0);1710      rootKey = null;1711    }1712    entries = payloadB.entries;1713    tombstones = payloadB.tombstones;1714    duressConfigured = false;1715    buildDomainIndexSync(entries);1716    await loadSettings();1717    touchAutoLock();1718    if (keyA) zeroKey(keyA);1719    // PB-26-213: a duress session must never serve the real pair-secret map.1720    await dropSessionCache();1721    // Nor the real device identity: a duress session is not a real one.1722    await notifyIdentitySessionEnded();1723    if (stale()) {1724      await lock();1725      return false;1726    }1727    await saveSessionKey(keyB, "decoy", epoch);1728    if (stale()) return false;1729    return true;1730  }17311732  if (keyA) zeroKey(keyA);1733  if (keyB) zeroKey(keyB);1734  // Surface corruption (good AES tag, broken schema) distinctly from1735  // wrong-password so the popup can offer history-restore (M7/COR-007).1736  if (resultA.status === "corrupted") {1737    lastUnlockFailureReason = resultA.reason;1738  } else if (resultB.status === "corrupted") {1739    lastUnlockFailureReason = resultB.reason;1740  }1741  return false;1742}17431744// ---------------------------------------------------------------------------1745// MV3 service worker session key — survives SW restarts within a browser session1746// ---------------------------------------------------------------------------17471748// chrome.storage.session is cleared on browser close; survives SW restart.1749// Firefox does not implement it (persistent background script, no restart risk).1750const SESSION_KEY_STORAGE = "peach_session_dk";17511752// Order writes and removals so lock's awaited removal is a barrier after1753// every escrow write that started before it.1754let sessionKeyMutation = Promise.resolve();17551756function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1757  const task = sessionKeyMutation.then(mutation, mutation);1758  sessionKeyMutation = task.catch(() => undefined);1759  return task;1760}17611762// PB-26-203: the session-key escrow may only operate where startup positively1763// established trusted-only session storage. Defaults to available so direct1764// vault imports (tests, non-entry bootstraps) keep working; entry.ts applies1765// the real hardening result before the first restore.1766let sessionEscrowAvailable = true;17671768/**1769 * Apply the storage-hardening result at startup. When the trusted-only1770 * session state cannot be established, any stored session key is removed and1771 * the escrow stays off: the user unlocks again after a worker restart1772 * instead of trusting an unproven boundary.1773 */1774export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1775  setSessionAllowed(sessionEscrow);1776  if (sessionEscrow) return;1777  sessionEscrowAvailable = false;1778  await clearSessionKey();1779}17801781async function saveSessionKey(1782  key: Uint8Array,1783  mode: "real" | "decoy",1784  expectedEpoch = vaultEpoch,1785): Promise<void> {1786  const session = chrome.storage.session;1787  if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1788  const stored = { keyHex: hex(key), mode };1789  await mutateSessionKey(async () => {1790    if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1791    await session.set({ [SESSION_KEY_STORAGE]: stored });1792  });1793}17941795async function clearSessionKey(): Promise<void> {1796  const session = chrome.storage.session;1797  if (!session) return;1798  await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1799}18001801// A lock that only holds while the service worker stays alive is no lock at1802// all: a cold worker's restoreFromSession() would find the surviving session1803// key and silently re-unlock ~a minute after the user locked. So lock()1804// bumps this epoch synchronously at entry and zeroes keys/indexes before any1805// await; a restore that started earlier sees the mismatch after its next1806// await and aborts instead of publishing.1807let vaultEpoch = 0;18081809// One in-flight restore shared by concurrent callers: storage is read once1810// and the locked-sync unlock hook runs once.1811let restoreInFlight: Promise<boolean> | null = null;18121813/**1814 * On SW restart, re-derive all in-memory vault state from the session key1815 * stored in chrome.storage.session (set at unlock time, cleared by lock();1816 * also cleared on browser close or when restoration fails).1817 * Returns true if the vault was restored, false if no session key was found.1818 * No-ops if the vault is already unlocked.1819 */1820export async function restoreFromSession(): Promise<boolean> {1821  if (derivedKey !== null) return true;1822  if (restoreInFlight) return restoreInFlight;1823  const task = restoreFromSessionInner();1824  restoreInFlight = task;1825  try {1826    return await task;1827  } finally {1828    restoreInFlight = null;1829  }1830}18311832async function restoreFromSessionInner(): Promise<boolean> {1833  const epoch = vaultEpoch;1834  const stale = () => epoch !== vaultEpoch;1835  if (derivedKey !== null) return true;1836  if (!sessionEscrowAvailable) return false;1837  if (!chrome.storage.session) return false;1838  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1839  if (stale()) return false;1840  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1841  if (!entry?.keyHex) return false;18421843  const key = unhex(entry.keyHex);1844  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1845  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1846  const slot = await loadSlot(slotKey);1847  if (stale()) { zeroKey(key); return false; }1848  if (!slot) { await clearSessionKey(); return false; }18491850  const result = await tryDecryptSlotResult(slot, key, aadTag);1851  if (stale()) { zeroKey(key); return false; }1852  if (result.status !== "ok") { await clearSessionKey(); return false; }18531854  // Decrypted payload stays in locals until the final epoch-checked commit1855  // below; nothing observable is published before then.1856  const payload = result.payload;1857  await loadSettings();1858  if (stale()) { zeroKey(key); return false; }18591860  // Rehydrate the root key used by sync identity derivation from the password1861  // escrow stored on the current vault format.1862  let freshRoot: Uint8Array | null = null;1863  if (entry.mode === "real") {1864    freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1865    if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1866    if (!freshRoot) {1867      await lock();1868      return false;1869    }1870  }18711872  if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1873  if (freshRoot) {1874    // PB-26-213: encrypted pair-secret record + session copy, before publish.1875    await reconcilePairSecrets(freshRoot);1876    if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1877  } else {1878    // PB-26-213: a duress restore must never serve the real pair-secret map.1879    await dropSessionCache();1880    // Nor the real device identity.1881    await notifyIdentitySessionEnded();1882    if (stale()) { zeroKey(key); return false; }1883  }1884  derivedKey = key;1885  vaultMode = entry.mode;1886  if (entry.mode === "decoy") decoySyncTeardownHook?.();1887  entries = payload.entries;1888  tombstones = payload.tombstones;1889  duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1890  if (freshRoot) rootKey = freshRoot;1891  buildDomainIndexSync(entries);1892  touchAutoLock();18931894  await lockedSyncUnlockHook?.().catch((error) => {1895    console.warn("[Peach sync] locked inbox drain failed:", error);1896  });1897  if (stale()) return false;18981899  return true;1900}19011902export async function lock(): Promise<void> {1903  // A user-initiated lock must be durable across MV3 service-worker restarts.1904  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1905  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1906  // If the session key survived lock(), that restore would silently re-unlock1907  // the vault ~a minute after the user locked it — a lock that only held1908  // while the SW happened to stay alive. The background-sync convenience the1909  // session key once bought (sync runner re-waking to a restored vault) is1910  // not worth an unlocked vault the user never asked to unlock; when locked,1911  // sync-runner correctly reports VAULT_LOCKED instead.1912  vaultEpoch++;1913  if (derivedKey) {1914    zeroKey(derivedKey);1915    derivedKey = null;1916  }1917  if (rootKey) {1918    rootKey.fill(0);1919    rootKey = null;1920  }19211922  entries = [];1923  tombstones = new Map();1924  domainIndex = new Map();1925  vaultMode = "real";1926  duressConfigured = false;1927  clearAutoLock();1928  await clearSessionKey();1929  // The decrypted identity is session state as well, so a locked vault must not1930  // leave one behind; clear it after the in-memory state is gone.1931  await notifyIdentitySessionEnded();1932}19331934export function isUnlocked(): boolean {1935  return derivedKey !== null;1936}1937/** True only while the active session is unlocked through the decoy slot. */1938export function isDecoyMode(): boolean {1939  return derivedKey !== null && vaultMode === "decoy";1940}19411942// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1943// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1944// always derives from scratch via Argon2id.1945export async function unlockWithKey(1946  masterKeyHex: string,1947): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1948  const epoch = vaultEpoch;1949  const stale = () => epoch !== vaultEpoch;1950  let masterKeyBytes: Uint8Array | null = null;1951  try {1952    masterKeyBytes = unhex(masterKeyHex);1953    const stored = await loadSlot(SLOT_A);1954    if (!stored) return { ok: false, error: "invalid_key" };1955    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1956    if (!payload || stale()) {1957      zeroKey(masterKeyBytes);1958      return { ok: false, error: "invalid_key" };1959    }19601961    derivedKey = masterKeyBytes;1962    vaultMode = "real";1963    entries = payload.entries;1964    tombstones = payload.tombstones;1965    duressConfigured = payload.duressConfigured;1966    buildDomainIndexSync(entries);1967    await loadSettings();1968    touchAutoLock();19691970    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1971    if (!rootKey) {1972      await lock();1973      return { ok: false, error: "invalid_key" };1974    }1975    await reconcilePairSecrets(rootKey);1976    if (stale()) {1977      await lock();1978      return { ok: false, error: "invalid_key" };1979    }1980    await saveSessionKey(masterKeyBytes, "real", epoch);1981    if (stale()) return { ok: false, error: "invalid_key" };1982    return { ok: true, entries };1983  } catch {1984    if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1985    return { ok: false, error: "invalid_key" };1986  }1987}19881989// --- Adoption duress-slot helpers (passthrough, no decrypt) ---19901991/**1992 * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1993 * duress is configured and slot B exists, so the inline snapshot can ship it1994 * to the receiver. Returns null otherwise. Never decrypts.1995 */1996export async function exportDuressSlotForAdoption(): Promise<string | null> {1997  if (!(await isDuressEnabled())) return null;1998  const slotB = await loadSlot(SLOT_B);1999  if (!slotB) return null;2000  return JSON.stringify(slotB);2001}20022003/**2004 * Adoption RECEIVER helper: installs a duress slot received from a source2005 * device. Validates the StoredVault shape (same guard as unlockFromCodex)2006 * and writes it to slot B. Throws on invalid shape so the caller can treat2007 * it as a non-fatal snapshot failure. Never decrypts.2008 */2009export async function installAdoptedDuressSlot(json: string): Promise<void> {2010  let parsed: StoredVault;2011  try {2012    const raw = JSON.parse(json) as unknown;2013    if (!hasValidVaultSchema(raw)) {2014      throw new Error("invalid_duress_slot_shape");2015    }2016    parsed = raw;2017  } catch (err) {2018    if (err instanceof SyntaxError) {2019      throw new Error("invalid_duress_slot_shape");2020    }2021    throw err;2022  }2023  await writeSlot(SLOT_B, parsed);2024}20252026/**2027 * Restore the vault from a parsed Peach Codex payload.2028 *2029 * The supplied passphrase is verified against the Codex's slotA *before* any2030 * storage mutation — wrong passphrases return without touching disk. On2031 * success, the current vault state is snapshotted (reason: "restore") so the2032 * user can revert via Settings -> Vault History, then slotA/slotB are written2033 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A2034 * regular `unlock()` follows so the rest of the SW sees the standard2035 * post-unlock state.2036 *2037 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex2038 * printed by a Pro user must always be restorable, even on a free device.2039 *2040 * Timing is not held constant here (in contrast to `unlock`): the Codex flow2041 * is an explicit user action with a known passphrase, not a passive lock2042 * screen, so the duress-vs-real timing side channel does not apply.2043 */2044export async function unlockFromCodex(params: {2045  slotA: string; // JSON string of StoredVault for slot A2046  slotB: string | null; // JSON string of StoredVault for slot B, or null2047  timestamp: number;2048  masterPassphrase: string;2049}): Promise<2050  | { ok: true; entryCount: number }2051  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" | "missing_root_escrow" }2052> {2053  let parsedSlotA: StoredVault;2054  let parsedSlotB: StoredVault | null = null;2055  try {2056    const rawA = JSON.parse(params.slotA) as unknown;2057    if (!hasValidVaultSchema(rawA)) {2058      return { ok: false, reason: "invalid_slot_format" };2059    }2060    parsedSlotA = rawA;2061    if (params.slotB !== null) {2062      const rawB = JSON.parse(params.slotB) as unknown;2063      if (!hasValidVaultSchema(rawB)) {2064        return { ok: false, reason: "invalid_slot_format" };2065      }2066      parsedSlotB = rawB;2067    }2068  } catch {2069    return { ok: false, reason: "invalid_slot_format" };2070  }20712072  // Verify the passphrase and the password-wrapped root escrow against the2073  // staged Slot A WITHOUT writing anything. Payload decoding is part of the2074  // trial decrypt, so unlock() cannot be the first place these checks run.2075  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));2076  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");2077  if (!trialPayload) {2078    zeroKey(trialKey);2079    return { ok: false, reason: "wrong_passphrase" };2080  }2081  const hasEscrow =2082    !!parsedSlotA.pw_root_ciphertext && !!parsedSlotA.pw_root_nonce && !!parsedSlotA.pw_root_tag;2083  if (!hasEscrow) {2084    zeroKey(trialKey);2085    return { ok: false, reason: "missing_root_escrow" };2086  }2087  const stagedRootKey = await deriveRootKeyFromDerivedKey(trialKey, parsedSlotA);2088  if (!stagedRootKey) {2089    zeroKey(trialKey);2090    return { ok: false, reason: "wrong_passphrase" };2091  }2092  zeroKey(stagedRootKey);2093  zeroKey(trialKey);20942095  // Stage the complete candidate pair in memory before touching storage.2096  const stagedSlotB = parsedSlotB ?? generateNoiseBlob(parsedSlotA.ciphertext.length / 2);20972098  // Drop the trial key; `unlock()` below derives its own copy. Holding two2099  // copies of the master key in memory at once would be a needless surface.21002101  // Snapshot the about-to-be-overwritten on-disk state so the user can revert2102  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.2103  await takeVaultSnapshot("restore");2104  const previousSlotA = await loadSlot(SLOT_A);2105  const previousSlotB = await loadSlot(SLOT_B);2106  let slotACommitted = false;2107  let slotBCommitted = false;2108  try {2109    await writeSlot(SLOT_A, parsedSlotA);2110    slotACommitted = true;2111    await writeSlot(SLOT_B, stagedSlotB);2112    slotBCommitted = true;2113    await chrome.storage.local.set({2114      peach_last_modified_at: String(params.timestamp),2115    });2116  } catch (err) {2117    if (slotBCommitted && previousSlotB) await writeSlot(SLOT_B, previousSlotB);2118    if (slotACommitted && previousSlotA) await writeSlot(SLOT_A, previousSlotA);2119    throw err;2120  }21212122  // Standard unlock path so domain index, settings, auto-lock, and2123  // recovery-key state all populate exactly as a normal unlock would.2124  const ok = await unlock(params.masterPassphrase);2125  if (!ok) {2126    // Should be unreachable — the staged candidate passed the same checks.2127    return { ok: false, reason: "wrong_passphrase" };2128  }2129  return { ok: true, entryCount: getEntries().length };2130}21312132// Returns the current in-memory derived key as a hex string. Only the PIN2133// unlock path needs this value, and it reads module state directly; this2134// accessor exists so unlockWithKey's root-key rehydration is testable.2135export /* @testable */ function getDerivedKeyHex(): string {2136  if (!derivedKey) throw new Error("vault_not_unlocked");2137  return hex(derivedKey);2138}21392140/**2141 * Returns the vault's stable root key as hex, or null when locked / not yet2142 * populated. The root key is random per-vault, generated at creation, escrowed2143 * under the recovery words, and re-wrapped (not regenerated) on every password2144 * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.2145 *2146 * This is the correct input for sync identity derivation (vault_id, the Noise2147 * NK static key, the Ed25519 manifest signing key, and per-entry envelope2148 * keys): paired devices keep the same identity and can still decrypt each2149 * other's entries after either side rotates its master password (SEV-2-B).2150 *2151 * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED2152 * instead of crashing; rootKey is populated on every supported real-mode2153 * unlock via deriveRootKeyFromDerivedKey.2154 */2155export function getRootKeyHex(): string | null {2156  return rootKey ? hex(rootKey) : null;2157}21582159export function getEntries(): VaultEntry[] {2160  if (!derivedKey) throw new Error("vault_locked");2161  return entries.filter((entry) => !entry.deletedAt);2162}21632164const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;21652166export function getIdentities(): SavedIdentity[] {2167  if (!derivedKey) throw new Error("vault_locked");2168  const active = entries.filter((e) => !e.deletedAt && e.type === "login");21692170  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();2171  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();21722173  for (const entry of active) {2174    const domain = extractDomain(entry.url ?? "") ?? "";2175    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;21762177    function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {2178      const existing = map.get(v);2179      if (!existing || modifiedAt > existing.lastUsed) {2180        map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });2181      } else {2182        map.set(v, { ...existing, count: existing.count + 1 });2183      }2184    }21852186    const uVal = entry.username?.trim();2187    if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);21882189    // Also count the dedicated email field — entries created via the inline2190    // popup new-entry form store email here rather than in username.2191    const eVal = entry.email?.trim();2192    if (eVal) tally(emailMap, eVal);2193  }21942195  const toIdentity = (type: "email" | "username") =>2196    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({2197      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,2198    });21992200  return [2201    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2202    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2203  ];2204}22052206export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2207  if (!derivedKey) throw new Error("vault_locked");22082209  const now = Date.now();2210  const added: VaultEntry[] = [];2211  for (const draft of drafts) {2212    try {2213      const sanitized = sanitizeEntryDraft(draft);2214      const nextEntry: VaultEntry = {2215        ...sanitized,2216        id: crypto.randomUUID(),2217        createdAt: now,2218        modifiedAt: monotonicEntryTimestamp(),2219      };2220      entries.push(nextEntry);2221      added.push(nextEntry);2222    } catch {2223      // Skip invalid drafts silently — caller can see diff between input and output length2224    }2225  }22262227  if (added.length > 0) {2228    await persistEntries();2229  }22302231  return added;2232}22332234/**2235 * Restore a set of entries in full fidelity — preserves id, createdAt,2236 * modifiedAt, and every other field unlike `batchAddEntries` which2237 * assigns fresh identity. Used by restore paths where the backup is the2238 * authoritative source of truth. `mode` controls how entries already in2239 * the vault are treated.2240 */2241export async function restoreEntries(2242  incoming: VaultEntry[],2243  mode: "replace" | "merge"2244): Promise<{ added: number; updated: number; skipped: number }> {2245  if (!derivedKey) throw new Error("vault_locked");22462247  // A replace with zero incoming entries is almost certainly a caller bug2248  // (e.g. an import that silently dropped everything). Refuse rather than2249  // wipe the vault.2250  if (mode === "replace" && incoming.length === 0) {2251    throw new Error("refuse to replace with empty entry set");2252  }22532254  let added = 0;2255  let updated = 0;2256  let skipped = 0;22572258  if (mode === "replace") {2259    entries = [];2260    for (const entry of incoming) {2261      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2262      if (!normalized) {2263        skipped += 1;2264        continue;2265      }2266      entries.push(normalized);2267      if (normalized.deletedAt) {2268        tombstones.set(2269          normalized.id,2270          Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2271        );2272      } else {2273        tombstones.delete(normalized.id);2274      }2275      added += 1;2276    }2277  } else {2278    const byId = new Map<string, VaultEntry>();2279    for (const e of entries) byId.set(e.id, e);22802281    for (const entry of incoming) {2282      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2283      if (!normalized) {2284        skipped += 1;2285        continue;2286      }2287      const existing = byId.get(normalized.id);2288      if (!existing) {2289        entries.push(normalized);2290        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2291        else tombstones.delete(normalized.id);2292        added += 1;2293      } else if (normalized.modifiedAt > existing.modifiedAt) {2294        const idx = entries.findIndex((e) => e.id === existing.id);2295        if (idx !== -1) {2296          entries[idx] = normalized;2297          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2298          else tombstones.delete(normalized.id);2299          updated += 1;2300        }2301      } else {2302        skipped += 1;2303      }2304    }2305  }23062307  await persistEntries("import");2308  return { added, updated, skipped };2309}23102311/**2312 * A Peach entry id is a UUID. A restored or imported file carries whatever id2313 * its author used, and no reader bounds it, so one oversized id would ride2314 * into every future sync frame and be rejected there — a permanent, opaque2315 * failure on both platforms, caused by data that was already local.2316 *2317 * The action is mint, not reject: this is the last point where an id is still2318 * ours to choose, so the worst outcome is one duplicate if the same vault is2319 * later restored beside a device still holding the old id. Rejecting would2320 * lose the user's import.2321 *2322 * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries2323 * also calls. Sync has to adopt a peer's id verbatim — that is the only thing2324 * keeping two devices agreed on which entry they are discussing — so reminting2325 * there would fork identity on every device, on every round.2326 */2327const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;23282329function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2330  if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2331  return { ...entry, id: crypto.randomUUID() };2332}23332334/**2335 * Validate + normalise a VaultEntry coming from a backup, import, or sync2336 * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies2337 * only known VaultEntry fields (unknown/attacker keys are dropped), and2338 * nested `passkey`/`canary` shapes are rebuilt field-by-field, never2339 * spread. Returns null for values the live vault shouldn't accept.2340 */2341function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2342  if (!entry || typeof entry !== "object") return null;2343  const title = typeof entry.title === "string" ? entry.title.trim() : "";2344  if (!title) return null;2345  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2346  const now = Date.now();2347  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2348  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;23492350  // normalizeEntry whitelists every known VaultEntry field before the entry2351  // reaches the live vault.2352  const normalized = normalizeEntry({2353    ...entry,2354    id,2355    title,2356    type: entry.type ?? "login",2357    createdAt,2358    modifiedAt,2359  });23602361  if (normalized.passkey) {2362    normalized.passkey = sanitizePasskey(normalized.passkey);2363  }2364  if (normalized.canary) {2365    normalized.canary = sanitizeCanary(normalized.canary);2366  }2367  // PCH-01-016: bound tags on the restore path too, so both import funnels agree.2368  normalized.tags = sanitizeTags(normalized.tags);23692370  // Login entries from backup must carry a password (or be passkey-only,2371  // in which case the passkey sub-object replaces the password requirement).2372  if (2373    normalized.type === "login" &&2374    !normalized.passkey &&2375    typeof normalized.password !== "string"2376  ) {2377    return null;2378  }2379  if (entry.passkey && !normalized.passkey) {2380    // Entry claimed a passkey but the shape didn't validate — refuse the2381    // whole entry rather than silently importing a credential-less husk.2382    return null;2383  }23842385  return normalized;2386}23872388/**2389 * Rebuild a PasskeyData object from untrusted input, field by field.2390 * Returns undefined when required fields are missing/mistyped — a rogue2391 * passkey credential must not activate via import (MV-6 attack scenario).2392 */2393function sanitizePasskey(pk: unknown): PasskeyData | undefined {2394  if (!pk || typeof pk !== "object") return undefined;2395  const r = pk as Record<string, unknown>;2396  if (2397    typeof r.rpId !== "string" || !r.rpId ||2398    typeof r.credentialId !== "string" || !r.credentialId ||2399    typeof r.publicKeyCose !== "string" ||2400    typeof r.userHandle !== "string" ||2401    typeof r.userName !== "string"2402  ) {2403    return undefined;2404  }2405  return {2406    rpId: r.rpId,2407    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2408    credentialId: r.credentialId,2409    publicKeyCose: r.publicKeyCose,2410    privateKeyJwk:2411      r.privateKeyJwk && typeof r.privateKeyJwk === "object"2412        ? (r.privateKeyJwk as JsonWebKey)2413        : undefined,2414    userHandle: r.userHandle,2415    userName: r.userName,2416    userDisplayName:2417      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2418    algorithm: r.algorithm === -8 ? -8 : -7,2419    signCount: typeof r.signCount === "number" ? r.signCount : 0,2420    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2421    backupEligible: r.backupEligible === true,2422    backupState: r.backupState === true,2423    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2424    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2425    transports: Array.isArray(r.transports)2426      ? r.transports.filter((t): t is string => typeof t === "string")2427      : undefined,2428    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2429  };2430}24312432/**2433 * Rebuild a Canary object from untrusted input. The webhook must be a2434 * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing2435 * exfiltration endpoint that the canary subsystem will POST entry titles2436 * to (MV-6 attack scenario). Invalid → the canary is dropped, not the2437 * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire2438 * time (isAllowedCanaryWebhookUrl), so this import-time check is the2439 * outer of two gates.2440 */2441function sanitizeCanary(c: unknown): Canary | undefined {2442  if (!c || typeof c !== "object") return undefined;2443  const r = c as Record<string, unknown>;2444  if (typeof r.webhookUrl !== "string") return undefined;2445  let url: URL;2446  try {2447    url = new URL(r.webhookUrl);2448  } catch {2449    return undefined;2450  }2451  if (url.protocol !== "https:") return undefined;2452  return {2453    webhookUrl: url.toString(),2454    label: typeof r.label === "string" ? r.label : undefined,2455    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2456    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2457  };2458}24592460/**2461 * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted.2462 * Keep only non-empty strings, cap each at 256 chars, and cap the count at 2552463 * (the same per-entry ceiling the export codec enforces, peachPayload.ts).2464 * Non-array or all-invalid input collapses to undefined.2465 */2466function sanitizeTags(tags: unknown): string[] | undefined {2467  if (!Array.isArray(tags)) return undefined;2468  const out: string[] = [];2469  for (const t of tags) {2470    if (typeof t !== "string") continue;2471    const trimmed = t.trim();2472    if (!trimmed) continue;2473    out.push(trimmed.slice(0, 256));2474    if (out.length >= 255) break;2475  }2476  return out.length > 0 ? out : undefined;2477}24782479// Soft-deletes every non-deleted entry in one call. The literal string2480// argument is a tripwire against accidental calls — any caller that would2481// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this2482// to a boolean.2483export async function clearVaultEntries(2484  confirmation: "REPLACE_ALL"2485): Promise<void> {2486  if (confirmation !== "REPLACE_ALL") {2487    throw new Error("clearVaultEntries requires explicit confirmation");2488  }2489  if (!derivedKey) throw new Error("vault_locked");2490  for (const entry of entries) {2491    if (!entry.deletedAt) {2492      const stamp = monotonicEntryTimestamp(Math.max(2493        entry.modifiedAt,2494        tombstones.get(entry.id) ?? 0,2495      ));2496      entry.deletedAt = stamp;2497      entry.modifiedAt = stamp;2498      tombstones.set(entry.id, stamp);2499    }2500  }2501  await persistEntries();2502}25032504export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2505  if (!derivedKey) throw new Error("vault_locked");25062507  const sanitized = sanitizeEntryDraft(entry);2508  const now = Date.now();2509  const stamp = monotonicEntryTimestamp();2510  const nextEntry: VaultEntry = {2511    ...sanitized,2512    id: crypto.randomUUID(),2513    createdAt: now,2514    modifiedAt: stamp,2515    // Stamp passwordChangedAt at creation so age tracking is accurate from2516    // day one. Non-login entries carry the same stamp harmlessly.2517    passwordChangedAt: sanitized.password ? stamp : undefined,2518  };25192520  // Clone-and-replace so a failed persist rolls back the in-memory state2521  // (COR-008). Without this, a quota-full or storage error leaves the2522  // module-level `entries` ahead of what's actually on disk.2523  const rollback = entries;2524  entries = [...entries, nextEntry];2525  try {2526    await persistEntries();2527  } catch (err) {2528    entries = rollback;2529    throw err;2530  }25312532  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)2533  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2534    checkBreach(nextEntry.password).then(async (count) => {2535      if (count > 0) {2536        const idx = entries.findIndex((e) => e.id === nextEntry.id);2537        if (idx !== -1) {2538          entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2539          await persistEntries();2540        }2541      }2542    });2543  }25442545  fireSyncTrigger("vault_change");25462547  return nextEntry;2548}25492550/**2551 * Run the passkey-merge pass on the live in-memory entries and persist if2552 * anything changed. Called immediately after any passkey registration so that2553 * a same-domain TOTP-only entry already in the vault gets consolidated right2554 * away instead of waiting until the next lock/unlock cycle.2555 */2556export async function consolidatePasskeyEntries(): Promise<void> {2557  if (!derivedKey) return;2558  const { merged } = mergePasskeyOnlyIntoLogins(entries);2559  if (merged) {2560    await persistEntries();2561  }2562}25632564export async function updateEntry(2565  id: string,2566  patch: Partial<VaultEntry>2567): Promise<void> {2568  if (!derivedKey) throw new Error("vault_locked");25692570  const idx = entries.findIndex((entry) => entry.id === id);2571  if (idx === -1 || entries[idx].deletedAt) {2572    throw new Error("entry_not_found");2573  }25742575  const current = entries[idx];2576  const sanitizedPatch = sanitizeEntryPatch(patch);2577  // Refresh passwordChangedAt only when the password value actually changes —2578  // a metadata-only update (title, tags, etc.) must not reset the age clock2579  // that drives the Pro health rotation recommendation.2580  const passwordChanged =2581    "password" in patch &&2582    typeof sanitizedPatch.password === "string" &&2583    sanitizedPatch.password !== current.password;2584  const stamp = monotonicEntryTimestamp(current.modifiedAt);2585  // When the password actually changes, push the outgoing one onto the history2586  // (newest first) so the user can reveal/copy/restore it later. We record the2587  // age it carried (passwordChangedAt, falling back to createdAt) rather than2588  // `now`, so the list reflects when each password was last in effect. Only2589  // capture a non-empty prior value — clearing a password leaves no secret to2590  // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)2591  // to avoid noise.2592  const priorPassword = current.password;2593  const shouldCapture =2594    passwordChanged &&2595    typeof priorPassword === "string" &&2596    priorPassword.length > 0 &&2597    current.passwordHistory?.[0]?.password !== priorPassword;2598  const nextHistory = shouldCapture2599    ? [2600        { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2601        ...(current.passwordHistory ?? []),2602      ].slice(0, PASSWORD_HISTORY_MAX)2603    : current.passwordHistory;2604  entries[idx] = {2605    ...current,2606    ...sanitizedPatch,2607    modifiedAt: stamp,2608    ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2609  };2610261126122613  // Card entries must retain their card number2614  if (entries[idx].type === "card" && !entries[idx].cardNumber) {2615    entries[idx] = current;2616    throw new Error("card number is required for card entries");2617  }26182619  // Roll the in-memory entry back to its pre-edit state if the disk write2620  // fails (e.g. storage quota). Without this the module-level `entries` array2621  // would hold an edit that never landed on disk, diverging from the vault2622  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.2623  try {2624    await persistEntries();2625  } catch (err) {2626    entries[idx] = current;2627    throw err;2628  }26292630  // Breach check when password changes: clear immediately so old breach doesn't linger,2631  // then async-check the new password and only re-set if it's also found.2632  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2633    entries[idx] = { ...entries[idx], breach: undefined };2634    await persistEntries();2635    const password = entries[idx].password;2636    if (password) {2637      checkBreach(password).then(async (count) => {2638        const i = entries.findIndex((e) => e.id === id);2639        if (i !== -1 && count > 0) {2640          entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2641          await persistEntries();2642        }2643      });2644    }2645  }26462647  fireSyncTrigger("vault_change");2648}26492650export async function deleteEntry(id: string): Promise<void> {2651  if (!derivedKey) throw new Error("vault_locked");26522653  const entry = entries.find((item) => item.id === id);2654  if (!entry || entry.deletedAt) {2655    throw new Error("entry_not_found");2656  }26572658  // Tombstone timestamp must be strictly greater than the entry's last2659  // modification so the deletion wins LWW against any in-flight edit, even2660  // under clock skew (SEV-2-E).2661  const previousModifiedAt = entry.modifiedAt;2662  const previousTombstone = tombstones.get(id);2663  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2664  entry.deletedAt = stamp;2665  entry.modifiedAt = stamp;2666  tombstones.set(id, stamp);2667  // Roll the tombstone back if the disk write fails, so a quota error doesn't2668  // leave the entry deleted in memory but alive on disk (SEV-3-A).2669  try {2670    await persistEntries();2671  } catch (err) {2672    delete entry.deletedAt;2673    entry.modifiedAt = previousModifiedAt;2674    if (previousTombstone === undefined) tombstones.delete(id);2675    else tombstones.set(id, previousTombstone);2676    throw err;2677  }2678  fireSyncTrigger("vault_change");2679}26802681export function getDeletedEntries(): VaultEntry[] {2682  if (!derivedKey) return [];2683  return entries.filter((e) => !!e.deletedAt);2684}26852686/** Return all durable deletion timestamps, including purged full entries. */2687export function getTombstoneManifest(): Record<string, number> {2688  if (!derivedKey) return {};2689  return Object.fromEntries(tombstones);2690}26912692/**2693 * Returns the vault's monotonic version — the `peach_last_modified_at` value2694 * stamped on every user-initiated write. Remote (sync) writes do NOT advance2695 * this version so a sync round never cascades into a second outbound sync.2696 */2697export async function getVaultVersion(): Promise<number> {2698  try {2699    const raw = await chrome.storage.local.get("peach_last_modified_at");2700    const v = (raw as Record<string, unknown>).peach_last_modified_at;2701    if (typeof v === "number") return v;2702    if (typeof v === "string" && v !== "") {2703      const n = Number.parseInt(v, 10);2704      return Number.isFinite(n) ? n : 0;2705    }2706    return 0;2707  } catch {2708    return 0;2709  }2710}27112712/**2713 * Apply a batch of remote entries + tombstones from a sync peer.2714 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when2715 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and2716 * persists with reason `sync_received` — does NOT fire the sync dispatcher, so2717 * applying a sync round cannot cascade into another outbound sync.2718 *2719 * `applied` counts entries that actually changed local state (new add,2720 * overwrite-by-newer, or new tombstone). `received` is the raw payload size2721 * the caller can surface in the sync result UI.2722 */2723export async function applyMergedRemoteEntries(2724  remoteEntries: VaultEntry[],2725  remoteTombstones: Record<string, number>,2726): Promise<{ received: number; applied: number; conflicts: number }> {2727  // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices2728  // concurrently edit different fields of the same entry, the one with the2729  // newer modifiedAt wins the whole entry and the other device's field edits2730  // are dropped silently. This is the deliberate trade-off for a simple,2731  // conflict-free merge — per-field CRDT merge is out of scope. Guidance for2732  // users: edit a given entry on one device at a time.2733  if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");27342735  const received = remoteEntries.length + Object.keys(remoteTombstones).length;2736  let applied = 0;2737  let conflicts = 0;27382739  const byId = new Map<string, VaultEntry>();2740  for (const e of entries) byId.set(e.id, e);27412742  const mergeNow = Date.now();2743  const remoteTombstoneTimes = new Map<string, number>();2744  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2745    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2746      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2747    }2748  }27492750  for (const remote of remoteEntries) {2751    const sanitized = sanitizeRestoredEntry(remote);2752    if (!sanitized) continue;2753    // HV-1a: a far-future remote modifiedAt must not permanently pin a2754    // malicious value against honest local edits. Clamp BEFORE any2755    // comparison against local timestamps.2756    sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2757    if (typeof sanitized.deletedAt === "number") {2758      sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2759    }2760    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2761    const localTombstoneTs = tombstones.get(sanitized.id);2762    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2763    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2764      continue;2765    }2766    const local = byId.get(sanitized.id);2767    if (!local) {2768      entries.push(sanitized);2769      byId.set(sanitized.id, sanitized);2770      tombstones.delete(sanitized.id);2771      applied += 1;2772    } else if (sanitized.modifiedAt > local.modifiedAt) {2773      const idx = entries.findIndex((e) => e.id === local.id);2774      if (idx !== -1) {2775        entries[idx] = sanitized;2776        byId.set(sanitized.id, sanitized);2777        tombstones.delete(sanitized.id);2778        applied += 1;2779        if (!local.deletedAt) conflicts += 1;2780      }2781    }2782  }27832784  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2785    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2786    // HV-1a/1c: clamp the tombstone timestamp before every comparison.2787    const deletedAt = remoteTombstoneTimes.get(id)!;2788    const knownTombstone = tombstones.get(id);2789    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2790    const local = byId.get(id);2791    if (!local) {2792      tombstones.set(id, deletedAt);2793      applied += 1;2794      continue;2795    }2796    if (local.deletedAt && local.deletedAt >= deletedAt) continue;2797    if (deletedAt < local.modifiedAt) continue;2798    const idx = entries.findIndex((e) => e.id === id);2799    if (idx !== -1) {2800      entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2801      tombstones.set(id, deletedAt);2802      applied += 1;2803    }2804  }28052806  if (applied > 0 && vaultMode === "real") {2807    await persistEntries("sync_received");2808    buildDomainIndexSync(entries);2809    // An open popup holds the entries it loaded at mount and polls nothing, so2810    // a background round (the relay listener responding to a peer's push, or the2811    // alarm-driven dial) otherwise lands silently and the user sees stale data2812    // until they reopen. Best-effort: no popup listening is the normal case.2813    try {2814      void chrome.runtime2815        .sendMessage({ type: "VAULT_ENTRIES_CHANGED" })2816        .catch(() => undefined);2817    } catch {2818      // messaging unavailable2819    }2820  }2821  return { received, applied, conflicts };2822}28232824export async function restoreEntry(id: string): Promise<void> {2825  if (!derivedKey) throw new Error("vault_locked");2826  const entry = entries.find((e) => e.id === id && e.deletedAt);2827  if (!entry) throw new Error("deleted entry not found");2828  const previousDeletedAt = entry.deletedAt;2829  const previousModifiedAt = entry.modifiedAt;2830  const previousTombstone = tombstones.get(id);2831  delete entry.deletedAt;2832  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2833    entry.modifiedAt,2834    previousTombstone ?? 0,2835  ));2836  tombstones.delete(id);2837  try {2838    await persistEntries();2839  } catch (err) {2840    entry.deletedAt = previousDeletedAt;2841    entry.modifiedAt = previousModifiedAt;2842    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2843    throw err;2844  }2845  buildDomainIndexSync(entries);2846}28472848export async function purgeEntry(id: string): Promise<void> {2849  if (!derivedKey) throw new Error("vault_locked");2850  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2851  if (idx === -1) throw new Error("deleted entry not found");2852  const entry = entries[idx]!;2853  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2854  entries.splice(idx, 1);2855  await persistEntries();2856}28572858/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */2859export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2860  if (!derivedKey || retentionMs === 0) return 0;2861  const cutoff = Date.now() - retentionMs;2862  const before = entries.length;2863  for (const entry of entries) {2864    if (entry.deletedAt && entry.deletedAt < cutoff) {2865      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2866    }2867  }2868  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2869  const purged = before - entries.length;2870  if (purged > 0) await persistEntries();2871  return purged;2872}28732874export function queryDomain(domain: string): VaultEntry[] {2875  return domainIndex.get(domain) ?? [];2876}28772878export function getEntryById(id: string): VaultEntry | undefined {2879  return entries.find((entry) => entry.id === id && !entry.deletedAt);2880}28812882async function persistSlotA(2883  reason: VaultSnapshot["reason"] = "local_save",2884): Promise<StoredVault> {2885  if (!derivedKey) throw new Error("vault_locked");2886  const epoch = vaultEpoch;2887  const throwIfStale = () => {2888    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2889  };28902891  const slotA = await loadSlot(SLOT_A);2892  throwIfStale();2893  if (!slotA) throw new Error("missing slot A");28942895  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures2896  // are swallowed by takeVaultSnapshot — history must never block a save.2897  await takeVaultSnapshot(reason);2898  throwIfStale();28992900  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2901  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2902  throwIfStale();29032904  // Preserve existing recovery escrow fields.2905  const nextSlotA: StoredVault = {2906    ...slotA,2907    nonce,2908    ciphertext,2909    tag,2910    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot2911  };29122913  // Slot A (the real vault) is now committed. Wrap every later write so a2914  // mid-persist failure (e.g. a storage error after slot A landed) restores the2915  // prior slot A before rethrowing. Without this, a "failed" save that already2916  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/2917  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces2918  // as a duplicate on the next unlock (observed under storage-quota pressure).2919  let slotACommitted = false;2920  try {2921    throwIfStale();2922    await writeSlot(SLOT_A, nextSlotA);2923    slotACommitted = true;2924    throwIfStale();29252926    // When duress is not configured, keep slot B size-matched with slot A by2927    // regenerating its noise. When duress IS configured, leave slot B alone —2928    // we don't have the decoy key to re-encrypt it. This is a known trade-off.2929    if (!duressConfigured) {2930      throwIfStale();2931      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2932      throwIfStale();2933    }2934    // Only advance the vault version for locally-originated writes. Entries2935    // applied from a sync peer ("sync_received") must NOT bump the counter —2936    // otherwise applying a remote round makes us look newer than the peer and2937    // triggers a redundant outbound sync, defeating the no-op optimisation2938    // and risking a ping-pong between two devices (SEV-2-C).2939    if (reason !== "sync_received") {2940      throwIfStale();2941      await stampLocalVaultMutation();2942      throwIfStale();2943    }2944  } catch (err) {2945    // Best-effort restore — the prior slot A is smaller-or-equal, so this write2946    // fits even when the failure was a quota overflow.2947    if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2948    if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2949    throw err;2950  }29512952  return nextSlotA;2953}29542955async function persistSlotB(): Promise<void> {2956  if (!derivedKey) throw new Error("vault_locked");29572958  const slotB = await loadSlot(SLOT_B);2959  if (!slotB) throw new Error("missing slot B");29602961  // Decoy plaintext always reports duressConfigured=false — the decoy user2962  // sees a clean slate. persistSlotB only runs in decoy mode, which only2963  // exists once duress is configured, so pad to the fixed duress ceiling to2964  // keep slot B's length equal to slot A's (audit MEDIUM — deniability).2965  const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2966  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");29672968  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2969  // Symmetric with persistSlotA: if the version stamp fails after the decoy2970  // slot committed, restore the prior slot B so a failed decoy-mode save can't2971  // leave disk ahead of the in-memory rollback.2972  try {2973    await stampLocalVaultMutation();2974  } catch (err) {2975    await writeSlot(SLOT_B, slotB).catch(() => {});2976    throw err;2977  }2978}29792980async function persistEntries(2981  reason: VaultSnapshot["reason"] = "local_save",2982): Promise<void> {2983  if (!derivedKey) throw new Error("vault_locked");29842985  // Reconcile direct soft-delete callers while restoring the previous map if2986  // storage rejects the save.2987  const previousTombstones = tombstones;2988  tombstones = new Map(tombstones);2989  reconcileTombstones();2990  try {2991    if (vaultMode === "decoy") {2992      await persistSlotB();2993    } else {2994      await persistSlotA(reason);2995    }2996  } catch (err) {2997    tombstones = previousTombstones;2998    throw err;2999  }3000  buildDomainIndexSync(entries);3001  await refreshLockedSyncState();3002}30033004// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage3005// boundary. Schemeless values (e.g. "github.com") are inert and kept — they3006// pre-date this guard in existing data. Unparseable values are kept: the3007// reviewed render paths never execute them (favicons via _favicon/, domains3008// via tldts); this is defense-in-depth against future sinks.3009function allowHttpUrl(url: string | undefined): string {3010  const trimmed = url?.trim() ?? "";3011  if (!trimmed) return "";3012  try {3013    const parsed = new URL(trimmed);3014    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";3015  } catch {3016    // No scheme to reject.3017  }3018  return trimmed;3019}30203021function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {3022  if (!Array.isArray(values)) return undefined;3023  const seen = new Set<string>();3024  const cleaned: string[] = [];3025  for (const value of values) {3026    const trimmed = typeof value === "string" ? value.trim() : "";3027    if (!trimmed) continue;3028    if (!allowHttpUrl(trimmed)) continue;3029    const key = trimmed.toLowerCase();3030    if (seen.has(key)) continue;3031    seen.add(key);3032    cleaned.push(trimmed);3033  }3034  return cleaned.length > 0 ? cleaned : undefined;3035}30363037function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {3038  const title = entry.title.trim();3039  const type: VaultEntry["type"] = entry.type ?? "login";30403041  if (!title) throw new Error("title is required");30423043  if (entry.passkey) {3044    const p = entry.passkey;3045    if (3046      !p.rpId ||3047      !p.credentialId ||3048      !p.publicKeyCose ||3049      !p.userHandle ||3050      !p.userName ||3051      (p.algorithm !== -7 && p.algorithm !== -8)3052    ) {3053      throw new Error("invalid passkey credential");3054    }3055  }3056  if (type === "card") {3057    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {3058      throw new Error("cardholder name and card number are required");3059    }3060  }30613062  return {3063    type,3064    title,3065    username: type === "login" ? entry.username?.trim() ?? "" : undefined,3066    email: type === "login" ? entry.email?.trim() || undefined : undefined,3067    password: type === "login" ? entry.password : undefined,3068    url: type === "login" ? allowHttpUrl(entry.url) : undefined,3069    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,3070    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,3071    // PCH-01-019: preserve non-default TOTP params through batch-add/import so3072    // they are not silently stripped (they fall back to SHA-1/6/30 when absent).3073    totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,3074    totpDigits: type === "login" ? entry.totpDigits : undefined,3075    totpPeriod: type === "login" ? entry.totpPeriod : undefined,3076    notes: entry.notes?.trim() || undefined,3077    breach: type === "login" ? entry.breach : undefined,3078    // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags3079    // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here.3080    canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,3081    tags: sanitizeTags(entry.tags),3082    passkey: entry.passkey,3083    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,3084    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,3085    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,3086    expYear: type === "card" ? entry.expYear?.trim() : undefined,3087    cvc: type === "card" ? entry.cvc?.trim() : undefined,3088    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,3089    network: type === "card" ? entry.network : undefined,3090    last4: type === "card" ? entry.last4?.trim() : undefined,3091  };3092}30933094function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {3095  const nextPatch: Partial<VaultEntry> = {};30963097  if ("type" in patch) {3098    nextPatch.type = patch.type;3099  }31003101  if ("title" in patch) {3102    const title = patch.title?.trim() ?? "";3103    if (!title) throw new Error("title is required");3104    nextPatch.title = title;3105  }31063107  if ("url" in patch) {3108    nextPatch.url = allowHttpUrl(patch.url);3109  }31103111  if ("additionalUrls" in patch) {3112    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];3113    const seen = new Set<string>();3114    const cleaned: string[] = [];3115    for (const value of incoming) {3116      const trimmed = typeof value === "string" ? value.trim() : "";3117      if (!trimmed || !allowHttpUrl(trimmed)) continue;3118      const key = trimmed.toLowerCase();3119      if (seen.has(key)) continue;3120      seen.add(key);3121      cleaned.push(trimmed);3122    }3123    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;3124  }31253126  if ("username" in patch) {3127    nextPatch.username = patch.username?.trim() ?? "";3128  }31293130  if ("email" in patch) {3131    nextPatch.email = patch.email?.trim() || undefined;3132  }31333134  if ("password" in patch) {3135    const password = patch.password ?? "";3136    // Allow clearing a password (e.g. on passkey-only entries)3137    nextPatch.password = password || undefined;3138  }31393140  if ("totp" in patch) {3141    nextPatch.totp = patch.totp?.trim() || undefined;3142  }31433144  if ("totpAlgorithm" in patch) {3145    nextPatch.totpAlgorithm = patch.totpAlgorithm;3146  }31473148  if ("totpDigits" in patch) {3149    nextPatch.totpDigits = patch.totpDigits;3150  }31513152  if ("totpPeriod" in patch) {3153    nextPatch.totpPeriod = patch.totpPeriod;3154  }31553156  if ("notes" in patch) {3157    nextPatch.notes = patch.notes?.trim() || undefined;3158  }31593160  if ("breach" in patch) {3161    nextPatch.breach = patch.breach;3162  }31633164  if ("canary" in patch) {3165    // PCH-02-001: route update patches through the same canary whitelist used3166    // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.3167    nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;3168  }31693170  if ("passkey" in patch) {3171    // A patch with `passkey: undefined` removes the credential from the entry3172    // (used by "remove passkey" without deleting the whole entry).3173    if (patch.passkey === undefined || patch.passkey === null) {3174      nextPatch.passkey = undefined;3175    } else {3176      const sanitized = sanitizePasskey(patch.passkey);3177      if (!sanitized) throw new Error("invalid passkey credential");3178      nextPatch.passkey = sanitized;3179    }3180  }31813182  if ("tags" in patch) {3183    // PCH-02-001: bound tags exactly like both import funnels.3184    nextPatch.tags = sanitizeTags(patch.tags);3185  }31863187  if ("cardholderName" in patch) {3188    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;3189  }3190  if ("cardNumber" in patch) {3191    const n = patch.cardNumber ?? "";3192    nextPatch.cardNumber = n || undefined;3193  }3194  if ("expMonth" in patch) {3195    nextPatch.expMonth = patch.expMonth?.trim() || undefined;3196  }3197  if ("expYear" in patch) {3198    nextPatch.expYear = patch.expYear?.trim() || undefined;3199  }3200  if ("cvc" in patch) {3201    nextPatch.cvc = patch.cvc?.trim() || undefined;3202  }3203  if ("cardNickname" in patch) {3204    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;3205  }3206  if ("network" in patch) {3207    nextPatch.network = patch.network;3208  }3209  if ("last4" in patch) {3210    nextPatch.last4 = patch.last4?.trim() || undefined;3211  }32123213  return nextPatch;3214}32153216function normalizeEntry(entry: VaultEntry): VaultEntry {3217  const base: VaultEntry = {3218    id: entry.id,3219    type: entry.type,3220    title: entry.title,3221    username: entry.username,3222    email: entry.email,3223    password: entry.password,3224    url: entry.url,3225    additionalUrls:3226      "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3227        ? entry.additionalUrls3228        : undefined,3229    totp: entry.totp,3230    // PCH-01-019: carry TOTP params through the normalize/restore path too.3231    totpAlgorithm: entry.totpAlgorithm,3232    totpDigits: entry.totpDigits,3233    totpPeriod: entry.totpPeriod,3234    notes: entry.notes,3235    breach: entry.breach,3236    // Preserve the password-age clock across lock/unlock. Without this the3237    // field is dropped on every decode and the advanced-health "aged password"3238    // signal silently resets to createdAt on each unlock (SEV-2-A).3239    passwordChangedAt:3240      "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3241    // Preserve past-password history across lock/unlock. normalizeEntry is a3242    // field whitelist, so an unlisted field is silently dropped on every decode3243    // (same class as the passwordChangedAt SEV-2-A above).3244    passwordHistory:3245      "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3246        ? entry.passwordHistory3247        : undefined,3248    canary: entry.canary,3249    passkey: entry.passkey,3250    createdAt: entry.createdAt,3251    modifiedAt: entry.modifiedAt,3252    deletedAt: entry.deletedAt,3253    tags: entry.tags,3254    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3255    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3256    expMonth: "expMonth" in entry ? entry.expMonth : undefined,3257    expYear: "expYear" in entry ? entry.expYear : undefined,3258    cvc: "cvc" in entry ? entry.cvc : undefined,3259    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3260    network: "network" in entry ? entry.network : undefined,3261    last4: "last4" in entry ? entry.last4 : undefined,3262  };32633264  return base;3265}326632673268// ---------------------------------------------------------------------------3269// Deduplication3270// ---------------------------------------------------------------------------32713272function entryDupeKey(e: VaultEntry): string {3273  if (e.type === "note") return `note|${e.title.toLowerCase()}`;3274  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins3275  // on different sites that happen to share a username are still distinct;3276  // two passkey credentials for the same RP+user are duplicates regardless3277  // of any login fields they may also carry.3278  if (e.passkey) {3279    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3280  }3281  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3282}32833284/** Returns the number of duplicate entries (those that would be removed). */3285export function countDuplicates(): number {3286  const best = new Map<string, VaultEntry>();3287  let dupeCount = 0;3288  for (const e of entries.filter((e) => !e.deletedAt)) {3289    const key = entryDupeKey(e);3290    const existing = best.get(key);3291    if (!existing) {3292      best.set(key, e);3293    } else {3294      dupeCount++;3295      // Keep the more recently modified one in the map3296      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3297        best.set(key, e);3298      }3299    }3300  }3301  return dupeCount;3302}33033304/**3305 * Soft-deletes duplicate entries, keeping the most recently modified copy of3306 * each group. Returns the number of entries moved to trash.3307 */3308export async function deduplicateEntries(): Promise<number> {3309  if (!derivedKey) throw new Error("vault_locked");3310  // Dedup reshapes slot B permanently under the decoy key, which is3311  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.3312  if (vaultMode === "decoy") {3313    throw new Error("deduplicate disabled in decoy mode");3314  }33153316  const best = new Map<string, VaultEntry>();3317  for (const e of entries.filter((en) => !en.deletedAt)) {3318    const key = entryDupeKey(e);3319    const existing = best.get(key);3320    if (!existing) {3321      best.set(key, e);3322    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3323      best.set(key, e);3324    }3325  }33263327  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3328  let removed = 0;3329  entries = entries.map((e) => {3330    if (!e.deletedAt && !keepIds.has(e.id)) {3331      removed++;3332      const deletedAt = monotonicEntryTimestamp(Math.max(3333        e.modifiedAt,3334        tombstones.get(e.id) ?? 0,3335      ));3336      tombstones.set(e.id, deletedAt);3337      return { ...e, deletedAt };3338    }3339    return e;3340  });33413342  if (removed > 0) await persistEntries();3343  return removed;3344}33453346// ---------------------------------------------------------------------------3347// Vault history — snapshot BEFORE every vault write so the saved blob is3348// what is about to be lost, not what was just written. Capped at 5. The3349// stored blobs are AES-256-GCM ciphertext under the master password, so3350// history adds zero security surface.3351// ---------------------------------------------------------------------------33523353const HISTORY_KEY = "peach_vault_history";3354const HISTORY_MAX = 5;33553356// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the3357// plaintext snapshot field cannot reveal exact vault size or single-entry change3358// deltas. Buckets widen as the vault grows so absolute leakage stays roughly3359// proportional rather than exact.3360function bucketEntryCount(n: number): number {3361  if (n <= 0) return 0;3362  if (n <= 10) return 10;3363  if (n <= 25) return 25;3364  if (n <= 50) return 50;3365  if (n <= 100) return 100;3366  if (n <= 250) return 250;3367  if (n <= 500) return 500;3368  return Math.ceil(n / 500) * 500;3369}33703371/**3372 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE3373 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost3374 * state. Identical-to-most-recent blobs are skipped to avoid filling history3375 * with no-op writes.3376 */3377export async function takeVaultSnapshot(3378  reason: VaultSnapshot["reason"],3379): Promise<void> {3380  // The history store is real-mode state by construction, so it must not be3381  // written from a decoy session either. The guard lives here rather than only3382  // at the call sites so the invariant survives a new caller (PB-26-915).3383  if (vaultMode === "decoy") return;3384  try {3385    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3386    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3387    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];33883389    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,3390    // pre-createVault). createVault skips snapshots intentionally — there is3391    // no prior state to preserve.3392    if (!slotA) return;33933394    const vault_a = JSON.stringify(slotA);3395    const vault_b = JSON.stringify(slotB ?? slotA);33963397    // Best-effort entry count: only meaningful when the in-memory state is3398    // unlocked AND in real mode. Decoy mode would leak the decoy entry3399    // count; locked mode has no entries to count. Both fall through to null.3400    // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage3401    // .local sits next to the AES-GCM blobs in plaintext, so an exact count would3402    // leak vault size and per-write deltas (the metadata padding hides this).3403    let entryCount: number | null = null;3404    if (derivedKey && vaultMode === "real") {3405      try {3406        entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3407      } catch {3408        entryCount = null;3409      }3410    }34113412    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3413    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3414      ? existingRaw3415      : { snapshots: [] };34163417    // Deduplication: if the most recent snapshot has byte-identical blobs,3418    // skip — saves the same vault N times in a row would otherwise burn the3419    // entire history window with no recovery value.3420    const latest = history.snapshots[0];3421    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3422      return;3423    }34243425    const snapshot: VaultSnapshot = {3426      id: crypto.randomUUID(),3427      savedAt: Date.now(),3428      reason,3429      vault_a,3430      vault_b,3431      entryCount,3432    };34333434    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3435    await chrome.storage.local.set({ [HISTORY_KEY]: history });3436  } catch (err) {3437    // Non-fatal — history must never block a save. A failed snapshot just3438    // means the user loses one rung of the safety ladder for this write.3439    // M4/QUA-006: log so the failure is visible; if it looks like a quota3440    // problem, notify the popup so the user can prune history themselves.3441    console.warn('[Peach] takeVaultSnapshot failed:', err);3442    if (String(err).includes('QUOTA') || String(err).includes('quota')) {3443      try {3444        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3445      } catch {3446        // popup may be closed; surfaced via console.warn above3447      }3448    }3449  }3450}34513452/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */3453export async function clearVaultHistory(): Promise<void> {3454  try {3455    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3456  } catch {3457    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().3458  }3459}34603461/** Read all snapshots, newest first, for the Settings vault history view. */3462export async function getVaultHistory(): Promise<VaultSnapshot[]> {3463  if (vaultMode === "decoy") return [];3464  const raw = await chrome.storage.local.get(HISTORY_KEY);3465  const value = (raw as Record<string, unknown>)[HISTORY_KEY];3466  return sanitizeHistory(value);3467}34683469/**3470 * Replace the on-disk vault with the chosen snapshot's blobs and lock the3471 * vault. The current state is itself snapshotted before replacement.3472 */3473export async function restoreVaultSnapshot(3474  snapshotId: string,3475): Promise<{ ok: true } | { ok: false; error: string }> {3476  if (vaultMode === "decoy") return { ok: false, error: "vault_locked" };3477  const history = await getVaultHistory();3478  const snapshot = history.find((s) => s.id === snapshotId);3479  if (!snapshot) return { ok: false, error: "snapshot_not_found" };34803481  const restoredA = parseHistorySlot(snapshot.vault_a);3482  const restoredB = parseHistorySlot(snapshot.vault_b);3483  if (!restoredA || !restoredB) return { ok: false, error: "snapshot_corrupt" };34843485  await takeVaultSnapshot("restore");3486  const current = await chrome.storage.local.get([SLOT_A, SLOT_B]);3487  const previousA = (current as Record<string, unknown>)[SLOT_A];3488  const previousB = (current as Record<string, unknown>)[SLOT_B];3489  try {3490    await chrome.storage.local.set({ [SLOT_A]: restoredA, [SLOT_B]: restoredB });3491    await stampLocalVaultMutation();3492  } catch {3493    try {3494      await chrome.storage.local.set({ [SLOT_A]: previousA, [SLOT_B]: previousB });3495    } catch {3496      // Preserve the restore failure if storage cannot complete the rollback.3497    }3498    return { ok: false, error: "snapshot_corrupt" };3499  }35003501  await lock();3502  return { ok: true };3503}35043505/**3506 * Rotate the vault's sync identity by generating a FRESH ROOT KEY and3507 * re-wrapping everything under it (MV-8). Because every sync credential —3508 * vault_id, the Noise static keypair, the Ed25519 manifest signing key,3509 * and the per-entry envelope keys — derives from the root key, rotation3510 * makes a lost/stolen device's retained key material useless. All paired3511 * devices must re-pair afterwards.3512 *3513 * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from3514 * the root key, so rotation INVALIDATES the old recovery phrase. The flow3515 * sets peach_recovery_pending so the popup re-presents the new words for3516 * confirmation, same as the initial recovery-word confirmation path.3517 */3518export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3519  if (!derivedKey || vaultMode !== "real") {3520    return { ok: false, error: "vault_locked" };3521  }3522  const slotA = await loadSlot(SLOT_A);3523  if (!slotA) return { ok: false, error: "missing_slot_a" };35243525  // 1. Fresh root key + recovery escrow (new words).3526  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3527    await generateAndWrapRootKey();35283529  // 2. Re-wrap the new root key under the current password-derived key.3530  const newRootKey = unhex(rawRootKey);3531  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);35323533  // 3. Snapshot, then single atomic slot write — both escrow wrappings of3534  //    the new root key land together (same transactional rule as3535  //    changePassword). Entries are untouched: they are sealed under the3536  //    password-derived key, not the root key.3537  await takeVaultSnapshot("local_save");3538  const nextSlotA: StoredVault = {3539    ...slotA,3540    recovery_salt: recoverySalt,3541    recovery_wrapped_root_key: wrappedRootKey,3542    recovery_nonce: wrappedNonce,3543    recovery_tag: wrappedTag,3544    ...pwRoot,3545  };3546  await writeSlot(SLOT_A, nextSlotA);35473548  // 4. Swap in memory only after disk committed.3549  if (rootKey) zeroKey(rootKey);3550  rootKey = newRootKey;35513552  // 4b. The identity's root wrap was encrypted under the dead root. Without3553  //     this the identity survives only until the unlock key also changes.3554  await notifyIdentityKeysRotated();35553556  // 5. Old peers' identities derive from the dead root key — drop them all,3557  // with their rendezvous secrets (PB-26-213: rotation clears the map, so3558  // there is nothing to rewrap under the new key).3559  await clearPairedDevices();3560  await clearPairSecrets();35613562  // 6. Force re-presentation of the NEW recovery words on next popup view.3563  await setRecoveryConfirmPending(); // same flag the create-vault flow uses35643565  await stampLocalVaultMutation();3566  return { ok: true };3567}35683569/**3570 * Change the master password. Re-encrypts slot A with a fresh salt + new3571 * derived key. Slot B (duress or noise) is left untouched when duress is3572 * configured — it uses an independent key derived from the duress password —3573 * and regenerated as noise when duress is not configured.3574 */3575export async function changePassword(3576  currentPassword: string,3577  newPassword: string3578): Promise<boolean> {3579  const epoch = vaultEpoch;3580  const stale = () => epoch !== vaultEpoch;3581  if (!derivedKey || vaultMode !== "real") return false;35823583  // Verify current password before allowing any change3584  const valid = await verifyMainPassword(currentPassword);3585  if (!valid) return false;35863587  const slotA = await loadSlot(SLOT_A);3588  if (!slotA) return false;3589  if (!rootKey) return false;35903591  // Derive fresh key from new password + new random salt3592  const newSalt = generateSalt();3593  const newKey = await deriveKey(newPassword, newSalt);3594  if (stale()) {3595    zeroKey(newKey);3596    return false;3597  }35983599  // Re-encrypt the in-memory entries with the new key3600  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3601  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");36023603  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey3604  // under recovery words and remain untouched — recovery phrase keeps working3605  // across password changes (H4/COR-002).3606  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);36073608  const finalSlotA: StoredVault = {3609    ...slotA,3610    salt: hex(newSalt),3611    nonce,3612    ciphertext,3613    tag,3614    ...pwRoot,3615  };36163617  // Snapshot the OLD-key blob before overwriting it. If the user later3618  // restores from this snapshot they will need the OLD password — the3619  // post-restore lock surfaces that immediately.3620  await takeVaultSnapshot("local_save");36213622  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in3623  // one storage transaction. An SW kill before this point leaves the old3624  // slot intact; after this point everything tied to the new password is3625  // already on disk together (M6/COR-004).3626  await writeSlot(SLOT_A, finalSlotA);3627  if (stale()) {3628    zeroKey(newKey);3629    return false;3630  }36313632  // Swap the in-memory key synchronously before any further await. If we3633  // threw between the Slot A write and this swap, the next persistEntries()3634  // would overwrite Slot A with old-key ciphertext, locking the user out.3635  zeroKey(derivedKey);3636  derivedKey = newKey;36373638  // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,3639  // the next service-worker restore decrypts the new-key Slot A with the stale3640  // key, fails tryDecryptSlotResult, and silently drops the user to a lock3641  // screen. Re-escrow the new key so restore survives a password change.3642  if (stale()) {3643    await lock();3644    return false;3645  }3646  await saveSessionKey(newKey, "real", epoch);3647  if (stale()) return false;36483649  // The identity envelope holds one wrap per recovering key. Swapping the3650  // unlock key kills the local wrap, so re-wrap now or the next key swap3651  // orphans the identity -- and until then the old password still opens it.3652  await notifyIdentityKeysRotated();3653  if (stale()) return false;36543655  // Regenerate noise for slot B when duress is not in use. Runs after the3656  // key swap so a failure here is a disposable noise regeneration, not a3657  // lockout.3658  if (!duressConfigured) {3659    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3660  }3661  await clearPinUnlockStorage();3662  await stampLocalVaultMutation();36633664  return true;3665}