vault.ts

115.4 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";16// Static, not `await import()`: runtime import() throws in an MV3 service17// worker. Neither module imports vault.ts back, so there is no cycle to break.18import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21  rootKeyToWords,22  wordsToRootKeyMaterial,23  deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26  AccentColorName,27  Canary,28  ManualSummonRecord,29  PasskeyData,30  SavedIdentity,31  StoredVault,32  VaultEntry,33  VaultHistory,34  VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import { setRecoveryConfirmPending } from "./recovery-pending";3940type EntryDraft = Omit<41  VaultEntry,42  "id" | "createdAt" | "modifiedAt" | "deletedAt"43>;4445// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B46// either holds the decoy vault (when duress is configured) or random noise47// that looks identical at rest. No external indicator is stored — duress48// configuration is recorded only inside the encrypted payload of slot A.49const SLOT_A = "peach_vault_a";50const SLOT_B = "peach_vault_b";5152// Plaintext is padded to the next multiple of this size before encryption.53// This coarsens ciphertext-length leakage into 1 KiB buckets.54const PAD_BLOCK = 1024;5556// How many past passwords to retain per entry. Generous enough to be useful as57// a recovery aid while bounding the secret material we hold (and the ciphertext58// growth) for an entry whose password is rotated frequently.59const PASSWORD_HISTORY_MAX = 50;6061// When duress is configured, BOTH slots are padded to this fixed ceiling so62// their ciphertext lengths are identical and cannot reveal that a hidden decoy63// vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably64// holds a very large vault (thousands of entries incl. passkeys). The decoy65// slot can't be re-encrypted on every real-vault save (the real session has no66// decoy key), so a fixed ceiling — rather than size-matching on each write — is67// what keeps the two slots equal. A real vault whose plaintext exceeds this68// would grow past the ceiling and re-expose the bit; that is an extreme,69// documented edge well beyond normal use.70const DURESS_SLOT_SIZE = 262_144;7172let derivedKey: Uint8Array | null = null;73// 32-byte random root key, separate from the derivedKey used for vault encryption.74// The root key is wrapped (encrypted) under the recovery key derived from the75// 12-word phrase. When the user changes their password, only the wrapping changes;76// the root key and recovery words remain stable.77let rootKey: Uint8Array | null = null;78let entries: VaultEntry[] = [];79// Tombstones outlive their full entry records so a peer that was offline80// during a purge cannot reintroduce an old ciphertext envelope. This map is81// serialized only inside the encrypted vault payload.82let tombstones = new Map<string, number>();83let domainIndex: Map<string, VaultEntry[]> = new Map();84let vaultMode: "real" | "decoy" = "real";85// Cached post-unlock view of whether the real vault records that duress is86// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock87// this stays false because we never see slot A's plaintext.88let duressConfigured = false;8990// Set to a corruption reason during the last unlock() attempt when both slots91// decrypted but failed schema validation. Consumed and cleared by92// consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted93// distinctly from wrong-password (M7/COR-007).94//95// NOTE: module-level state — if vault.ts is ever moved to a worker context,96// this must be replaced with a structured return value from unlock() or a97// postMessage channel. The signal is invisible across module-instance98// boundaries.99let lastUnlockFailureReason: string | null = null;100101export function consumeLastUnlockFailureReason(): string | null {102  const r = lastUnlockFailureReason;103  lastUnlockFailureReason = null;104  return r;105}106107// ---------------------------------------------------------------------------108// Settings (persisted inside the encrypted vault store as a sidecar key)109// ---------------------------------------------------------------------------110111export type TextSize = "small" | "medium" | "large";112113export interface VaultSettings {114  accentColor?: AccentColorName;115  theme?: "dark" | "light";116  autoLockMinutes?: number;117  clipboardClearSeconds?: number;118  lockOnClose?: boolean;119  breachMonitoring?: boolean;120  passkeyProviderEnabled?: boolean;121  trashRetentionDays?: number;122  manualSummonDomains?: Record<string, ManualSummonRecord>;123  declinedCardFingerprints?: string[];124  /**125   * Text size scale for body copy and entry titles. Stops at "large" by126   * design — XL clips dense screens (Vault, Health) inside the fixed127   * 330×480 popup surface.128   */129  textSize?: TextSize;130  /**131   * When true, all entrance/exit transitions and ambient animations are132   * collapsed to instant. Honoured alongside the user's OS-level133   * `prefers-reduced-motion`.134   */135  reduceMotion?: boolean;136  /** Whether this vault has a local PIN unlock wrapper configured. */137  pinEnabled?: boolean;138  /** Whether the lock screen should open on PIN instead of the password. */139  pinPreferred?: boolean;140  /**141   * User overrides for keyboard shortcuts. Keys are ShortcutAction names142   * (see src/shared/shortcuts.ts). Missing actions fall through to143   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the144   * canonical lookup. Storing only the deltas keeps the vault payload145   * small and lets default updates land for users who never rebind.146   */147  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;148}149150interface StoredPinWrap {151  version: 1;152  salt: string;153  nonce: string;154  ciphertext: string;155  tag: string;156}157158const PIN_WRAP_STORAGE = "peach_pin_wrap";159const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";160const PIN_MIN_LENGTH = 4;161const PIN_MAX_LENGTH = 12;162const PIN_MAX_ATTEMPTS = 3;163164let settings: VaultSettings = {};165let inMemoryPinWrap: StoredPinWrap | null = null;166let inMemoryPinFailedAttempts = 0;167let sessionPinStorageFailed = false;168169export function getSettings(): VaultSettings {170  return settings;171}172173export async function ensureSettingsLoaded(): Promise<VaultSettings> {174  await loadSettings();175  return settings;176}177178export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {179  await loadSettings();180  // LV-7: cheap insurance against prototype pollution if a future caller ever181  // feeds parsed external JSON here (native spread keeps __proto__ an own-182  // property today, but Object.assign-style consumers and JSON revivers have183  // been burned before).184  const safe = Object.fromEntries(185    Object.entries(patch).filter(186      ([k]) => !["__proto__", "constructor", "prototype"].includes(k),187    ),188  ) as Partial<VaultSettings>;189  settings = { ...settings, ...safe };190  await persistSettings();191}192193function isValidPin(pin: string): boolean {194  return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);195}196197/** Set up a local PIN wrapper after proving the current master password. */198export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {199  if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;200  if (!(await verifyMainPassword(currentPassword))) return false;201202  const salt = generateSalt();203  const pinKey = await deriveKey(pin, salt);204  try {205    const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");206    await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });207    await updateSettings({ pinEnabled: true });208    return true;209  } finally {210    zeroKey(pinKey);211  }212}213214async function clearPinUnlockStorage(): Promise<void> {215  inMemoryPinWrap = null;216  inMemoryPinFailedAttempts = 0;217  sessionPinStorageFailed = false;218  if (chrome.storage.session) {219    await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);220  }221  await updateSettings({ pinEnabled: false });222}223224/** Remove the PIN wrapper after proving the current master password. */225export async function disablePinUnlock(currentPassword: string): Promise<boolean> {226  if (!derivedKey || vaultMode !== "real") return false;227  if (!(await verifyMainPassword(currentPassword))) return false;228  await clearPinUnlockStorage();229  return true;230}231232/** Unlock the real vault from its locally stored PIN-wrapped derived key. */233export async function unlockWithPin(pin: string): Promise<boolean> {234  if (!isValidPin(pin)) return false;235  await loadSettings();236  if (!settings.pinEnabled) return false;237238  const { stored, failedAttempts } = await loadPinState();239  if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;240  if (241    !stored ||242    stored.version !== 1 ||243    !stored.salt ||244    !stored.nonce ||245    !stored.ciphertext ||246    !stored.tag247  ) return false;248249  const pinKey = await deriveKey(pin, unhex(stored.salt));250  let unwrappedKey: Uint8Array | null = null;251  try {252    const masterKeyHex = await decrypt(253      pinKey,254      stored.nonce,255      stored.ciphertext,256      stored.tag,257      "pin_wrap_v1",258    );259    if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;260    unwrappedKey = unhex(masterKeyHex);261    const result = await unlockWithKey(masterKeyHex);262    if (!result.ok) return false;263    await saveSessionKey(unwrappedKey, "real");264    await resetPinFailedAttempts();265    return true;266  } catch {267    await recordPinFailure(failedAttempts + 1);268    return false;269  } finally {270    zeroKey(pinKey);271    if (unwrappedKey) zeroKey(unwrappedKey);272  }273}274275async function savePinWrap(stored: StoredPinWrap): Promise<void> {276  inMemoryPinWrap = stored;277  inMemoryPinFailedAttempts = 0;278  sessionPinStorageFailed = !chrome.storage.session;279  if (!chrome.storage.session) return;280  try {281    await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });282    sessionPinStorageFailed = false;283  } catch {284    // Firefox fallback: keep the wrapper only in this background context.285    sessionPinStorageFailed = true;286  }287}288289async function loadPinState(): Promise<{290  stored: StoredPinWrap | undefined;291  failedAttempts: number;292}> {293  if (!chrome.storage.session) {294    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };295  }296  try {297    const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);298    return {299      stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??300        (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),301      failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"302        ? result[PIN_ATTEMPTS_STORAGE]303        : 0,304    };305  } catch {306    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };307  }308}309310async function recordPinFailure(failedAttempts: number): Promise<void> {311  inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);312  if (!chrome.storage.session) return;313  try {314    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });315  } catch { /* in-memory counter remains the fallback */ }316}317318async function resetPinFailedAttempts(): Promise<void> {319  inMemoryPinFailedAttempts = 0;320  if (!chrome.storage.session) return;321  try {322    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });323  } catch { /* in-memory counter remains the fallback */ }324}325326export async function getPinUnlockStatus(): Promise<{327  configured: boolean;328  available: boolean;329  attemptsRemaining: number;330}> {331  await loadSettings();332  if (!settings.pinEnabled) {333    return { configured: false, available: false, attemptsRemaining: 0 };334  }335  const { stored, failedAttempts } = await loadPinState();336  const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;337  return {338    configured: true,339    available,340    attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,341  };342}343344async function persistSettings(): Promise<void> {345  try {346    await chrome.storage.local.set({ peach_settings: settings });347  } catch (err) {348    throw mapStorageError(err);349  }350}351352function mapStorageError(err: unknown): Error {353  const message = err instanceof Error ? err.message : String(err);354  if (/QUOTA_BYTES|quota/i.test(message)) {355    return new Error("STORAGE_QUOTA_EXCEEDED");356  }357  return err instanceof Error ? err : new Error(message);358}359360async function loadSettings(): Promise<void> {361  const result = await chrome.storage.local.get("peach_settings");362  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};363}364365// Fire-and-forget sync trigger after a committed vault write. A failure here366// must never fail the write that caused it.367function fireSyncTrigger(reason: "vault_change"): void {368  void Promise.resolve()369    .then(() => triggerSyncIfReachable({ reason }))370    .catch(() => {371      /* no sync runner registered, or nothing reachable — non-fatal */372    });373}374375let lockedSyncRefreshHook: (() => Promise<void>) | null = null;376let lockedSyncUnlockHook: (() => Promise<void>) | null = null;377378export function setLockedSyncHooks(hooks: {379  refresh: () => Promise<void>;380  afterUnlock: () => Promise<void>;381}): void {382  lockedSyncRefreshHook = hooks.refresh;383  lockedSyncUnlockHook = hooks.afterUnlock;384}385386async function refreshLockedSyncState(): Promise<void> {387  if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;388  await lockedSyncRefreshHook().catch((error) => {389    console.warn("[Peach sync] locked spool refresh failed:", error);390  });391}392393// ---------------------------------------------------------------------------394// HIBP breach check (k-anonymity model)395// ---------------------------------------------------------------------------396397export async function checkBreach(password: string): Promise<number> {398  // HIBP k-anonymity API is keyed on SHA-1 prefixes — this is dictated by399  // the public API, not a security choice. Do not migrate to SHA-256 here.400  const encoder = new TextEncoder();401  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));402  const hashHex = Array.from(new Uint8Array(hashBuffer))403    .map((b) => b.toString(16).padStart(2, "0"))404    .join("")405    .toUpperCase();406407  const prefix = hashHex.slice(0, 5);408  const suffix = hashHex.slice(5);409410  try {411    // PCH-02-007: request HIBP response padding so response length does not412    // reveal how many suffixes a queried prefix returned.413    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {414      headers: { "Add-Padding": "true" },415    });416    if (!res.ok) return 0;417    const text = await res.text();418    const match = text.split("\r\n").find((l) => l.startsWith(suffix));419    return match ? parseInt(match.split(":")[1], 10) : 0;420  } catch {421    return 0; // Network unavailable — don't block422  }423}424425export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {426  const fingerprint = await cardFingerprint(last4, expMonth, expYear);427  await loadSettings();428  settings = {429    ...settings,430    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],431  };432  await persistSettings();433}434435export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {436  await loadSettings();437  const fingerprint = await cardFingerprint(last4, expMonth, expYear);438  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);439}440441async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {442  const encoder = new TextEncoder();443  const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));444  return Array.from(new Uint8Array(buf).slice(0, 8))445    .map((b) => b.toString(16).padStart(2, "0"))446    .join("");447}448449function hex(buf: Uint8Array): string {450  return Array.from(buf)451    .map((b) => b.toString(16).padStart(2, "0"))452    .join("");453}454455function unhex(s: string): Uint8Array {456  const out = new Uint8Array(s.length / 2);457  for (let i = 0; i < s.length; i += 2) {458    out[i / 2] = parseInt(s.slice(i, i + 2), 16);459  }460  return out;461}462463// ---------------------------------------------------------------------------464// Root key generation and recovery word wrapping465// ---------------------------------------------------------------------------466467/**468 * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key469 * derived from the recovery word material via HKDF-SHA256. The wrapped blob470 * fields are stored in slot A alongside the password-wrapped ciphertext. The471 * root key itself is kept in memory for the session so getRecoveryWords() can472 * encode it without re-deriving.473 *474 * SECURITY MODEL (PCH-01-005): the recovery KEK is derived from the 12-word475 * material, which encodes only rootKey[0..16] (recovery-words.ts). The KEK is thus476 * derived from a PREFIX of the very plaintext it wraps, and effective recovery477 * security is 128-bit (standard for a 12-word phrase), NOT 256-bit. This is safe —478 * unwrapping requires the words, and the GCM ciphertext leaks nothing without them —479 * but it is intentional, not an oversight. A future escrow version wanting 256-bit480 * recovery must derive the words from INDEPENDENT entropy, not from the root key.481 */482async function generateAndWrapRootKey(): Promise<{483  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey484  recoverySalt: string;485  wrappedRootKey: string;486  wrappedNonce: string;487  wrappedTag: string;488}> {489  const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));490491  // Derive the recovery key from the BIP39 words encoding of the root key.492  const words = await rootKeyToWords(rootKeyBytes);493  const wordMaterial = await wordsToRootKeyMaterial(words);494  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));495  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);496497  // Wrap the root key with the recovery key using AES-256-GCM directly.498  // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.499  const nonce = crypto.getRandomValues(new Uint8Array(12));500  const encoded = new TextEncoder().encode(hex(rootKeyBytes));501  const raw = await crypto.subtle.encrypt(502    {503      name: "AES-GCM",504      iv: nonce,505      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,506      tagLength: 128,507    },508    recoveryKey,509    encoded510  );511  // WebCrypto returns ciphertext || auth tag concatenated; split them.512  const rawU8 = new Uint8Array(raw);513  const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));514  const tag = hex(rawU8.slice(rawU8.length - 16));515516  return {517    rawRootKey: hex(rootKeyBytes),518    recoverySalt: hex(recoverySalt),519    wrappedRootKey: ciphertext,520    wrappedNonce: hex(nonce),521    wrappedTag: tag,522  };523}524525async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{526  recoverySalt: string;527  wrappedRootKey: string;528  wrappedNonce: string;529  wrappedTag: string;530}> {531  if (rootKeyBytes.length !== 32) {532    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");533  }534535  const words = await rootKeyToWords(rootKeyBytes);536  const wordMaterial = await wordsToRootKeyMaterial(words);537  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));538  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);539  const nonce = crypto.getRandomValues(new Uint8Array(12));540  const encoded = new TextEncoder().encode(hex(rootKeyBytes));541  const raw = await crypto.subtle.encrypt(542    {543      name: "AES-GCM",544      iv: nonce,545      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,546      tagLength: 128,547    },548    recoveryKey,549    encoded,550  );551  const rawU8 = new Uint8Array(raw);552  return {553    recoverySalt: hex(recoverySalt),554    wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),555    wrappedNonce: hex(nonce),556    wrappedTag: hex(rawU8.slice(rawU8.length - 16)),557  };558}559560/**561 * Unwrap the root key using recovery words. Called during recovery flow when562 * the user provides their 12-word phrase and a new password.563 * Exported for use by the RECOVER_WITH_WORDS message handler.564 */565export async function unwrapRootKeyForRecovery(566  wrappedRootKey: string,567  wrappedNonce: string,568  wrappedTag: string,569  recoverySaltHex: string,570  words: string[]571): Promise<void> {572  const wordMaterial = await wordsToRootKeyMaterial(words);573  const recoverySalt = unhex(recoverySaltHex);574  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);575576  // Decrypt and cache the root key. The root key bytes were hex-encoded before577  // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt578  // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.579  // Each component is stored separately in the vault slot; decode them individually580  // so the nonce is always exactly the 12 bytes from wrappedNonce.581  const nonceRaw = unhex(wrappedNonce);582  const ciphertextRaw = unhex(wrappedRootKey);583  const tagRaw = unhex(wrappedTag);584  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).585  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.586  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;587  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);588  combined.set(ciphertextRaw, 0);589  combined.set(tagRaw, ciphertextRaw.length);590  const raw = await crypto.subtle.decrypt(591    { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },592    recoveryKey,593    combined594  );595  const decryptedHex = new TextDecoder().decode(raw);596  rootKey = unhex(decryptedHex);597}598599/**600 * Re-wrap the cached root key under a new derived key (from the user's new601 * password). Called after password change and after recovery re-key so the602 * same recovery words continue to work with the updated password — and so603 * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.604 *605 * recovery_* fields are intentionally not touched: those wrap rootKey under606 * the recovery words, which do not change when the password changes.607 */608export async function rewrapRootKeyForPassword(609  newDerivedKey: Uint8Array,610  _newSalt: Uint8Array611): Promise<void> {612  if (!rootKey) throw new Error("root key not set");613  const slotA = await loadSlot(SLOT_A);614  if (!slotA) throw new Error("missing slot A");615  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);616  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });617}618619/**620 * Change the vault encryption to a new derived key (already derived from621 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.622 * Caller is responsible for key swap and snapshot.623 */624async function reencryptVaultForNewKey(625  newKey: Uint8Array,626  slotA: StoredVault627): Promise<void> {628  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);629  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");630  await writeSlot(SLOT_A, {631    ...slotA,632    nonce,633    ciphertext,634    tag,635  });636}637638/**639 * Full password change with an already-derived key (used after recovery).640 * Unlike changePassword(), this skips re-verification of current password641 * since recovery flow has already validated the user's identity via words.642 */643export async function changePasswordWithNewKey(644  newSalt: Uint8Array,645  newKey: Uint8Array646): Promise<void> {647  const slotA = await loadSlot(SLOT_A);648  if (!slotA) throw new Error("missing slot A");649  // reencryptVaultForNewKey must receive the updated salt so that subsequent650  // UNLOCK calls with the new password can re-derive the correct key.651  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });652  if (derivedKey) zeroKey(derivedKey);653  derivedKey = newKey;654  // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow655  // reaches this path, so a post-recovery SW restart would otherwise lock out656  // a user who just reset their password.657  void saveSessionKey(newKey, "real");658  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works659  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow660  // should have populated it — fall back silently rather than blowing up the661  // recovery path; the escrow is already present on every supported vault.662  if (rootKey) {663    const slotAfter = await loadSlot(SLOT_A);664    if (slotAfter) {665      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);666      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });667    }668  }669  if (!duressConfigured) {670    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));671  }672  await clearPinUnlockStorage();673  await stampLocalVaultMutation();674}675676// ---------------------------------------------------------------------------677// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive678// a new key after password reset without re-importing the crypto module.679// ---------------------------------------------------------------------------680681export { deriveKey, generateSalt };682683export async function loadSlotA(): Promise<StoredVault | null> {684  return loadSlot(SLOT_A);685}686687/**688 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields689 * (a second wrapping of the root key under the password-derived key). This690 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without691 * the user re-entering their recovery phrase: the same root key bytes —692 * and therefore the same 12 words — survive every unlock.693 *694 * Returns null when the escrow is absent or decryption fails.695 */696async function deriveRootKeyFromDerivedKey(697  derivedKeyBytes: Uint8Array,698  slotA: StoredVault,699): Promise<Uint8Array | null> {700  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {701    return null;702  }703  try {704    const rootHex = await decrypt(705      derivedKeyBytes,706      slotA.pw_root_nonce,707      slotA.pw_root_ciphertext,708      slotA.pw_root_tag,709      "pw_root_v1",710    );711    return unhex(rootHex);712  } catch {713    return null;714  }715}716717// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the718// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.719async function wrapRootKeyForDerivedKey(720  derivedKeyBytes: Uint8Array,721  rootKeyBytes: Uint8Array,722): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {723  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");724  return {725    pw_root_ciphertext: ciphertext,726    pw_root_nonce: nonce,727    pw_root_tag: tag,728  };729}730731/** Returns the 12 recovery words for the current session's root key. */732export async function getRecoveryWords(): Promise<string[]> {733  if (!rootKey) {734    throw new Error("recovery not set up");735  }736  return rootKeyToWords(rootKey);737}738739async function loadSlot(key: string): Promise<StoredVault | null> {740  const result = await chrome.storage.local.get(key);741  return (result as Record<string, StoredVault | undefined>)[key] ?? null;742}743744async function writeSlot(key: string, vault: StoredVault): Promise<void> {745  try {746    await chrome.storage.local.set({ [key]: vault });747  } catch (err) {748    throw mapStorageError(err);749  }750}751752/**753 * Roll back a vault that was created by [createVaultFromRootKey] but754 * couldn't be paired (e.g. the subsequent license activation, identity755 * derivation, or paired-device upsert failed). Without this, a failed756 * receiver session leaves behind an empty vault — the user has to757 * re-pair to get a working setup, but the empty vault sits in the way.758 *759 * The call is idempotent: removing keys that aren't there is a no-op.760 */761export async function rollbackAdoptedVault(): Promise<void> {762  lock();763  try {764    await chrome.storage.local.remove([765      SLOT_A,766      SLOT_B,767      "peach_last_modified_at",768    ]);769  } catch (err) {770    throw mapStorageError(err);771  }772}773774async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {775  const raw = await chrome.storage.local.get("peach_last_modified_at");776  const previous = Number(777    (raw as Record<string, unknown>).peach_last_modified_at,778  );779  const next = Math.max(780    Date.now(),781    Number.isFinite(previous) ? previous + 1 : 0,782  );783  try {784    await chrome.storage.local.set({785      [SLOT_A]: slotA,786      [SLOT_B]: slotB,787      peach_last_modified_at: String(next),788    });789  } catch (err) {790    throw mapStorageError(err);791  }792}793794// Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the795// sync layer's last-write-wins merge against wall-clock regressions (NTP step,796// manual clock change, DST glitch): a backwards Date.now() must never produce797// an entry timestamp that's <= the entry's existing modifiedAt, or a genuine798// local edit would lose to a stale remote copy. The returned value is always799// strictly greater than both the entry's current modifiedAt and the previous800// call's result (SEV-2-E).801let lastEntryTimestamp = 0;802function monotonicEntryTimestamp(currentModifiedAt?: number): number {803  const base = Math.max(804    Date.now(),805    (currentModifiedAt ?? 0) + 1,806    lastEntryTimestamp + 1,807  );808  lastEntryTimestamp = base;809  return base;810}811812async function stampLocalVaultMutation(): Promise<void> {813  const raw = await chrome.storage.local.get("peach_last_modified_at");814  const previous = Number(815    (raw as Record<string, unknown>).peach_last_modified_at,816  );817  const next = Math.max(818    Date.now(),819    Number.isFinite(previous) ? previous + 1 : 0,820  );821  await chrome.storage.local.set({822    peach_last_modified_at: String(next),823  });824}825826// ---------------------------------------------------------------------------827// Padding + noise helpers828// ---------------------------------------------------------------------------829830/**831 * Pads the plaintext string with trailing spaces so that the encoded byte832 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing833 * whitespace after a complete document, so the padding is transparent on834 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.835 */836function padPlaintext(json: string, minBytes = 0): string {837  const encoded = new TextEncoder().encode(json);838  const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);839  // minBytes lets the duress path force a fixed ceiling so both slots match.840  const target = Math.max(bucket, minBytes);841  const padLen = target - encoded.length;842  return json + " ".repeat(padLen);843}844845/**846 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The847 * ciphertext length matches the given byte count. Because GCM ciphertext is848 * pseudo-random, this blob is indistinguishable at rest from a real849 * encrypted vault without the key.850 *851 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto852 * spec hard limit). Chunk the fill so vaults larger than that — common once853 * the user has dozens of entries or any passkey — don't throw.854 */855const CSPRNG_CHUNK = 65_536;856857function fillRandom(buf: Uint8Array): Uint8Array {858  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {859    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);860    const chunk = new Uint8Array(end - offset);861    crypto.getRandomValues(chunk);862    buf.set(chunk, offset);863  }864  return buf;865}866867function generateNoiseBlob(ciphertextBytes: number): StoredVault {868  const salt = crypto.getRandomValues(new Uint8Array(16));869  const nonce = crypto.getRandomValues(new Uint8Array(12));870  const cipher = fillRandom(new Uint8Array(ciphertextBytes));871  const tag = crypto.getRandomValues(new Uint8Array(16));872  return {873    version: 1,874    salt: hex(salt),875    nonce: hex(nonce),876    ciphertext: hex(cipher),877    tag: hex(tag),878  };879}880881// ---------------------------------------------------------------------------882// Encrypted plaintext envelope883// ---------------------------------------------------------------------------884885type VaultPayload = {886  entries: VaultEntry[];887  tombstones?: Record<string, number>;888  duressConfigured?: boolean;889};890891type DecryptedPayload = {892  entries: VaultEntry[];893  tombstones: Map<string, number>;894  duressConfigured: boolean;895};896897function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {898  const envelope: VaultPayload = {899    entries,900    tombstones: Object.fromEntries(tombstones),901    duressConfigured: duress,902  };903  return padPlaintext(JSON.stringify(envelope), minBytes);904}905906function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {907  const result = new Map<string, number>();908  if (raw && typeof raw === "object" && !Array.isArray(raw)) {909    for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {910      if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {911        result.set(id, timestamp);912      }913    }914  }915  // Older payloads had no ledger. Rebuild the durable portion from any916  // soft-deleted entries before they are eligible for a later purge.917  for (const entry of allEntries) {918    if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {919      continue;920    }921    if ((result.get(entry.id) ?? 0) < entry.deletedAt) {922      result.set(entry.id, entry.deletedAt);923    }924  }925  return result;926}927928function reconcileTombstones(): void {929  const byId = new Map(entries.map((entry) => [entry.id, entry]));930  for (const [id] of tombstones) {931    if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);932  }933  for (const entry of entries) {934    if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {935      if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {936        tombstones.set(entry.id, entry.deletedAt);937      }938    }939  }940}941942function decodePayload(plain: string): DecryptedPayload {943  const parsed = JSON.parse(plain) as VaultPayload;944  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {945    throw new Error("invalid vault payload");946  }947  const normalized = parsed.entries.map(normalizeEntry);948  return {949    entries: normalized,950    tombstones: decodeTombstones(parsed.tombstones, normalized),951    duressConfigured: parsed.duressConfigured === true,952  };953}954955/**956 * Consolidate duplicate passkey records after registration.957 *958 * Multiple registrations for one RP can leave duplicate passkey records. This959 * pass collapses the duplicates:960 *961 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.962 *   2. Within each group, the most recently-active credential wins (prefer963 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).964 *   3. If a NON-passkey login entry exists for that RP (matched by url or965 *      additionalUrls hostname), attach the winning passkey to it and966 *      soft-delete every passkey-bearing entry in the group.967 *   4. Otherwise, soft-delete every loser in the group, keeping the winner968 *      as a passkey-only entry.969 *970 * Soft delete (deletedAt) rather than splice — the trash flow keeps the971 * record around for retention and the user can restore if the heuristic972 * picked wrong. The `merged` flag goes back to decodePayload so the973 * unlock path persists the consolidated shape immediately.974 */975function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {976  let merged = false;977  const now = Date.now();978979  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);980  const byRpId = new Map<string, VaultEntry[]>();981  for (const e of candidates) {982    const rpId = e.passkey!.rpId;983    const list = byRpId.get(rpId);984    if (list) list.push(e);985    else byRpId.set(rpId, [e]);986  }987988  for (const [rpId, group] of byRpId) {989    if (group.length === 0) continue;990    group.sort((a, b) => {991      const aT =992        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;993      const bT =994        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;995      return bT - aT;996    });997    const winner = group[0]!;998999    // Look for a NON-passkey login entry matching this RP. Use the URL1000    // hostname rather than a substring search so a "login.foo.com" entry1001    // doesn't accidentally absorb a passkey for "foo.com".1002    const loginTarget = entries.find((e) => {1003      if (e.deletedAt || e.passkey || e.type !== "login") return false;1004      if (e.url && extractDomain(e.url) === rpId) return true;1005      for (const extra of e.additionalUrls ?? []) {1006        if (extractDomain(extra) === rpId) return true;1007      }1008      return false;1009    });10101011    if (loginTarget) {1012      loginTarget.passkey = winner.passkey;1013      // A passkey entry may also carry the site's TOTP seed. Preserve it when1014      // the passkey is consolidated onto an existing login instead of1015      // soft-deleting the only record that held the 2FA configuration.1016      if (!loginTarget.totp && winner.totp) {1017        loginTarget.totp = winner.totp;1018        loginTarget.totpAlgorithm = winner.totpAlgorithm;1019        loginTarget.totpDigits = winner.totpDigits;1020        loginTarget.totpPeriod = winner.totpPeriod;1021      }1022      loginTarget.modifiedAt = now;1023      for (const dup of group) {1024        if (!dup.deletedAt) {1025          dup.deletedAt = now;1026          dup.modifiedAt = now;1027        }1028      }1029      merged = true;1030    } else if (group.length > 1) {1031      for (let i = 1; i < group.length; i += 1) {1032        const dup = group[i]!;1033        if (!dup.deletedAt) {1034          dup.deletedAt = now;1035          dup.modifiedAt = now;1036        }1037      }1038      merged = true;1039    }1040  }10411042  return { merged };1043}10441045// Validates that a `StoredVault` record on disk has the field shape we expect1046// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only1047// authenticates the static VERSION_BYTE AAD, not the `version` field on the1048// outer record — a corrupted `version: 42` slot still decrypts cleanly. This1049// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a1050// history-restore path instead of falsely reporting "wrong password".1051function hasValidVaultSchema(v: unknown): v is StoredVault {1052  if (typeof v !== "object" || v === null) return false;1053  const r = v as Record<string, unknown>;1054  return (1055    r.version === 1 &&1056    typeof r.salt === "string" &&1057    typeof r.nonce === "string" &&1058    typeof r.ciphertext === "string" &&1059    typeof r.tag === "string"1060  );1061}10621063export type TryDecryptResult =1064  | { status: "ok"; payload: DecryptedPayload }1065  | { status: "wrong_key" }1066  | { status: "corrupted"; reason: string };10671068// Exported solely for `unlockFromCodex` below, which needs to verify a1069// supplied passphrase against a slotA blob *without* committing anything to1070// storage. Keep this internal otherwise — callers crossing the messages.ts1071// boundary should always go through `unlock` / `verifyMainPassword` so the1072// constant-time invariants in Projects.md §3.4 are not bypassed.1073export async function tryDecryptSlot(1074  vault: StoredVault,1075  key: Uint8Array,1076  aadTag?: string,1077): Promise<DecryptedPayload | null> {1078  const res = await tryDecryptSlotResult(vault, key, aadTag);1079  return res.status === "ok" ? res.payload : null;1080}10811082export async function tryDecryptSlotResult(1083  vault: StoredVault,1084  key: Uint8Array,1085  aadTag?: string,1086): Promise<TryDecryptResult> {1087  if (!hasValidVaultSchema(vault)) {1088    return { status: "corrupted", reason: "invalid_slot_schema" };1089  }1090  let plain: string;1091  try {1092    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1093  } catch {1094    return { status: "wrong_key" };1095  }1096  try {1097    const parsed = JSON.parse(plain) as unknown;1098    if (typeof parsed !== "object" || parsed === null) {1099      return { status: "corrupted", reason: "invalid_payload_shape" };1100    }1101    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1102      return { status: "corrupted", reason: "invalid_payload_shape" };1103    }1104    return { status: "ok", payload: decodePayload(plain) };1105  } catch {1106    return { status: "corrupted", reason: "invalid_payload_json" };1107  }1108}11091110// ---------------------------------------------------------------------------1111// Core operations1112// ---------------------------------------------------------------------------11131114function buildDomainIndexSync(all: VaultEntry[]): void {1115  domainIndex = new Map();11161117  for (const entry of all) {1118    if (entry.deletedAt) continue;1119    if (entry.type === "note" || entry.type === "card") continue;11201121    // An entry can match multiple sites via `additionalUrls`. Index each1122    // hostname so `queryDomain` returns the entry regardless of which alias1123    // the user is currently visiting. Dedupe per-entry so a single entry1124    // never appears twice in one bucket.1125    const domains = new Set<string>();1126    const primary = extractDomain(entry.url ?? "");1127    if (primary) domains.add(primary);1128    for (const extra of entry.additionalUrls ?? []) {1129      const d = extractDomain(extra);1130      if (d) domains.add(d);1131    }1132    if (domains.size === 0) continue;11331134    for (const domain of domains) {1135      const bucket = domainIndex.get(domain) ?? [];1136      bucket.push(entry);1137      domainIndex.set(domain, bucket);1138    }1139  }1140}11411142export async function createVault(password: string): Promise<void> {1143  const salt = generateSalt();1144  derivedKey = await deriveKey(password, salt);1145  entries = [];1146  tombstones = new Map();1147  vaultMode = "real";1148  duressConfigured = false;1149  buildDomainIndexSync(entries);11501151  // Generate and wrap the root key for recovery word escrow.1152  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1153    await generateAndWrapRootKey();1154  rootKey = unhex(rawRootKey);11551156  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1157  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");11581159  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate1160  // it without recovery words (H2/COR-006).1161  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);11621163  const slotA: StoredVault = {1164    version: 1,1165    salt: hex(salt),1166    nonce,1167    ciphertext,1168    tag,1169    recovery_salt: recoverySalt,1170    recovery_wrapped_root_key: wrappedRootKey,1171    recovery_nonce: wrappedNonce,1172    recovery_tag: wrappedTag,1173    ...pwRoot,1174  };1175  await writeSlot(SLOT_A, slotA);11761177  // Matching-size noise blob for slot B, so a fresh install shows two1178  // similarly-shaped records regardless of whether duress is ever set up.1179  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1180  await stampLocalVaultMutation();11811182  touchAutoLock();1183}11841185/**1186 * Creates a local vault from an existing vault root key received through the1187 * SAS-gated adoption protocol. The password is local to this device; it wraps1188 * the adopted root key at rest and does not need to match the source device.1189 */1190export async function createVaultFromRootKey(1191  password: string,1192  rootKeyHex: string,1193): Promise<{ vaultId: string }> {1194  if (typeof password !== "string" || password.length === 0) {1195    throw new Error("ADOPT_INVALID_PASSWORD");1196  }1197  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1198    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1199  }12001201  const rootKeyBytes = unhex(rootKeyHex);1202  if (rootKeyBytes.length !== 32) {1203    rootKeyBytes.fill(0);1204    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1205  }12061207  let nextDerivedKey: Uint8Array | null = null;1208  let sessionRootKey: Uint8Array | null = null;1209  try {1210    if ((await loadSlot(SLOT_A)) !== null) {1211      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1212    }12131214    const nextEntries: VaultEntry[] = [];1215    const nextDuressConfigured = false;1216    const vaultId = await computeVaultId(rootKeyBytes);1217    const salt = generateSalt();1218    nextDerivedKey = await deriveKey(password, salt);1219    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);12201221    sessionRootKey = new Uint8Array(rootKeyBytes);1222    const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1223    const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1224    const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1225    const slotA: StoredVault = {1226      version: 1,1227      salt: hex(salt),1228      nonce,1229      ciphertext,1230      tag,1231      recovery_salt: recovery.recoverySalt,1232      recovery_wrapped_root_key: recovery.wrappedRootKey,1233      recovery_nonce: recovery.wrappedNonce,1234      recovery_tag: recovery.wrappedTag,1235      ...pwRoot,1236    };1237    const slotB = generateNoiseBlob(ciphertext.length / 2);1238    await commitCreatedVaultSlots(slotA, slotB);12391240    const previousDerivedKey = derivedKey;1241    const previousRootKey = rootKey;1242    derivedKey = nextDerivedKey;1243    rootKey = sessionRootKey;1244    nextDerivedKey = null;1245    sessionRootKey = null;1246    entries = nextEntries;1247    tombstones = new Map();1248    vaultMode = "real";1249    duressConfigured = nextDuressConfigured;1250    buildDomainIndexSync(entries);1251    if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1252    if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1253    touchAutoLock();12541255    return { vaultId };1256  } finally {1257    rootKeyBytes.fill(0);1258    if (nextDerivedKey) zeroKey(nextDerivedKey);1259    if (sessionRootKey) sessionRootKey.fill(0);1260  }1261}12621263export async function hasVault(): Promise<boolean> {1264  return (await loadSlot(SLOT_A)) !== null;1265}12661267/**1268 * Returns whether the real vault has recorded that a duress passphrase is1269 * configured. Reliable only after a real-mode unlock — before unlock or in1270 * decoy mode, returns false because we cannot read slot A's plaintext.1271 */1272export async function isDuressEnabled(): Promise<boolean> {1273  return vaultMode === "real" && duressConfigured;1274}12751276export async function setupDuress(duressPassword: string): Promise<void> {1277  if (!derivedKey || vaultMode !== "real") {1278    throw new Error("vault must be unlocked in real mode");1279  }1280  // Encrypt the decoy payload (empty entries) under the duress key. Pad to1281  // at least the current slot A ciphertext size so the two slots look the1282  // same at rest at setup time.1283  const slotA = await loadSlot(SLOT_A);1284  if (!slotA) throw new Error("missing slot A");12851286  const decoySalt = generateSalt();1287  const decoyKey = await deriveKey(duressPassword, decoySalt);1288  try {1289    // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1290    // same ceiling by the persistSlotA() call below (duressConfigured is now1291    // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1292    // lengths cannot reveal that duress is configured (audit MEDIUM).1293    const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1294    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1295    await writeSlot(SLOT_B, {1296      version: 1,1297      salt: hex(decoySalt),1298      nonce,1299      ciphertext,1300      tag,1301    });1302  } finally {1303    zeroKey(decoyKey);1304  }13051306  // Re-encrypt slot A with the duressConfigured flag set to true. No external1307  // flag is written — the bit lives inside the encrypted envelope.1308  duressConfigured = true;1309  await persistSlotA();1310}13111312export async function disableDuress(): Promise<void> {1313  if (!derivedKey || vaultMode !== "real") {1314    throw new Error("vault must be unlocked in real mode");1315  }1316  // Overwrite slot B with fresh noise that matches slot A's size. The slot1317  // remains occupied so its mere existence does not signal that duress was1318  // ever configured.1319  const slotA = await loadSlot(SLOT_A);1320  if (slotA) {1321    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1322  }13231324  duressConfigured = false;1325  await persistSlotA();1326}13271328// L14/COR-015: this function intentionally checks slot A (real vault) only.1329// A duress-mode user attempting changePassword is rejected earlier by the1330// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1331// distinction here is by design — duress users cannot rotate the real1332// password by guessing it, and the asymmetry in error timing is acceptable1333// because duress mode is opt-in and the user knows their own decoy key.1334export async function verifyMainPassword(password: string): Promise<boolean> {1335  const slotA = await loadSlot(SLOT_A);1336  if (!slotA) return false;1337  const key = await deriveKey(password, unhex(slotA.salt));1338  try {1339    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1340  } finally {1341    zeroKey(key);1342  }1343}13441345export async function unlock(password: string): Promise<boolean> {1346  return attemptUnlockWithDerive(password, deriveKey);1347}13481349async function attemptUnlockWithDerive(1350  password: string,1351  derive: typeof deriveKey,1352): Promise<boolean> {1353  // Load both current-format slots in parallel. Both exist on a supported1354  // install; the1355  // parallel load and the parallel derivation below keep wall-clock time1356  // constant regardless of which slot's passphrase (real or decoy) the1357  // caller supplied, so a timing side channel cannot distinguish the1358  // duress path from the real-unlock path (DUR-002).1359  const [slotA, slotB] = await Promise.all([1360    loadSlot(SLOT_A),1361    loadSlot(SLOT_B),1362  ]);13631364  // Derive both keys in parallel, unconditionally.1365  const [keyA, keyB] = await Promise.all([1366    slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1367    slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1368  ]);13691370  // Attempt both decrypts in parallel. The Result variant lets us distinguish1371  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1372  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1373  const [resultA, resultB] = await Promise.all([1374    keyA && slotA1375      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1376      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1377    keyB && slotB1378      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1379      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1380  ]);1381  const payloadA = resultA.status === "ok" ? resultA.payload : null;1382  const payloadB = resultB.status === "ok" ? resultB.payload : null;13831384  if (payloadA && keyA) {1385    derivedKey = keyA;1386    void saveSessionKey(keyA, "real");1387    vaultMode = "real";1388    entries = payloadA.entries;1389    tombstones = payloadA.tombstones;1390    duressConfigured = payloadA.duressConfigured;1391    buildDomainIndexSync(entries);1392    await loadSettings();1393    touchAutoLock();1394    if (keyB) zeroKey(keyB);13951396    // Current vaults always carry a password-wrapped root key. Older vaults1397    // without this escrow are outside the supported storage format.1398    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1399    if (!rootKey) {1400      lock();1401      return false;1402    }14031404    await lockedSyncUnlockHook?.().catch((error) => {1405      console.warn("[Peach sync] locked inbox drain failed:", error);1406    });1407    await resetPinFailedAttempts();1408    return true;1409  }14101411  if (payloadB && keyB) {1412    derivedKey = keyB;1413    void saveSessionKey(keyB, "decoy");1414    vaultMode = "decoy";1415    entries = payloadB.entries;1416    tombstones = payloadB.tombstones;1417    duressConfigured = false;1418    buildDomainIndexSync(entries);1419    await loadSettings();1420    touchAutoLock();1421    if (keyA) zeroKey(keyA);1422    return true;1423  }14241425  if (keyA) zeroKey(keyA);1426  if (keyB) zeroKey(keyB);1427  // Surface corruption (good AES tag, broken schema) distinctly from1428  // wrong-password so the popup can offer history-restore (M7/COR-007).1429  if (resultA.status === "corrupted") {1430    lastUnlockFailureReason = resultA.reason;1431  } else if (resultB.status === "corrupted") {1432    lastUnlockFailureReason = resultB.reason;1433  }1434  return false;1435}14361437// ---------------------------------------------------------------------------1438// MV3 service worker session key — survives SW restarts within a browser session1439// ---------------------------------------------------------------------------14401441// chrome.storage.session is cleared on browser close; survives SW restart.1442// Firefox does not implement it (persistent background script, no restart risk).1443const SESSION_KEY_STORAGE = "peach_session_dk";14441445async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1446  if (!chrome.storage.session) return;1447  await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1448}14491450async function clearSessionKey(): Promise<void> {1451  if (!chrome.storage.session) return;1452  await chrome.storage.session.remove(SESSION_KEY_STORAGE);1453}14541455/**1456 * On SW restart, re-derive all in-memory vault state from the session key1457 * stored in chrome.storage.session (set at unlock time, cleared by lock();1458 * also cleared on browser close or when restoration fails).1459 * Returns true if the vault was restored, false if no session key was found.1460 * No-ops if the vault is already unlocked.1461 */1462export async function restoreFromSession(): Promise<boolean> {1463  if (derivedKey !== null) return true;1464  if (!chrome.storage.session) return false;1465  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1466  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1467  if (!entry?.keyHex) return false;14681469  const key = unhex(entry.keyHex);1470  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1471  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1472  const slot = await loadSlot(slotKey);1473  if (!slot) { await clearSessionKey(); return false; }14741475  const result = await tryDecryptSlotResult(slot, key, aadTag);1476  if (result.status !== "ok") { await clearSessionKey(); return false; }14771478  derivedKey = key;1479  vaultMode = entry.mode;1480  entries = result.payload.entries;1481  tombstones = result.payload.tombstones;1482  duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1483  buildDomainIndexSync(entries);1484  await loadSettings();1485  touchAutoLock();14861487  // Rehydrate the root key used by sync identity derivation from the password1488  // escrow stored on the current vault format.1489  if (entry.mode === "real") {1490    rootKey = await deriveRootKeyFromDerivedKey(key, slot);1491    if (!rootKey) {1492      lock();1493      return false;1494    }1495  }14961497  await lockedSyncUnlockHook?.().catch((error) => {1498    console.warn("[Peach sync] locked inbox drain failed:", error);1499  });15001501  return true;1502}15031504export function lock(): void {1505  // A user-initiated lock must be durable across MV3 service-worker restarts.1506  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1507  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1508  // If the session key survived lock(), that restore would silently re-unlock1509  // the vault ~a minute after the user locked it — a lock that only held1510  // while the SW happened to stay alive. The background-sync convenience the1511  // session key once bought (sync runner re-waking to a restored vault) is1512  // not worth an unlocked vault the user never asked to unlock; when locked,1513  // sync-runner correctly reports VAULT_LOCKED instead.1514  void clearSessionKey();1515  if (derivedKey) {1516    zeroKey(derivedKey);1517    derivedKey = null;1518  }1519  if (rootKey) {1520    rootKey.fill(0);1521    rootKey = null;1522  }15231524  entries = [];1525  tombstones = new Map();1526  domainIndex = new Map();1527  vaultMode = "real";1528  duressConfigured = false;1529  clearAutoLock();1530}15311532export function isUnlocked(): boolean {1533  return derivedKey !== null;1534}15351536// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1537// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1538// always derives from scratch via Argon2id.1539export async function unlockWithKey(1540  masterKeyHex: string,1541): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1542  try {1543    const masterKeyBytes = unhex(masterKeyHex);1544    const stored = await loadSlot(SLOT_A);1545    if (!stored) return { ok: false, error: "invalid_key" };1546    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1547    if (!payload) return { ok: false, error: "invalid_key" };15481549    derivedKey = masterKeyBytes;1550    vaultMode = "real";1551    entries = payload.entries;1552    tombstones = payload.tombstones;1553    duressConfigured = payload.duressConfigured;1554    buildDomainIndexSync(entries);1555    await loadSettings();1556    touchAutoLock();15571558    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1559    if (!rootKey) {1560      lock();1561      return { ok: false, error: "invalid_key" };1562    }1563    return { ok: true, entries };1564  } catch {1565    return { ok: false, error: "invalid_key" };1566  }1567}15681569// --- Adoption duress-slot helpers (passthrough, no decrypt) ---15701571/**1572 * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1573 * duress is configured and slot B exists, so the inline snapshot can ship it1574 * to the receiver. Returns null otherwise. Never decrypts.1575 */1576export async function exportDuressSlotForAdoption(): Promise<string | null> {1577  if (!(await isDuressEnabled())) return null;1578  const slotB = await loadSlot(SLOT_B);1579  if (!slotB) return null;1580  return JSON.stringify(slotB);1581}15821583/**1584 * Adoption RECEIVER helper: installs a duress slot received from a source1585 * device. Validates the StoredVault shape (same guard as unlockFromCodex)1586 * and writes it to slot B. Throws on invalid shape so the caller can treat1587 * it as a non-fatal snapshot failure. Never decrypts.1588 */1589export async function installAdoptedDuressSlot(json: string): Promise<void> {1590  let parsed: StoredVault;1591  try {1592    const raw = JSON.parse(json) as unknown;1593    if (!hasValidVaultSchema(raw)) {1594      throw new Error("invalid_duress_slot_shape");1595    }1596    parsed = raw;1597  } catch (err) {1598    if (err instanceof SyntaxError) {1599      throw new Error("invalid_duress_slot_shape");1600    }1601    throw err;1602  }1603  await writeSlot(SLOT_B, parsed);1604}16051606/**1607 * Restore the vault from a parsed Peach Codex payload.1608 *1609 * The supplied passphrase is verified against the Codex's slotA *before* any1610 * storage mutation — wrong passphrases return without touching disk. On1611 * success, the current vault state is snapshotted (reason: "restore") so the1612 * user can revert via Settings -> Vault History, then slotA/slotB are written1613 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A1614 * regular `unlock()` follows so the rest of the SW sees the standard1615 * post-unlock state.1616 *1617 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex1618 * printed by a Pro user must always be restorable, even on a free device.1619 *1620 * Timing is not held constant here (in contrast to `unlock`): the Codex flow1621 * is an explicit user action with a known passphrase, not a passive lock1622 * screen, so the duress-vs-real timing side channel does not apply.1623 */1624export async function unlockFromCodex(params: {1625  slotA: string; // JSON string of StoredVault for slot A1626  slotB: string | null; // JSON string of StoredVault for slot B, or null1627  timestamp: number;1628  masterPassphrase: string;1629}): Promise<1630  | { ok: true; entryCount: number }1631  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1632> {1633  let parsedSlotA: StoredVault;1634  let parsedSlotB: StoredVault | null = null;1635  try {1636    const rawA = JSON.parse(params.slotA) as unknown;1637    if (!hasValidVaultSchema(rawA)) {1638      return { ok: false, reason: "invalid_slot_format" };1639    }1640    parsedSlotA = rawA;1641    if (params.slotB !== null) {1642      const rawB = JSON.parse(params.slotB) as unknown;1643      if (!hasValidVaultSchema(rawB)) {1644        return { ok: false, reason: "invalid_slot_format" };1645      }1646      parsedSlotB = rawB;1647    }1648  } catch {1649    return { ok: false, reason: "invalid_slot_format" };1650  }16511652  // Verify the passphrase against the supplied slotA WITHOUT writing anything.1653  // If this fails the on-disk vault is left completely untouched.1654  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1655  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1656  if (!trialPayload) {1657    zeroKey(trialKey);1658    return { ok: false, reason: "wrong_passphrase" };1659  }1660  // Drop the trial key; `unlock()` below derives its own copy. Holding two1661  // copies of the master key in memory at once would be a needless surface.1662  zeroKey(trialKey);16631664  // Snapshot the about-to-be-overwritten on-disk state so the user can revert1665  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.1666  await takeVaultSnapshot("restore");16671668  await writeSlot(SLOT_A, parsedSlotA);1669  if (parsedSlotB) {1670    await writeSlot(SLOT_B, parsedSlotB);1671  } else {1672    // Slot B must always exist with ciphertext length matching slot A1673    // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't1674    // configured); regenerate noise to satisfy the invariant.1675    await writeSlot(1676      SLOT_B,1677      generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1678    );1679  }1680  await chrome.storage.local.set({1681    peach_last_modified_at: String(params.timestamp),1682  });16831684  // Standard unlock path so domain index, settings, auto-lock, and1685  // recovery-key state all populate exactly as a normal unlock would.1686  const ok = await unlock(params.masterPassphrase);1687  if (!ok) {1688    // Should be unreachable — we just verified the passphrase against the1689    // same slotA we wrote. Treat as wrong_passphrase to keep the contract1690    // simple if some race or storage anomaly intrudes.1691    return { ok: false, reason: "wrong_passphrase" };1692  }1693  return { ok: true, entryCount: getEntries().length };1694}16951696// Returns the current in-memory derived key as a hex string. Only the PIN1697// unlock path needs this value, and it reads module state directly; this1698// accessor exists so unlockWithKey's root-key rehydration is testable.1699export /* @testable */ function getDerivedKeyHex(): string {1700  if (!derivedKey) throw new Error("vault_not_unlocked");1701  return hex(derivedKey);1702}17031704/**1705 * Returns the vault's stable root key as hex, or null when locked / not yet1706 * populated. The root key is random per-vault, generated at creation, escrowed1707 * under the recovery words, and re-wrapped (not regenerated) on every password1708 * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.1709 *1710 * This is the correct input for sync identity derivation (vault_id, the Noise1711 * NK static key, the Ed25519 manifest signing key, and per-entry envelope1712 * keys): paired devices keep the same identity and can still decrypt each1713 * other's entries after either side rotates its master password (SEV-2-B).1714 *1715 * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED1716 * instead of crashing; rootKey is populated on every supported real-mode1717 * unlock via deriveRootKeyFromDerivedKey.1718 */1719export function getRootKeyHex(): string | null {1720  return rootKey ? hex(rootKey) : null;1721}17221723export function getEntries(): VaultEntry[] {1724  if (!derivedKey) throw new Error("vault_locked");1725  return entries.filter((entry) => !entry.deletedAt);1726}17271728const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;17291730export function getIdentities(): SavedIdentity[] {1731  if (!derivedKey) throw new Error("vault_locked");1732  const active = entries.filter((e) => !e.deletedAt && e.type === "login");17331734  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1735  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();17361737  for (const entry of active) {1738    const domain = extractDomain(entry.url ?? "") ?? "";1739    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;17401741    function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1742      const existing = map.get(v);1743      if (!existing || modifiedAt > existing.lastUsed) {1744        map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1745      } else {1746        map.set(v, { ...existing, count: existing.count + 1 });1747      }1748    }17491750    const uVal = entry.username?.trim();1751    if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);17521753    // Also count the dedicated email field — entries created via the inline1754    // popup new-entry form store email here rather than in username.1755    const eVal = entry.email?.trim();1756    if (eVal) tally(emailMap, eVal);1757  }17581759  const toIdentity = (type: "email" | "username") =>1760    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1761      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1762    });17631764  return [1765    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1766    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1767  ];1768}17691770export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1771  if (!derivedKey) throw new Error("vault_locked");17721773  const now = Date.now();1774  const added: VaultEntry[] = [];1775  for (const draft of drafts) {1776    try {1777      const sanitized = sanitizeEntryDraft(draft);1778      const nextEntry: VaultEntry = {1779        ...sanitized,1780        id: crypto.randomUUID(),1781        createdAt: now,1782        modifiedAt: monotonicEntryTimestamp(),1783      };1784      entries.push(nextEntry);1785      added.push(nextEntry);1786    } catch {1787      // Skip invalid drafts silently — caller can see diff between input and output length1788    }1789  }17901791  if (added.length > 0) {1792    await persistEntries();1793  }17941795  return added;1796}17971798/**1799 * Restore a set of entries in full fidelity — preserves id, createdAt,1800 * modifiedAt, and every other field unlike `batchAddEntries` which1801 * assigns fresh identity. Used by restore paths where the backup is the1802 * authoritative source of truth. `mode` controls how entries already in1803 * the vault are treated.1804 */1805export async function restoreEntries(1806  incoming: VaultEntry[],1807  mode: "replace" | "merge"1808): Promise<{ added: number; updated: number; skipped: number }> {1809  if (!derivedKey) throw new Error("vault_locked");18101811  // A replace with zero incoming entries is almost certainly a caller bug1812  // (e.g. an import that silently dropped everything). Refuse rather than1813  // wipe the vault.1814  if (mode === "replace" && incoming.length === 0) {1815    throw new Error("refuse to replace with empty entry set");1816  }18171818  let added = 0;1819  let updated = 0;1820  let skipped = 0;18211822  if (mode === "replace") {1823    entries = [];1824    for (const entry of incoming) {1825      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1826      if (!normalized) {1827        skipped += 1;1828        continue;1829      }1830      entries.push(normalized);1831      if (normalized.deletedAt) {1832        tombstones.set(1833          normalized.id,1834          Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),1835        );1836      } else {1837        tombstones.delete(normalized.id);1838      }1839      added += 1;1840    }1841  } else {1842    const byId = new Map<string, VaultEntry>();1843    for (const e of entries) byId.set(e.id, e);18441845    for (const entry of incoming) {1846      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1847      if (!normalized) {1848        skipped += 1;1849        continue;1850      }1851      const existing = byId.get(normalized.id);1852      if (!existing) {1853        entries.push(normalized);1854        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1855        else tombstones.delete(normalized.id);1856        added += 1;1857      } else if (normalized.modifiedAt > existing.modifiedAt) {1858        const idx = entries.findIndex((e) => e.id === existing.id);1859        if (idx !== -1) {1860          entries[idx] = normalized;1861          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1862          else tombstones.delete(normalized.id);1863          updated += 1;1864        }1865      } else {1866        skipped += 1;1867      }1868    }1869  }18701871  await persistEntries("import");1872  return { added, updated, skipped };1873}18741875/**1876 * A Peach entry id is a UUID. A restored or imported file carries whatever id1877 * its author used, and no reader bounds it, so one oversized id would ride1878 * into every future sync frame and be rejected there — a permanent, opaque1879 * failure on both platforms, caused by data that was already local.1880 *1881 * The action is mint, not reject: this is the last point where an id is still1882 * ours to choose, so the worst outcome is one duplicate if the same vault is1883 * later restored beside a device still holding the old id. Rejecting would1884 * lose the user's import.1885 *1886 * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries1887 * also calls. Sync has to adopt a peer's id verbatim — that is the only thing1888 * keeping two devices agreed on which entry they are discussing — so reminting1889 * there would fork identity on every device, on every round.1890 */1891const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;18921893function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {1894  if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;1895  return { ...entry, id: crypto.randomUUID() };1896}18971898/**1899 * Validate + normalise a VaultEntry coming from a backup, import, or sync1900 * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies1901 * only known VaultEntry fields (unknown/attacker keys are dropped), and1902 * nested `passkey`/`canary` shapes are rebuilt field-by-field, never1903 * spread. Returns null for values the live vault shouldn't accept.1904 */1905function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1906  if (!entry || typeof entry !== "object") return null;1907  const title = typeof entry.title === "string" ? entry.title.trim() : "";1908  if (!title) return null;1909  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1910  const now = Date.now();1911  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1912  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;19131914  // normalizeEntry whitelists every known VaultEntry field before the entry1915  // reaches the live vault.1916  const normalized = normalizeEntry({1917    ...entry,1918    id,1919    title,1920    type: entry.type ?? "login",1921    createdAt,1922    modifiedAt,1923  });19241925  if (normalized.passkey) {1926    normalized.passkey = sanitizePasskey(normalized.passkey);1927  }1928  if (normalized.canary) {1929    normalized.canary = sanitizeCanary(normalized.canary);1930  }1931  // PCH-01-016: bound tags on the restore path too, so both import funnels agree.1932  normalized.tags = sanitizeTags(normalized.tags);19331934  // Login entries from backup must carry a password (or be passkey-only,1935  // in which case the passkey sub-object replaces the password requirement).1936  if (1937    normalized.type === "login" &&1938    !normalized.passkey &&1939    typeof normalized.password !== "string"1940  ) {1941    return null;1942  }1943  if (entry.passkey && !normalized.passkey) {1944    // Entry claimed a passkey but the shape didn't validate — refuse the1945    // whole entry rather than silently importing a credential-less husk.1946    return null;1947  }19481949  return normalized;1950}19511952/**1953 * Rebuild a PasskeyData object from untrusted input, field by field.1954 * Returns undefined when required fields are missing/mistyped — a rogue1955 * passkey credential must not activate via import (MV-6 attack scenario).1956 */1957function sanitizePasskey(pk: unknown): PasskeyData | undefined {1958  if (!pk || typeof pk !== "object") return undefined;1959  const r = pk as Record<string, unknown>;1960  if (1961    typeof r.rpId !== "string" || !r.rpId ||1962    typeof r.credentialId !== "string" || !r.credentialId ||1963    typeof r.publicKeyCose !== "string" ||1964    typeof r.userHandle !== "string" ||1965    typeof r.userName !== "string"1966  ) {1967    return undefined;1968  }1969  return {1970    rpId: r.rpId,1971    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1972    credentialId: r.credentialId,1973    publicKeyCose: r.publicKeyCose,1974    privateKeyJwk:1975      r.privateKeyJwk && typeof r.privateKeyJwk === "object"1976        ? (r.privateKeyJwk as JsonWebKey)1977        : undefined,1978    userHandle: r.userHandle,1979    userName: r.userName,1980    userDisplayName:1981      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1982    algorithm: r.algorithm === -8 ? -8 : -7,1983    signCount: typeof r.signCount === "number" ? r.signCount : 0,1984    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1985    backupEligible: r.backupEligible === true,1986    backupState: r.backupState === true,1987    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1988    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1989    transports: Array.isArray(r.transports)1990      ? r.transports.filter((t): t is string => typeof t === "string")1991      : undefined,1992    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1993  };1994}19951996/**1997 * Rebuild a Canary object from untrusted input. The webhook must be a1998 * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing1999 * exfiltration endpoint that the canary subsystem will POST entry titles2000 * to (MV-6 attack scenario). Invalid → the canary is dropped, not the2001 * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire2002 * time (isAllowedCanaryWebhookUrl), so this import-time check is the2003 * outer of two gates.2004 */2005function sanitizeCanary(c: unknown): Canary | undefined {2006  if (!c || typeof c !== "object") return undefined;2007  const r = c as Record<string, unknown>;2008  if (typeof r.webhookUrl !== "string") return undefined;2009  let url: URL;2010  try {2011    url = new URL(r.webhookUrl);2012  } catch {2013    return undefined;2014  }2015  if (url.protocol !== "https:") return undefined;2016  return {2017    webhookUrl: url.toString(),2018    label: typeof r.label === "string" ? r.label : undefined,2019    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2020    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2021  };2022}20232024/**2025 * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted.2026 * Keep only non-empty strings, cap each at 256 chars, and cap the count at 2552027 * (the same per-entry ceiling the export codec enforces, peachPayload.ts).2028 * Non-array or all-invalid input collapses to undefined.2029 */2030function sanitizeTags(tags: unknown): string[] | undefined {2031  if (!Array.isArray(tags)) return undefined;2032  const out: string[] = [];2033  for (const t of tags) {2034    if (typeof t !== "string") continue;2035    const trimmed = t.trim();2036    if (!trimmed) continue;2037    out.push(trimmed.slice(0, 256));2038    if (out.length >= 255) break;2039  }2040  return out.length > 0 ? out : undefined;2041}20422043// Soft-deletes every non-deleted entry in one call. The literal string2044// argument is a tripwire against accidental calls — any caller that would2045// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this2046// to a boolean.2047export async function clearVaultEntries(2048  confirmation: "REPLACE_ALL"2049): Promise<void> {2050  if (confirmation !== "REPLACE_ALL") {2051    throw new Error("clearVaultEntries requires explicit confirmation");2052  }2053  if (!derivedKey) throw new Error("vault_locked");2054  for (const entry of entries) {2055    if (!entry.deletedAt) {2056      const stamp = monotonicEntryTimestamp(Math.max(2057        entry.modifiedAt,2058        tombstones.get(entry.id) ?? 0,2059      ));2060      entry.deletedAt = stamp;2061      entry.modifiedAt = stamp;2062      tombstones.set(entry.id, stamp);2063    }2064  }2065  await persistEntries();2066}20672068export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2069  if (!derivedKey) throw new Error("vault_locked");20702071  const sanitized = sanitizeEntryDraft(entry);2072  const now = Date.now();2073  const stamp = monotonicEntryTimestamp();2074  const nextEntry: VaultEntry = {2075    ...sanitized,2076    id: crypto.randomUUID(),2077    createdAt: now,2078    modifiedAt: stamp,2079    // Stamp passwordChangedAt at creation so age tracking is accurate from2080    // day one. Non-login entries carry the same stamp harmlessly.2081    passwordChangedAt: sanitized.password ? stamp : undefined,2082  };20832084  // Clone-and-replace so a failed persist rolls back the in-memory state2085  // (COR-008). Without this, a quota-full or storage error leaves the2086  // module-level `entries` ahead of what's actually on disk.2087  const rollback = entries;2088  entries = [...entries, nextEntry];2089  try {2090    await persistEntries();2091  } catch (err) {2092    entries = rollback;2093    throw err;2094  }20952096  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)2097  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2098    checkBreach(nextEntry.password).then(async (count) => {2099      if (count > 0) {2100        const idx = entries.findIndex((e) => e.id === nextEntry.id);2101        if (idx !== -1) {2102          entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2103          await persistEntries();2104        }2105      }2106    });2107  }21082109  fireSyncTrigger("vault_change");21102111  return nextEntry;2112}21132114/**2115 * Run the passkey-merge pass on the live in-memory entries and persist if2116 * anything changed. Called immediately after any passkey registration so that2117 * a same-domain TOTP-only entry already in the vault gets consolidated right2118 * away instead of waiting until the next lock/unlock cycle.2119 */2120export async function consolidatePasskeyEntries(): Promise<void> {2121  if (!derivedKey) return;2122  const { merged } = mergePasskeyOnlyIntoLogins(entries);2123  if (merged) {2124    await persistEntries();2125  }2126}21272128export async function updateEntry(2129  id: string,2130  patch: Partial<VaultEntry>2131): Promise<void> {2132  if (!derivedKey) throw new Error("vault_locked");21332134  const idx = entries.findIndex((entry) => entry.id === id);2135  if (idx === -1 || entries[idx].deletedAt) {2136    throw new Error("entry_not_found");2137  }21382139  const current = entries[idx];2140  const sanitizedPatch = sanitizeEntryPatch(patch);2141  // Refresh passwordChangedAt only when the password value actually changes —2142  // a metadata-only update (title, tags, etc.) must not reset the age clock2143  // that drives the Pro health rotation recommendation.2144  const passwordChanged =2145    "password" in patch &&2146    typeof sanitizedPatch.password === "string" &&2147    sanitizedPatch.password !== current.password;2148  const stamp = monotonicEntryTimestamp(current.modifiedAt);2149  // When the password actually changes, push the outgoing one onto the history2150  // (newest first) so the user can reveal/copy/restore it later. We record the2151  // age it carried (passwordChangedAt, falling back to createdAt) rather than2152  // `now`, so the list reflects when each password was last in effect. Only2153  // capture a non-empty prior value — clearing a password leaves no secret to2154  // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)2155  // to avoid noise.2156  const priorPassword = current.password;2157  const shouldCapture =2158    passwordChanged &&2159    typeof priorPassword === "string" &&2160    priorPassword.length > 0 &&2161    current.passwordHistory?.[0]?.password !== priorPassword;2162  const nextHistory = shouldCapture2163    ? [2164        { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2165        ...(current.passwordHistory ?? []),2166      ].slice(0, PASSWORD_HISTORY_MAX)2167    : current.passwordHistory;2168  entries[idx] = {2169    ...current,2170    ...sanitizedPatch,2171    modifiedAt: stamp,2172    ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2173  };2174217521762177  // Card entries must retain their card number2178  if (entries[idx].type === "card" && !entries[idx].cardNumber) {2179    entries[idx] = current;2180    throw new Error("card number is required for card entries");2181  }21822183  // Roll the in-memory entry back to its pre-edit state if the disk write2184  // fails (e.g. storage quota). Without this the module-level `entries` array2185  // would hold an edit that never landed on disk, diverging from the vault2186  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.2187  try {2188    await persistEntries();2189  } catch (err) {2190    entries[idx] = current;2191    throw err;2192  }21932194  // Breach check when password changes: clear immediately so old breach doesn't linger,2195  // then async-check the new password and only re-set if it's also found.2196  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2197    entries[idx] = { ...entries[idx], breach: undefined };2198    await persistEntries();2199    const password = entries[idx].password;2200    if (password) {2201      checkBreach(password).then(async (count) => {2202        const i = entries.findIndex((e) => e.id === id);2203        if (i !== -1 && count > 0) {2204          entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2205          await persistEntries();2206        }2207      });2208    }2209  }22102211  fireSyncTrigger("vault_change");2212}22132214export async function deleteEntry(id: string): Promise<void> {2215  if (!derivedKey) throw new Error("vault_locked");22162217  const entry = entries.find((item) => item.id === id);2218  if (!entry || entry.deletedAt) {2219    throw new Error("entry_not_found");2220  }22212222  // Tombstone timestamp must be strictly greater than the entry's last2223  // modification so the deletion wins LWW against any in-flight edit, even2224  // under clock skew (SEV-2-E).2225  const previousModifiedAt = entry.modifiedAt;2226  const previousTombstone = tombstones.get(id);2227  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2228  entry.deletedAt = stamp;2229  entry.modifiedAt = stamp;2230  tombstones.set(id, stamp);2231  // Roll the tombstone back if the disk write fails, so a quota error doesn't2232  // leave the entry deleted in memory but alive on disk (SEV-3-A).2233  try {2234    await persistEntries();2235  } catch (err) {2236    delete entry.deletedAt;2237    entry.modifiedAt = previousModifiedAt;2238    if (previousTombstone === undefined) tombstones.delete(id);2239    else tombstones.set(id, previousTombstone);2240    throw err;2241  }2242  fireSyncTrigger("vault_change");2243}22442245export function getDeletedEntries(): VaultEntry[] {2246  if (!derivedKey) return [];2247  return entries.filter((e) => !!e.deletedAt);2248}22492250/** Return all durable deletion timestamps, including purged full entries. */2251export function getTombstoneManifest(): Record<string, number> {2252  if (!derivedKey) return {};2253  return Object.fromEntries(tombstones);2254}22552256/**2257 * Returns the vault's monotonic version — the `peach_last_modified_at` value2258 * stamped on every user-initiated write. Remote (sync) writes do NOT advance2259 * this version so a sync round never cascades into a second outbound sync.2260 */2261export async function getVaultVersion(): Promise<number> {2262  try {2263    const raw = await chrome.storage.local.get("peach_last_modified_at");2264    const v = (raw as Record<string, unknown>).peach_last_modified_at;2265    if (typeof v === "number") return v;2266    if (typeof v === "string" && v !== "") {2267      const n = Number.parseInt(v, 10);2268      return Number.isFinite(n) ? n : 0;2269    }2270    return 0;2271  } catch {2272    return 0;2273  }2274}22752276/**2277 * Apply a batch of remote entries + tombstones from a sync peer.2278 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when2279 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and2280 * persists with reason `sync_received` — does NOT fire the sync dispatcher, so2281 * applying a sync round cannot cascade into another outbound sync.2282 *2283 * `applied` counts entries that actually changed local state (new add,2284 * overwrite-by-newer, or new tombstone). `received` is the raw payload size2285 * the caller can surface in the sync result UI.2286 */2287export async function applyMergedRemoteEntries(2288  remoteEntries: VaultEntry[],2289  remoteTombstones: Record<string, number>,2290  _context: { deviceName?: string } = {},2291): Promise<{ received: number; applied: number; conflicts: number }> {2292  // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices2293  // concurrently edit different fields of the same entry, the one with the2294  // newer modifiedAt wins the whole entry and the other device's field edits2295  // are dropped silently. This is the deliberate trade-off for a simple,2296  // conflict-free merge — per-field CRDT merge is out of scope. Guidance for2297  // users: edit a given entry on one device at a time.2298  if (!derivedKey) throw new Error("vault_locked");22992300  const received = remoteEntries.length + Object.keys(remoteTombstones).length;2301  let applied = 0;2302  let conflicts = 0;23032304  const byId = new Map<string, VaultEntry>();2305  for (const e of entries) byId.set(e.id, e);23062307  const mergeNow = Date.now();2308  const remoteTombstoneTimes = new Map<string, number>();2309  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2310    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2311      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2312    }2313  }23142315  for (const remote of remoteEntries) {2316    const sanitized = sanitizeRestoredEntry(remote);2317    if (!sanitized) continue;2318    // HV-1a: a far-future remote modifiedAt must not permanently pin a2319    // malicious value against honest local edits. Clamp BEFORE any2320    // comparison against local timestamps.2321    sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2322    if (typeof sanitized.deletedAt === "number") {2323      sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2324    }2325    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2326    const localTombstoneTs = tombstones.get(sanitized.id);2327    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2328    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2329      continue;2330    }2331    const local = byId.get(sanitized.id);2332    if (!local) {2333      entries.push(sanitized);2334      byId.set(sanitized.id, sanitized);2335      tombstones.delete(sanitized.id);2336      applied += 1;2337    } else if (sanitized.modifiedAt > local.modifiedAt) {2338      const idx = entries.findIndex((e) => e.id === local.id);2339      if (idx !== -1) {2340        entries[idx] = sanitized;2341        byId.set(sanitized.id, sanitized);2342        tombstones.delete(sanitized.id);2343        applied += 1;2344        if (!local.deletedAt) conflicts += 1;2345      }2346    }2347  }23482349  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2350    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2351    // HV-1a/1c: clamp the tombstone timestamp before every comparison.2352    const deletedAt = remoteTombstoneTimes.get(id)!;2353    const knownTombstone = tombstones.get(id);2354    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2355    const local = byId.get(id);2356    if (!local) {2357      tombstones.set(id, deletedAt);2358      applied += 1;2359      continue;2360    }2361    if (local.deletedAt && local.deletedAt >= deletedAt) continue;2362    if (deletedAt < local.modifiedAt) continue;2363    const idx = entries.findIndex((e) => e.id === id);2364    if (idx !== -1) {2365      entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2366      tombstones.set(id, deletedAt);2367      applied += 1;2368    }2369  }23702371  if (applied > 0) {2372    await persistEntries("sync_received");2373    buildDomainIndexSync(entries);2374  }2375  return { received, applied, conflicts };2376}23772378export async function restoreEntry(id: string): Promise<void> {2379  if (!derivedKey) throw new Error("vault_locked");2380  const entry = entries.find((e) => e.id === id && e.deletedAt);2381  if (!entry) throw new Error("deleted entry not found");2382  const previousDeletedAt = entry.deletedAt;2383  const previousModifiedAt = entry.modifiedAt;2384  const previousTombstone = tombstones.get(id);2385  delete entry.deletedAt;2386  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2387    entry.modifiedAt,2388    previousTombstone ?? 0,2389  ));2390  tombstones.delete(id);2391  try {2392    await persistEntries();2393  } catch (err) {2394    entry.deletedAt = previousDeletedAt;2395    entry.modifiedAt = previousModifiedAt;2396    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2397    throw err;2398  }2399  buildDomainIndexSync(entries);2400}24012402export async function purgeEntry(id: string): Promise<void> {2403  if (!derivedKey) throw new Error("vault_locked");2404  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2405  if (idx === -1) throw new Error("deleted entry not found");2406  const entry = entries[idx]!;2407  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2408  entries.splice(idx, 1);2409  await persistEntries();2410}24112412/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */2413export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2414  if (!derivedKey || retentionMs === 0) return 0;2415  const cutoff = Date.now() - retentionMs;2416  const before = entries.length;2417  for (const entry of entries) {2418    if (entry.deletedAt && entry.deletedAt < cutoff) {2419      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2420    }2421  }2422  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2423  const purged = before - entries.length;2424  if (purged > 0) await persistEntries();2425  return purged;2426}24272428export function queryDomain(domain: string): VaultEntry[] {2429  return domainIndex.get(domain) ?? [];2430}24312432export function getEntryById(id: string): VaultEntry | undefined {2433  return entries.find((entry) => entry.id === id && !entry.deletedAt);2434}24352436async function persistSlotA(2437  reason: VaultSnapshot["reason"] = "local_save",2438): Promise<StoredVault> {2439  if (!derivedKey) throw new Error("vault_locked");24402441  const slotA = await loadSlot(SLOT_A);2442  if (!slotA) throw new Error("missing slot A");24432444  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures2445  // are swallowed by takeVaultSnapshot — history must never block a save.2446  await takeVaultSnapshot(reason);24472448  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2449  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");24502451  // Preserve existing recovery escrow fields.2452  const nextSlotA: StoredVault = {2453    ...slotA,2454    nonce,2455    ciphertext,2456    tag,2457    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot2458  };2459  await writeSlot(SLOT_A, nextSlotA);24602461  // Slot A (the real vault) is now committed. Wrap every later write so a2462  // mid-persist failure (e.g. a storage error after slot A landed) restores the2463  // prior slot A before rethrowing. Without this, a "failed" save that already2464  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/2465  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces2466  // as a duplicate on the next unlock (observed under storage-quota pressure).2467  try {2468    // When duress is not configured, keep slot B size-matched with slot A by2469    // regenerating its noise. When duress IS configured, leave slot B alone —2470    // we don't have the decoy key to re-encrypt it. This is a known trade-off.2471    if (!duressConfigured) {2472      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2473    }2474    // Only advance the vault version for locally-originated writes. Entries2475    // applied from a sync peer ("sync_received") must NOT bump the counter —2476    // otherwise applying a remote round makes us look newer than the peer and2477    // triggers a redundant outbound sync, defeating the no-op optimisation2478    // and risking a ping-pong between two devices (SEV-2-C).2479    if (reason !== "sync_received") {2480      await stampLocalVaultMutation();2481    }2482  } catch (err) {2483    // Best-effort restore — the prior slot A is smaller-or-equal, so this write2484    // fits even when the failure was a quota overflow.2485    await writeSlot(SLOT_A, slotA).catch(() => {});2486    throw err;2487  }24882489  return nextSlotA;2490}24912492async function persistSlotB(): Promise<void> {2493  if (!derivedKey) throw new Error("vault_locked");24942495  const slotB = await loadSlot(SLOT_B);2496  if (!slotB) throw new Error("missing slot B");24972498  // Decoy plaintext always reports duressConfigured=false — the decoy user2499  // sees a clean slate. persistSlotB only runs in decoy mode, which only2500  // exists once duress is configured, so pad to the fixed duress ceiling to2501  // keep slot B's length equal to slot A's (audit MEDIUM — deniability).2502  const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2503  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");25042505  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2506  // Symmetric with persistSlotA: if the version stamp fails after the decoy2507  // slot committed, restore the prior slot B so a failed decoy-mode save can't2508  // leave disk ahead of the in-memory rollback.2509  try {2510    await stampLocalVaultMutation();2511  } catch (err) {2512    await writeSlot(SLOT_B, slotB).catch(() => {});2513    throw err;2514  }2515}25162517async function persistEntries(2518  reason: VaultSnapshot["reason"] = "local_save",2519): Promise<void> {2520  if (!derivedKey) throw new Error("vault_locked");25212522  // Reconcile direct soft-delete callers while restoring the previous map if2523  // storage rejects the save.2524  const previousTombstones = tombstones;2525  tombstones = new Map(tombstones);2526  reconcileTombstones();2527  try {2528    if (vaultMode === "decoy") {2529      await persistSlotB();2530    } else {2531      await persistSlotA(reason);2532    }2533  } catch (err) {2534    tombstones = previousTombstones;2535    throw err;2536  }2537  buildDomainIndexSync(entries);2538  await refreshLockedSyncState();2539}25402541// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage2542// boundary. Schemeless values (e.g. "github.com") are inert and kept — they2543// pre-date this guard in existing data. Unparseable values are kept: the2544// reviewed render paths never execute them (favicons via _favicon/, domains2545// via tldts); this is defense-in-depth against future sinks.2546function allowHttpUrl(url: string | undefined): string {2547  const trimmed = url?.trim() ?? "";2548  if (!trimmed) return "";2549  try {2550    const parsed = new URL(trimmed);2551    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2552  } catch {2553    // No scheme to reject.2554  }2555  return trimmed;2556}25572558function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2559  if (!Array.isArray(values)) return undefined;2560  const seen = new Set<string>();2561  const cleaned: string[] = [];2562  for (const value of values) {2563    const trimmed = typeof value === "string" ? value.trim() : "";2564    if (!trimmed) continue;2565    if (!allowHttpUrl(trimmed)) continue;2566    const key = trimmed.toLowerCase();2567    if (seen.has(key)) continue;2568    seen.add(key);2569    cleaned.push(trimmed);2570  }2571  return cleaned.length > 0 ? cleaned : undefined;2572}25732574function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2575  const title = entry.title.trim();2576  const type: VaultEntry["type"] = entry.type ?? "login";25772578  if (!title) throw new Error("title is required");25792580  if (entry.passkey) {2581    const p = entry.passkey;2582    if (2583      !p.rpId ||2584      !p.credentialId ||2585      !p.publicKeyCose ||2586      !p.userHandle ||2587      !p.userName ||2588      (p.algorithm !== -7 && p.algorithm !== -8)2589    ) {2590      throw new Error("invalid passkey credential");2591    }2592  }2593  if (type === "card") {2594    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2595      throw new Error("cardholder name and card number are required");2596    }2597  }25982599  return {2600    type,2601    title,2602    username: type === "login" ? entry.username?.trim() ?? "" : undefined,2603    email: type === "login" ? entry.email?.trim() || undefined : undefined,2604    password: type === "login" ? entry.password : undefined,2605    url: type === "login" ? allowHttpUrl(entry.url) : undefined,2606    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2607    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2608    // PCH-01-019: preserve non-default TOTP params through batch-add/import so2609    // they are not silently stripped (they fall back to SHA-1/6/30 when absent).2610    totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2611    totpDigits: type === "login" ? entry.totpDigits : undefined,2612    totpPeriod: type === "login" ? entry.totpPeriod : undefined,2613    notes: entry.notes?.trim() || undefined,2614    breach: type === "login" ? entry.breach : undefined,2615    // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags2616    // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here.2617    canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2618    tags: sanitizeTags(entry.tags),2619    passkey: entry.passkey,2620    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2621    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2622    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2623    expYear: type === "card" ? entry.expYear?.trim() : undefined,2624    cvc: type === "card" ? entry.cvc?.trim() : undefined,2625    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2626    network: type === "card" ? entry.network : undefined,2627    last4: type === "card" ? entry.last4?.trim() : undefined,2628  };2629}26302631function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2632  const nextPatch: Partial<VaultEntry> = {};26332634  if ("type" in patch) {2635    nextPatch.type = patch.type;2636  }26372638  if ("title" in patch) {2639    const title = patch.title?.trim() ?? "";2640    if (!title) throw new Error("title is required");2641    nextPatch.title = title;2642  }26432644  if ("url" in patch) {2645    nextPatch.url = allowHttpUrl(patch.url);2646  }26472648  if ("additionalUrls" in patch) {2649    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2650    const seen = new Set<string>();2651    const cleaned: string[] = [];2652    for (const value of incoming) {2653      const trimmed = typeof value === "string" ? value.trim() : "";2654      if (!trimmed || !allowHttpUrl(trimmed)) continue;2655      const key = trimmed.toLowerCase();2656      if (seen.has(key)) continue;2657      seen.add(key);2658      cleaned.push(trimmed);2659    }2660    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2661  }26622663  if ("username" in patch) {2664    nextPatch.username = patch.username?.trim() ?? "";2665  }26662667  if ("email" in patch) {2668    nextPatch.email = patch.email?.trim() || undefined;2669  }26702671  if ("password" in patch) {2672    const password = patch.password ?? "";2673    // Allow clearing a password (e.g. on passkey-only entries)2674    nextPatch.password = password || undefined;2675  }26762677  if ("totp" in patch) {2678    nextPatch.totp = patch.totp?.trim() || undefined;2679  }26802681  if ("notes" in patch) {2682    nextPatch.notes = patch.notes?.trim() || undefined;2683  }26842685  if ("breach" in patch) {2686    nextPatch.breach = patch.breach;2687  }26882689  if ("canary" in patch) {2690    // PCH-02-001: route update patches through the same canary whitelist used2691    // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.2692    nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2693  }26942695  if ("passkey" in patch) {2696    // A patch with `passkey: undefined` removes the credential from the entry2697    // (used by "remove passkey" without deleting the whole entry).2698    if (patch.passkey === undefined || patch.passkey === null) {2699      nextPatch.passkey = undefined;2700    } else {2701      const sanitized = sanitizePasskey(patch.passkey);2702      if (!sanitized) throw new Error("invalid passkey credential");2703      nextPatch.passkey = sanitized;2704    }2705  }27062707  if ("tags" in patch) {2708    // PCH-02-001: bound tags exactly like both import funnels.2709    nextPatch.tags = sanitizeTags(patch.tags);2710  }27112712  if ("cardholderName" in patch) {2713    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2714  }2715  if ("cardNumber" in patch) {2716    const n = patch.cardNumber ?? "";2717    nextPatch.cardNumber = n || undefined;2718  }2719  if ("expMonth" in patch) {2720    nextPatch.expMonth = patch.expMonth?.trim() || undefined;2721  }2722  if ("expYear" in patch) {2723    nextPatch.expYear = patch.expYear?.trim() || undefined;2724  }2725  if ("cvc" in patch) {2726    nextPatch.cvc = patch.cvc?.trim() || undefined;2727  }2728  if ("cardNickname" in patch) {2729    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2730  }2731  if ("network" in patch) {2732    nextPatch.network = patch.network;2733  }2734  if ("last4" in patch) {2735    nextPatch.last4 = patch.last4?.trim() || undefined;2736  }27372738  return nextPatch;2739}27402741function normalizeEntry(entry: VaultEntry): VaultEntry {2742  const base: VaultEntry = {2743    id: entry.id,2744    type: entry.type,2745    title: entry.title,2746    username: entry.username,2747    email: entry.email,2748    password: entry.password,2749    url: entry.url,2750    additionalUrls:2751      "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2752        ? entry.additionalUrls2753        : undefined,2754    totp: entry.totp,2755    // PCH-01-019: carry TOTP params through the normalize/restore path too.2756    totpAlgorithm: entry.totpAlgorithm,2757    totpDigits: entry.totpDigits,2758    totpPeriod: entry.totpPeriod,2759    notes: entry.notes,2760    breach: entry.breach,2761    // Preserve the password-age clock across lock/unlock. Without this the2762    // field is dropped on every decode and the advanced-health "aged password"2763    // signal silently resets to createdAt on each unlock (SEV-2-A).2764    passwordChangedAt:2765      "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2766    // Preserve past-password history across lock/unlock. normalizeEntry is a2767    // field whitelist, so an unlisted field is silently dropped on every decode2768    // (same class as the passwordChangedAt SEV-2-A above).2769    passwordHistory:2770      "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2771        ? entry.passwordHistory2772        : undefined,2773    canary: entry.canary,2774    passkey: entry.passkey,2775    createdAt: entry.createdAt,2776    modifiedAt: entry.modifiedAt,2777    deletedAt: entry.deletedAt,2778    tags: entry.tags,2779    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2780    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2781    expMonth: "expMonth" in entry ? entry.expMonth : undefined,2782    expYear: "expYear" in entry ? entry.expYear : undefined,2783    cvc: "cvc" in entry ? entry.cvc : undefined,2784    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2785    network: "network" in entry ? entry.network : undefined,2786    last4: "last4" in entry ? entry.last4 : undefined,2787  };27882789  return base;2790}279127922793// ---------------------------------------------------------------------------2794// Deduplication2795// ---------------------------------------------------------------------------27962797function entryDupeKey(e: VaultEntry): string {2798  if (e.type === "note") return `note|${e.title.toLowerCase()}`;2799  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins2800  // on different sites that happen to share a username are still distinct;2801  // two passkey credentials for the same RP+user are duplicates regardless2802  // of any login fields they may also carry.2803  if (e.passkey) {2804    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2805  }2806  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2807}28082809/** Returns the number of duplicate entries (those that would be removed). */2810export function countDuplicates(): number {2811  const best = new Map<string, VaultEntry>();2812  let dupeCount = 0;2813  for (const e of entries.filter((e) => !e.deletedAt)) {2814    const key = entryDupeKey(e);2815    const existing = best.get(key);2816    if (!existing) {2817      best.set(key, e);2818    } else {2819      dupeCount++;2820      // Keep the more recently modified one in the map2821      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2822        best.set(key, e);2823      }2824    }2825  }2826  return dupeCount;2827}28282829/**2830 * Soft-deletes duplicate entries, keeping the most recently modified copy of2831 * each group. Returns the number of entries moved to trash.2832 */2833export async function deduplicateEntries(): Promise<number> {2834  if (!derivedKey) throw new Error("vault_locked");2835  // Dedup reshapes slot B permanently under the decoy key, which is2836  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.2837  if (vaultMode === "decoy") {2838    throw new Error("deduplicate disabled in decoy mode");2839  }28402841  const best = new Map<string, VaultEntry>();2842  for (const e of entries.filter((en) => !en.deletedAt)) {2843    const key = entryDupeKey(e);2844    const existing = best.get(key);2845    if (!existing) {2846      best.set(key, e);2847    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2848      best.set(key, e);2849    }2850  }28512852  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2853  let removed = 0;2854  entries = entries.map((e) => {2855    if (!e.deletedAt && !keepIds.has(e.id)) {2856      removed++;2857      const deletedAt = monotonicEntryTimestamp(Math.max(2858        e.modifiedAt,2859        tombstones.get(e.id) ?? 0,2860      ));2861      tombstones.set(e.id, deletedAt);2862      return { ...e, deletedAt };2863    }2864    return e;2865  });28662867  if (removed > 0) await persistEntries();2868  return removed;2869}28702871// ---------------------------------------------------------------------------2872// Vault history — snapshot BEFORE every vault write so the saved blob is2873// what is about to be lost, not what was just written. Capped at 5. The2874// stored blobs are AES-256-GCM ciphertext under the master password, so2875// history adds zero security surface.2876// ---------------------------------------------------------------------------28772878const HISTORY_KEY = "peach_vault_history";2879const HISTORY_MAX = 5;28802881// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the2882// plaintext snapshot field cannot reveal exact vault size or single-entry change2883// deltas. Buckets widen as the vault grows so absolute leakage stays roughly2884// proportional rather than exact.2885function bucketEntryCount(n: number): number {2886  if (n <= 0) return 0;2887  if (n <= 10) return 10;2888  if (n <= 25) return 25;2889  if (n <= 50) return 50;2890  if (n <= 100) return 100;2891  if (n <= 250) return 250;2892  if (n <= 500) return 500;2893  return Math.ceil(n / 500) * 500;2894}28952896/**2897 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE2898 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost2899 * state. Identical-to-most-recent blobs are skipped to avoid filling history2900 * with no-op writes.2901 */2902export async function takeVaultSnapshot(2903  reason: VaultSnapshot["reason"],2904): Promise<void> {2905  try {2906    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2907    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2908    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];29092910    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,2911    // pre-createVault). createVault skips snapshots intentionally — there is2912    // no prior state to preserve.2913    if (!slotA) return;29142915    const vault_a = JSON.stringify(slotA);2916    const vault_b = JSON.stringify(slotB ?? slotA);29172918    // Best-effort entry count: only meaningful when the in-memory state is2919    // unlocked AND in real mode. Decoy mode would leak the decoy entry2920    // count; locked mode has no entries to count. Both fall through to null.2921    // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage2922    // .local sits next to the AES-GCM blobs in plaintext, so an exact count would2923    // leak vault size and per-write deltas (the metadata padding hides this).2924    let entryCount: number | null = null;2925    if (derivedKey && vaultMode === "real") {2926      try {2927        entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2928      } catch {2929        entryCount = null;2930      }2931    }29322933    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2934    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2935      ? existingRaw2936      : { snapshots: [] };29372938    // Deduplication: if the most recent snapshot has byte-identical blobs,2939    // skip — saves the same vault N times in a row would otherwise burn the2940    // entire history window with no recovery value.2941    const latest = history.snapshots[0];2942    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2943      return;2944    }29452946    const snapshot: VaultSnapshot = {2947      id: crypto.randomUUID(),2948      savedAt: Date.now(),2949      reason,2950      vault_a,2951      vault_b,2952      entryCount,2953    };29542955    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2956    await chrome.storage.local.set({ [HISTORY_KEY]: history });2957  } catch (err) {2958    // Non-fatal — history must never block a save. A failed snapshot just2959    // means the user loses one rung of the safety ladder for this write.2960    // M4/QUA-006: log so the failure is visible; if it looks like a quota2961    // problem, notify the popup so the user can prune history themselves.2962    console.warn('[Peach] takeVaultSnapshot failed:', err);2963    if (String(err).includes('QUOTA') || String(err).includes('quota')) {2964      try {2965        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2966      } catch {2967        // popup may be closed; surfaced via console.warn above2968      }2969    }2970  }2971}29722973/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */2974export async function clearVaultHistory(): Promise<void> {2975  try {2976    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2977  } catch {2978    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().2979  }2980}29812982/** Read all snapshots, newest first, for the Settings vault history view. */2983export async function getVaultHistory(): Promise<VaultSnapshot[]> {2984  const raw = await chrome.storage.local.get(HISTORY_KEY);2985  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2986  if (!history || !Array.isArray(history.snapshots)) return [];2987  return history.snapshots;2988}29892990/**2991 * Replace the on-disk vault with the chosen snapshot's blobs and lock the2992 * vault. The current state is itself snapshotted with reason 'restore' first2993 * so the restore is reversible. After this returns, the user must unlock2994 * with the password that matches the restored blob.2995 */2996export async function restoreVaultSnapshot(2997  snapshotId: string,2998): Promise<{ ok: true } | { ok: false; error: string }> {2999  const history = await getVaultHistory();3000  const snapshot = history.find((s) => s.id === snapshotId);3001  if (!snapshot) return { ok: false, error: "snapshot_not_found" };30023003  // Snapshot the about-to-be-replaced current state so the restore is3004  // reversible. takeVaultSnapshot is no-op when the most recent entry is3005  // byte-identical, so a restore-of-a-restore stays clean.3006  await takeVaultSnapshot("restore");30073008  let restoredA: StoredVault;3009  let restoredB: StoredVault;3010  try {3011    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3012    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3013  } catch {3014    return { ok: false, error: "snapshot_corrupt" };3015  }30163017  await writeSlot(SLOT_A, restoredA);3018  await writeSlot(SLOT_B, restoredB);3019  await stampLocalVaultMutation();30203021  // Lock so the user must prove they can decrypt the restored blob. If they3022  // restored a pre-password-change snapshot they discover it immediately at3023  // the lock screen rather than silently holding an inaccessible vault.3024  lock();30253026  return { ok: true };3027}30283029/**3030 * Rotate the vault's sync identity by generating a FRESH ROOT KEY and3031 * re-wrapping everything under it (MV-8). Because every sync credential —3032 * vault_id, the Noise static keypair, the Ed25519 manifest signing key,3033 * and the per-entry envelope keys — derives from the root key, rotation3034 * makes a lost/stolen device's retained key material useless. All paired3035 * devices must re-pair afterwards.3036 *3037 * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from3038 * the root key, so rotation INVALIDATES the old recovery phrase. The flow3039 * sets peach_recovery_pending so the popup re-presents the new words for3040 * confirmation, same as the initial recovery-word confirmation path.3041 */3042export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3043  if (!derivedKey || vaultMode !== "real") {3044    return { ok: false, error: "vault_locked" };3045  }3046  const slotA = await loadSlot(SLOT_A);3047  if (!slotA) return { ok: false, error: "missing_slot_a" };30483049  // 1. Fresh root key + recovery escrow (new words).3050  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3051    await generateAndWrapRootKey();30523053  // 2. Re-wrap the new root key under the current password-derived key.3054  const newRootKey = unhex(rawRootKey);3055  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);30563057  // 3. Snapshot, then single atomic slot write — both escrow wrappings of3058  //    the new root key land together (same transactional rule as3059  //    changePassword). Entries are untouched: they are sealed under the3060  //    password-derived key, not the root key.3061  await takeVaultSnapshot("local_save");3062  const nextSlotA: StoredVault = {3063    ...slotA,3064    recovery_salt: recoverySalt,3065    recovery_wrapped_root_key: wrappedRootKey,3066    recovery_nonce: wrappedNonce,3067    recovery_tag: wrappedTag,3068    ...pwRoot,3069  };3070  await writeSlot(SLOT_A, nextSlotA);30713072  // 4. Swap in memory only after disk committed.3073  if (rootKey) zeroKey(rootKey);3074  rootKey = newRootKey;30753076  // 5. Old peers' identities derive from the dead root key — drop them all.3077  await clearPairedDevices();30783079  // 6. Force re-presentation of the NEW recovery words on next popup view.3080  await setRecoveryConfirmPending(); // same flag the create-vault flow uses30813082  await stampLocalVaultMutation();3083  return { ok: true };3084}30853086/**3087 * Change the master password. Re-encrypts slot A with a fresh salt + new3088 * derived key. Slot B (duress or noise) is left untouched when duress is3089 * configured — it uses an independent key derived from the duress password —3090 * and regenerated as noise when duress is not configured.3091 */3092export async function changePassword(3093  currentPassword: string,3094  newPassword: string3095): Promise<boolean> {3096  if (!derivedKey || vaultMode !== "real") return false;30973098  // Verify current password before allowing any change3099  const valid = await verifyMainPassword(currentPassword);3100  if (!valid) return false;31013102  const slotA = await loadSlot(SLOT_A);3103  if (!slotA) return false;3104  if (!rootKey) return false;31053106  // Derive fresh key from new password + new random salt3107  const newSalt = generateSalt();3108  const newKey = await deriveKey(newPassword, newSalt);31093110  // Re-encrypt the in-memory entries with the new key3111  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3112  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");31133114  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey3115  // under recovery words and remain untouched — recovery phrase keeps working3116  // across password changes (H4/COR-002).3117  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);31183119  const finalSlotA: StoredVault = {3120    ...slotA,3121    salt: hex(newSalt),3122    nonce,3123    ciphertext,3124    tag,3125    ...pwRoot,3126  };31273128  // Snapshot the OLD-key blob before overwriting it. If the user later3129  // restores from this snapshot they will need the OLD password — the3130  // post-restore lock surfaces that immediately.3131  await takeVaultSnapshot("local_save");31323133  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in3134  // one storage transaction. An SW kill before this point leaves the old3135  // slot intact; after this point everything tied to the new password is3136  // already on disk together (M6/COR-004).3137  await writeSlot(SLOT_A, finalSlotA);31383139  // Swap the in-memory key synchronously before any further await. If we3140  // threw between the Slot A write and this swap, the next persistEntries()3141  // would overwrite Slot A with old-key ciphertext, locking the user out.3142  zeroKey(derivedKey);3143  derivedKey = newKey;31443145  // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,3146  // the next service-worker restore decrypts the new-key Slot A with the stale3147  // key, fails tryDecryptSlotResult, and silently drops the user to a lock3148  // screen. Re-escrow the new key so restore survives a password change.3149  void saveSessionKey(newKey, "real");31503151  // Regenerate noise for slot B when duress is not in use. Runs after the3152  // key swap so a failure here is a disposable noise regeneration, not a3153  // lockout.3154  if (!duressConfigured) {3155    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3156  }3157  await clearPinUnlockStorage();3158  await stampLocalVaultMutation();31593160  return true;3161}