vault.ts

120.2 KB · raw

1/**2 * vault.ts — sole owner of vault state and chrome.storage vault keys.3 *4 * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5 * goes through this module. No other file accesses those keys directly.6 * External callers reach vault operations via `messages.ts` — never by7 * importing from here cross-boundary (popup/content must not). Invariants8 * for the two-slot schema, unlock timing, and key/disk atomicity are in9 * Projects.md §3.2–§3.6.10 */1112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";16// Static, not `await import()`: runtime import() throws in an MV3 service17// worker. Neither module imports vault.ts back, so there is no cycle to break.18import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21  rootKeyToWords,22  wordsToRootKeyMaterial,23  deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26  AccentColorName,27  Canary,28  ManualSummonRecord,29  PasskeyData,30  SavedIdentity,31  StoredVault,32  VaultEntry,33  VaultHistory,34  VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import {39  clearRecoveryConfirmPending,40  setRecoveryConfirmPending,41} from "./recovery-pending";4243type EntryDraft = Omit<44  VaultEntry,45  "id" | "createdAt" | "modifiedAt" | "deletedAt"46>;4748// Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B49// either holds the decoy vault (when duress is configured) or random noise50// that looks identical at rest. No external indicator is stored — duress51// configuration is recorded only inside the encrypted payload of slot A.52const SLOT_A = "peach_vault_a";53const SLOT_B = "peach_vault_b";5455// Plaintext is padded to the next multiple of this size before encryption.56// This coarsens ciphertext-length leakage into 1 KiB buckets.57const PAD_BLOCK = 1024;5859// How many past passwords to retain per entry. Generous enough to be useful as60// a recovery aid while bounding the secret material we hold (and the ciphertext61// growth) for an entry whose password is rotated frequently.62const PASSWORD_HISTORY_MAX = 50;6364// When duress is configured, BOTH slots are padded to this fixed ceiling so65// their ciphertext lengths are identical and cannot reveal that a hidden decoy66// vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably67// holds a very large vault (thousands of entries incl. passkeys). The decoy68// slot can't be re-encrypted on every real-vault save (the real session has no69// decoy key), so a fixed ceiling — rather than size-matching on each write — is70// what keeps the two slots equal. A real vault whose plaintext exceeds this71// would grow past the ceiling and re-expose the bit; that is an extreme,72// documented edge well beyond normal use.73const DURESS_SLOT_SIZE = 262_144;7475let derivedKey: Uint8Array | null = null;76// 32-byte random root key, separate from the derivedKey used for vault encryption.77// The root key is wrapped (encrypted) under the recovery key derived from the78// 12-word phrase. When the user changes their password, only the wrapping changes;79// the root key and recovery words remain stable.80let rootKey: Uint8Array | null = null;81let entries: VaultEntry[] = [];82// Tombstones outlive their full entry records so a peer that was offline83// during a purge cannot reintroduce an old ciphertext envelope. This map is84// serialized only inside the encrypted vault payload.85let tombstones = new Map<string, number>();86let domainIndex: Map<string, VaultEntry[]> = new Map();87let vaultMode: "real" | "decoy" = "real";88// Cached post-unlock view of whether the real vault records that duress is89// configured. Only meaningful after a real-mode unlock; for decoy-mode unlock90// this stays false because we never see slot A's plaintext.91let duressConfigured = false;9293// Set to a corruption reason during the last unlock() attempt when both slots94// decrypted but failed schema validation. Consumed and cleared by95// consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted96// distinctly from wrong-password (M7/COR-007).97//98// NOTE: module-level state — if vault.ts is ever moved to a worker context,99// this must be replaced with a structured return value from unlock() or a100// postMessage channel. The signal is invisible across module-instance101// boundaries.102let lastUnlockFailureReason: string | null = null;103104export function consumeLastUnlockFailureReason(): string | null {105  const r = lastUnlockFailureReason;106  lastUnlockFailureReason = null;107  return r;108}109110// ---------------------------------------------------------------------------111// Settings (persisted inside the encrypted vault store as a sidecar key)112// ---------------------------------------------------------------------------113114export type TextSize = "small" | "medium" | "large";115116export interface VaultSettings {117  accentColor?: AccentColorName;118  theme?: "dark" | "light";119  autoLockMinutes?: number;120  clipboardClearSeconds?: number;121  lockOnClose?: boolean;122  breachMonitoring?: boolean;123  passkeyProviderEnabled?: boolean;124  trashRetentionDays?: number;125  manualSummonDomains?: Record<string, ManualSummonRecord>;126  manualSummonSalt?: string;127  declinedCardFingerprints?: string[];128129  /** Metadata is bounded because it remains outside the encrypted vault blob. */130  metadataVersion?: 1;131  /**132   * Text size scale for body copy and entry titles. Stops at "large" by133   * design — XL clips dense screens (Vault, Health) inside the fixed134   * 330×480 popup surface.135   */136  textSize?: TextSize;137  /**138   * When true, all entrance/exit transitions and ambient animations are139   * collapsed to instant. Honoured alongside the user's OS-level140   * `prefers-reduced-motion`.141   */142  reduceMotion?: boolean;143  /** Whether this vault has a local PIN unlock wrapper configured. */144  pinEnabled?: boolean;145  /** Whether the lock screen should open on PIN instead of the password. */146  pinPreferred?: boolean;147  /**148   * User overrides for keyboard shortcuts. Keys are ShortcutAction names149   * (see src/shared/shortcuts.ts). Missing actions fall through to150   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the151   * canonical lookup. Storing only the deltas keeps the vault payload152   * small and lets default updates land for users who never rebind.153   */154  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;155}156157interface StoredPinWrap {158  version: 1;159  salt: string;160  nonce: string;161  ciphertext: string;162  tag: string;163}164165const PIN_WRAP_STORAGE = "peach_pin_wrap";166const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";167const PIN_MIN_LENGTH = 4;168const PIN_MAX_LENGTH = 12;169const PIN_MAX_ATTEMPTS = 3;170171let settings: VaultSettings = {};172const CARD_DECLINE_MAX = 256;173/** Hex length of a keyed card-decline digest. Legacy records are 16 hex. */174const CARD_DECLINE_DIGEST_HEX = 32;175176async function metadataDigest(value: string, key: string): Promise<string> {177  const bytes = new TextEncoder().encode(`${key}:${value}`);178  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));179  return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");180}181182let inMemoryPinWrap: StoredPinWrap | null = null;183let inMemoryPinFailedAttempts = 0;184let sessionPinStorageFailed = false;185186export function getSettings(): VaultSettings {187  return settings;188}189190export async function ensureSettingsLoaded(): Promise<VaultSettings> {191  await loadSettings();192  return settings;193}194195export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {196  await loadSettings();197  // LV-7: cheap insurance against prototype pollution if a future caller ever198  // feeds parsed external JSON here (native spread keeps __proto__ an own-199  // property today, but Object.assign-style consumers and JSON revivers have200  // been burned before).201  const safe = Object.fromEntries(202    Object.entries(patch).filter(203      ([k]) => !["__proto__", "constructor", "prototype"].includes(k),204    ),205  ) as Partial<VaultSettings>;206  settings = { ...settings, ...safe };207  await persistSettings();208}209210function isValidPin(pin: string): boolean {211  return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);212}213214/** Set up a local PIN wrapper after proving the current master password. */215export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {216  if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;217  if (!(await verifyMainPassword(currentPassword))) return false;218219  const salt = generateSalt();220  const pinKey = await deriveKey(pin, salt);221  try {222    const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");223    await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });224    await updateSettings({ pinEnabled: true });225    return true;226  } finally {227    zeroKey(pinKey);228  }229}230231async function clearPinUnlockStorage(): Promise<void> {232  inMemoryPinWrap = null;233  inMemoryPinFailedAttempts = 0;234  sessionPinStorageFailed = false;235  if (chrome.storage.session) {236    await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);237  }238  await updateSettings({ pinEnabled: false });239}240241/** Remove the PIN wrapper after proving the current master password. */242export async function disablePinUnlock(currentPassword: string): Promise<boolean> {243  if (!derivedKey || vaultMode !== "real") return false;244  if (!(await verifyMainPassword(currentPassword))) return false;245  await clearPinUnlockStorage();246  return true;247}248249/** Unlock the real vault from its locally stored PIN-wrapped derived key. */250export async function unlockWithPin(pin: string): Promise<boolean> {251  if (!isValidPin(pin)) return false;252  await loadSettings();253  if (!settings.pinEnabled) return false;254255  const { stored, failedAttempts } = await loadPinState();256  if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;257  if (258    !stored ||259    stored.version !== 1 ||260    !stored.salt ||261    !stored.nonce ||262    !stored.ciphertext ||263    !stored.tag264  ) return false;265266  const pinKey = await deriveKey(pin, unhex(stored.salt));267  let unwrappedKey: Uint8Array | null = null;268  try {269    const masterKeyHex = await decrypt(270      pinKey,271      stored.nonce,272      stored.ciphertext,273      stored.tag,274      "pin_wrap_v1",275    );276    if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;277    unwrappedKey = unhex(masterKeyHex);278    const result = await unlockWithKey(masterKeyHex);279    if (!result.ok) return false;280    await saveSessionKey(unwrappedKey, "real");281    await resetPinFailedAttempts();282    return true;283  } catch {284    await recordPinFailure(failedAttempts + 1);285    return false;286  } finally {287    zeroKey(pinKey);288    if (unwrappedKey) zeroKey(unwrappedKey);289  }290}291292async function savePinWrap(stored: StoredPinWrap): Promise<void> {293  inMemoryPinWrap = stored;294  inMemoryPinFailedAttempts = 0;295  sessionPinStorageFailed = !chrome.storage.session;296  if (!chrome.storage.session) return;297  try {298    await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });299    sessionPinStorageFailed = false;300  } catch {301    // Firefox fallback: keep the wrapper only in this background context.302    sessionPinStorageFailed = true;303  }304}305306async function loadPinState(): Promise<{307  stored: StoredPinWrap | undefined;308  failedAttempts: number;309}> {310  if (!chrome.storage.session) {311    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };312  }313  try {314    const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);315    return {316      stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??317        (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),318      failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"319        ? result[PIN_ATTEMPTS_STORAGE]320        : 0,321    };322  } catch {323    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };324  }325}326327async function recordPinFailure(failedAttempts: number): Promise<void> {328  inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);329  if (!chrome.storage.session) return;330  try {331    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });332  } catch { /* in-memory counter remains the fallback */ }333}334335async function resetPinFailedAttempts(): Promise<void> {336  inMemoryPinFailedAttempts = 0;337  if (!chrome.storage.session) return;338  try {339    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });340  } catch { /* in-memory counter remains the fallback */ }341}342343export async function getPinUnlockStatus(): Promise<{344  configured: boolean;345  available: boolean;346  attemptsRemaining: number;347}> {348  await loadSettings();349  if (!settings.pinEnabled) {350    return { configured: false, available: false, attemptsRemaining: 0 };351  }352  const { stored, failedAttempts } = await loadPinState();353  const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;354  return {355    configured: true,356    available,357    attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,358  };359}360361async function persistSettings(): Promise<void> {362  try {363    await chrome.storage.local.set({364      peach_settings: {365        ...settings,366        manualSummonDomains: undefined,367        manualSummonSalt: undefined,368        cardFingerprintSalt: undefined,369        declinedCardFingerprints: settings.declinedCardFingerprints ?? [],370        metadataVersion: 1,371      },372    });373  } catch (err) {374    throw mapStorageError(err);375  }376}377378function mapStorageError(err: unknown): Error {379  const message = err instanceof Error ? err.message : String(err);380  if (/QUOTA_BYTES|quota/i.test(message)) {381    return new Error("STORAGE_QUOTA_EXCEEDED");382  }383  return err instanceof Error ? err : new Error(message);384}385386async function loadSettings(): Promise<void> {387  const result = await chrome.storage.local.get("peach_settings");388  settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};389}390391// Fire-and-forget sync trigger after a committed vault write. A failure here392// must never fail the write that caused it.393function fireSyncTrigger(reason: "vault_change"): void {394  void Promise.resolve()395    .then(() => triggerSyncIfReachable({ reason }))396    .catch(() => {397      /* no sync runner registered, or nothing reachable — non-fatal */398    });399}400401let lockedSyncRefreshHook: (() => Promise<void>) | null = null;402let lockedSyncUnlockHook: (() => Promise<void>) | null = null;403404export function setLockedSyncHooks(hooks: {405  refresh: () => Promise<void>;406  afterUnlock: () => Promise<void>;407}): void {408  lockedSyncRefreshHook = hooks.refresh;409  lockedSyncUnlockHook = hooks.afterUnlock;410}411412async function refreshLockedSyncState(): Promise<void> {413  if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;414  await lockedSyncRefreshHook().catch((error) => {415    console.warn("[Peach sync] locked spool refresh failed:", error);416  });417}418419// ---------------------------------------------------------------------------420// HIBP breach check (k-anonymity model)421// ---------------------------------------------------------------------------422423export async function checkBreach(password: string): Promise<number> {424  // HIBP k-anonymity API is keyed on SHA-1 prefixes — this is dictated by425  // the public API, not a security choice. Do not migrate to SHA-256 here.426  const encoder = new TextEncoder();427  const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));428  const hashHex = Array.from(new Uint8Array(hashBuffer))429    .map((b) => b.toString(16).padStart(2, "0"))430    .join("")431    .toUpperCase();432433  const prefix = hashHex.slice(0, 5);434  const suffix = hashHex.slice(5);435436  try {437    // PCH-02-007: request HIBP response padding so response length does not438    // reveal how many suffixes a queried prefix returned.439    const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {440      headers: { "Add-Padding": "true" },441    });442    if (!res.ok) return 0;443    const text = await res.text();444    const match = text.split("\r\n").find((l) => l.startsWith(suffix));445    return match ? parseInt(match.split(":")[1], 10) : 0;446  } catch {447    return 0; // Network unavailable — don't block448  }449}450451export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {452  // Unreachable from the message layer, which gates on isUnlocked first.453  const key = await cardDeclineKey();454  if (!key) return;455  const fingerprint = await cardFingerprint(last4, expMonth, expYear);456  await loadSettings();457  settings = {458    ...settings,459    declinedCardFingerprints: [460      // Legacy records predate the keyed digest and are silently dropped here:461      // they are 16 hex characters rather than 32, and are no longer accepted.462      ...(settings.declinedCardFingerprints ?? []).filter(463        (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,464      ),465      await metadataDigest(fingerprint, key),466    ].slice(-CARD_DECLINE_MAX),467  };468  await persistSettings();469}470471export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {472  await loadSettings();473  const key = await cardDeclineKey();474  if (!key) return false;475  const fingerprint = await cardFingerprint(last4, expMonth, expYear);476  const candidate = await metadataDigest(fingerprint, key);477  return (settings.declinedCardFingerprints ?? []).some(478    (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,479  );480}481482/**483 * Keying material for the declined-card digest, derived from the vault root484 * key so it is available only while unlocked and never written to storage.485 *486 * PB-26-108: the previous scheme stored a random salt beside the records. A487 * salt defeats precomputed tables, not an attacker holding the file — the488 * candidate space here is ~10^4 last-four values x 12 months x a few years, so489 * a stored salt left the card's last four and expiry recoverable offline in490 * seconds. `peach_settings` is unencrypted and directly readable by a content491 * script on Firefox, so the key has to live outside storage entirely.492 */493async function cardDeclineKey(): Promise<string | null> {494  const root = getRootKeyHex();495  if (!root) return null;496  const digest = new Uint8Array(497    await crypto.subtle.digest(498      "SHA-256",499      new TextEncoder().encode(`peach-card-decline-v1:${root}`),500    ),501  );502  return hex(digest);503}504505async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {506  const encoder = new TextEncoder();507  const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));508  return Array.from(new Uint8Array(buf).slice(0, 8))509    .map((b) => b.toString(16).padStart(2, "0"))510    .join("");511}512513function hex(buf: Uint8Array): string {514  return Array.from(buf)515    .map((b) => b.toString(16).padStart(2, "0"))516    .join("");517}518519function unhex(s: string): Uint8Array {520  const out = new Uint8Array(s.length / 2);521  for (let i = 0; i < s.length; i += 2) {522    out[i / 2] = parseInt(s.slice(i, i + 2), 16);523  }524  return out;525}526527// ---------------------------------------------------------------------------528// Root key generation and recovery word wrapping529// ---------------------------------------------------------------------------530531/**532 * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key533 * derived from the recovery word material via HKDF-SHA256. The wrapped blob534 * fields are stored in slot A alongside the password-wrapped ciphertext. The535 * root key itself is kept in memory for the session so getRecoveryWords() can536 * encode it without re-deriving.537 *538 * SECURITY MODEL (PCH-01-005): the recovery KEK is derived from the 12-word539 * material, which encodes only rootKey[0..16] (recovery-words.ts). The KEK is thus540 * derived from a PREFIX of the very plaintext it wraps, and effective recovery541 * security is 128-bit (standard for a 12-word phrase), NOT 256-bit. This is safe —542 * unwrapping requires the words, and the GCM ciphertext leaks nothing without them —543 * but it is intentional, not an oversight. A future escrow version wanting 256-bit544 * recovery must derive the words from INDEPENDENT entropy, not from the root key.545 */546async function generateAndWrapRootKey(): Promise<{547  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey548  recoverySalt: string;549  wrappedRootKey: string;550  wrappedNonce: string;551  wrappedTag: string;552}> {553  const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));554555  // Derive the recovery key from the BIP39 words encoding of the root key.556  const words = await rootKeyToWords(rootKeyBytes);557  const wordMaterial = await wordsToRootKeyMaterial(words);558  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));559  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);560561  // Wrap the root key with the recovery key using AES-256-GCM directly.562  // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.563  const nonce = crypto.getRandomValues(new Uint8Array(12));564  const encoded = new TextEncoder().encode(hex(rootKeyBytes));565  const raw = await crypto.subtle.encrypt(566    {567      name: "AES-GCM",568      iv: nonce,569      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,570      tagLength: 128,571    },572    recoveryKey,573    encoded574  );575  // WebCrypto returns ciphertext || auth tag concatenated; split them.576  const rawU8 = new Uint8Array(raw);577  const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));578  const tag = hex(rawU8.slice(rawU8.length - 16));579580  return {581    rawRootKey: hex(rootKeyBytes),582    recoverySalt: hex(recoverySalt),583    wrappedRootKey: ciphertext,584    wrappedNonce: hex(nonce),585    wrappedTag: tag,586  };587}588589async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{590  recoverySalt: string;591  wrappedRootKey: string;592  wrappedNonce: string;593  wrappedTag: string;594}> {595  if (rootKeyBytes.length !== 32) {596    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");597  }598599  const words = await rootKeyToWords(rootKeyBytes);600  const wordMaterial = await wordsToRootKeyMaterial(words);601  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));602  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);603  const nonce = crypto.getRandomValues(new Uint8Array(12));604  const encoded = new TextEncoder().encode(hex(rootKeyBytes));605  const raw = await crypto.subtle.encrypt(606    {607      name: "AES-GCM",608      iv: nonce,609      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,610      tagLength: 128,611    },612    recoveryKey,613    encoded,614  );615  const rawU8 = new Uint8Array(raw);616  return {617    recoverySalt: hex(recoverySalt),618    wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),619    wrappedNonce: hex(nonce),620    wrappedTag: hex(rawU8.slice(rawU8.length - 16)),621  };622}623624/**625 * Unwrap the root key using recovery words. Called during recovery flow when626 * the user provides their 12-word phrase and a new password.627 * Exported for use by the RECOVER_WITH_WORDS message handler.628 */629export async function unwrapRootKeyForRecovery(630  wrappedRootKey: string,631  wrappedNonce: string,632  wrappedTag: string,633  recoverySaltHex: string,634  words: string[]635): Promise<void> {636  const wordMaterial = await wordsToRootKeyMaterial(words);637  const recoverySalt = unhex(recoverySaltHex);638  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);639640  // Decrypt and cache the root key. The root key bytes were hex-encoded before641  // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt642  // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.643  // Each component is stored separately in the vault slot; decode them individually644  // so the nonce is always exactly the 12 bytes from wrappedNonce.645  const nonceRaw = unhex(wrappedNonce);646  const ciphertextRaw = unhex(wrappedRootKey);647  const tagRaw = unhex(wrappedTag);648  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).649  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.650  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;651  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);652  combined.set(ciphertextRaw, 0);653  combined.set(tagRaw, ciphertextRaw.length);654  const raw = await crypto.subtle.decrypt(655    { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },656    recoveryKey,657    combined658  );659  const decryptedHex = new TextDecoder().decode(raw);660  rootKey = unhex(decryptedHex);661}662663/**664 * Re-wrap the cached root key under a new derived key (from the user's new665 * password). Called after password change and after recovery re-key so the666 * same recovery words continue to work with the updated password — and so667 * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey.668 *669 * recovery_* fields are intentionally not touched: those wrap rootKey under670 * the recovery words, which do not change when the password changes.671 */672export async function rewrapRootKeyForPassword(673  newDerivedKey: Uint8Array,674  _newSalt: Uint8Array675): Promise<void> {676  if (!rootKey) throw new Error("root key not set");677  const slotA = await loadSlot(SLOT_A);678  if (!slotA) throw new Error("missing slot A");679  const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);680  await writeSlot(SLOT_A, { ...slotA, ...pwRoot });681}682683/**684 * Change the vault encryption to a new derived key (already derived from685 * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.686 * Caller is responsible for key swap and snapshot.687 */688async function reencryptVaultForNewKey(689  newKey: Uint8Array,690  slotA: StoredVault691): Promise<void> {692  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);693  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");694  await writeSlot(SLOT_A, {695    ...slotA,696    nonce,697    ciphertext,698    tag,699  });700}701702/**703 * Full password change with an already-derived key (used after recovery).704 * Unlike changePassword(), this skips re-verification of current password705 * since recovery flow has already validated the user's identity via words.706 */707export async function changePasswordWithNewKey(708  newSalt: Uint8Array,709  newKey: Uint8Array710): Promise<void> {711  const slotA = await loadSlot(SLOT_A);712  if (!slotA) throw new Error("missing slot A");713  // reencryptVaultForNewKey must receive the updated salt so that subsequent714  // UNLOCK calls with the new password can re-derive the correct key.715  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });716  if (derivedKey) zeroKey(derivedKey);717  derivedKey = newKey;718  // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow719  // reaches this path, so a post-recovery SW restart would otherwise lock out720  // a user who just reset their password.721  void saveSessionKey(newKey, "real");722  // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works723  // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow724  // should have populated it — fall back silently rather than blowing up the725  // recovery path; the escrow is already present on every supported vault.726  if (rootKey) {727    const slotAfter = await loadSlot(SLOT_A);728    if (slotAfter) {729      const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);730      await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });731    }732  }733  if (!duressConfigured) {734    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));735  }736  await clearPinUnlockStorage();737  await stampLocalVaultMutation();738}739740// ---------------------------------------------------------------------------741// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive742// a new key after password reset without re-importing the crypto module.743// ---------------------------------------------------------------------------744745export { deriveKey, generateSalt };746747export async function loadSlotA(): Promise<StoredVault | null> {748  return loadSlot(SLOT_A);749}750751/**752 * Recover the session root key by AES-GCM-decrypting the pw_root_* fields753 * (a second wrapping of the root key under the password-derived key). This754 * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without755 * the user re-entering their recovery phrase: the same root key bytes —756 * and therefore the same 12 words — survive every unlock.757 *758 * Returns null when the escrow is absent or decryption fails.759 */760async function deriveRootKeyFromDerivedKey(761  derivedKeyBytes: Uint8Array,762  slotA: StoredVault,763): Promise<Uint8Array | null> {764  if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {765    return null;766  }767  try {768    const rootHex = await decrypt(769      derivedKeyBytes,770      slotA.pw_root_nonce,771      slotA.pw_root_ciphertext,772      slotA.pw_root_tag,773      "pw_root_v1",774    );775    return unhex(rootHex);776  } catch {777    return null;778  }779}780781// AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the782// hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.783async function wrapRootKeyForDerivedKey(784  derivedKeyBytes: Uint8Array,785  rootKeyBytes: Uint8Array,786): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {787  const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");788  return {789    pw_root_ciphertext: ciphertext,790    pw_root_nonce: nonce,791    pw_root_tag: tag,792  };793}794795/** Returns the 12 recovery words for the current session's root key. */796export async function getRecoveryWords(): Promise<string[]> {797  if (!rootKey) {798    throw new Error("recovery not set up");799  }800  return rootKeyToWords(rootKey);801}802803async function loadSlot(key: string): Promise<StoredVault | null> {804  const result = await chrome.storage.local.get(key);805  return (result as Record<string, StoredVault | undefined>)[key] ?? null;806}807808async function writeSlot(key: string, vault: StoredVault): Promise<void> {809  try {810    await chrome.storage.local.set({ [key]: vault });811  } catch (err) {812    throw mapStorageError(err);813  }814}815816/**817 * Roll back a vault that was created by [createVaultFromRootKey] but818 * couldn't be paired (e.g. the subsequent license activation, identity819 * derivation, or paired-device upsert failed). Without this, a failed820 * receiver session leaves behind an empty vault — the user has to821 * re-pair to get a working setup, but the empty vault sits in the way.822 *823 * The call is idempotent: removing keys that aren't there is a no-op.824 */825export async function rollbackAdoptedVault(): Promise<void> {826  await lock();827  try {828    await chrome.storage.local.remove([829      SLOT_A,830      SLOT_B,831      "peach_last_modified_at",832    ]);833  } catch (err) {834    throw mapStorageError(err);835  }836}837838async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {839  const raw = await chrome.storage.local.get("peach_last_modified_at");840  const previous = Number(841    (raw as Record<string, unknown>).peach_last_modified_at,842  );843  const next = Math.max(844    Date.now(),845    Number.isFinite(previous) ? previous + 1 : 0,846  );847  try {848    await chrome.storage.local.set({849      [SLOT_A]: slotA,850      [SLOT_B]: slotB,851      peach_last_modified_at: String(next),852    });853  } catch (err) {854    throw mapStorageError(err);855  }856}857858// Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the859// sync layer's last-write-wins merge against wall-clock regressions (NTP step,860// manual clock change, DST glitch): a backwards Date.now() must never produce861// an entry timestamp that's <= the entry's existing modifiedAt, or a genuine862// local edit would lose to a stale remote copy. The returned value is always863// strictly greater than both the entry's current modifiedAt and the previous864// call's result (SEV-2-E).865let lastEntryTimestamp = 0;866function monotonicEntryTimestamp(currentModifiedAt?: number): number {867  const base = Math.max(868    Date.now(),869    (currentModifiedAt ?? 0) + 1,870    lastEntryTimestamp + 1,871  );872  lastEntryTimestamp = base;873  return base;874}875876async function stampLocalVaultMutation(): Promise<void> {877  const raw = await chrome.storage.local.get("peach_last_modified_at");878  const previous = Number(879    (raw as Record<string, unknown>).peach_last_modified_at,880  );881  const next = Math.max(882    Date.now(),883    Number.isFinite(previous) ? previous + 1 : 0,884  );885  await chrome.storage.local.set({886    peach_last_modified_at: String(next),887  });888}889890// ---------------------------------------------------------------------------891// Padding + noise helpers892// ---------------------------------------------------------------------------893894/**895 * Pads the plaintext string with trailing spaces so that the encoded byte896 * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing897 * whitespace after a complete document, so the padding is transparent on898 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.899 */900function padPlaintext(json: string, minBytes = 0): string {901  const encoded = new TextEncoder().encode(json);902  const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);903  // minBytes lets the duress path force a fixed ceiling so both slots match.904  const target = Math.max(bucket, minBytes);905  const padLen = target - encoded.length;906  return json + " ".repeat(padLen);907}908909/**910 * Produces a StoredVault-shaped record filled with CSPRNG bytes. The911 * ciphertext length matches the given byte count. Because GCM ciphertext is912 * pseudo-random, this blob is indistinguishable at rest from a real913 * encrypted vault without the key.914 *915 * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto916 * spec hard limit). Chunk the fill so vaults larger than that — common once917 * the user has dozens of entries or any passkey — don't throw.918 */919const CSPRNG_CHUNK = 65_536;920921function fillRandom(buf: Uint8Array): Uint8Array {922  for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {923    const end = Math.min(offset + CSPRNG_CHUNK, buf.length);924    const chunk = new Uint8Array(end - offset);925    crypto.getRandomValues(chunk);926    buf.set(chunk, offset);927  }928  return buf;929}930931function generateNoiseBlob(ciphertextBytes: number): StoredVault {932  const salt = crypto.getRandomValues(new Uint8Array(16));933  const nonce = crypto.getRandomValues(new Uint8Array(12));934  const cipher = fillRandom(new Uint8Array(ciphertextBytes));935  const tag = crypto.getRandomValues(new Uint8Array(16));936  return {937    version: 1,938    salt: hex(salt),939    nonce: hex(nonce),940    ciphertext: hex(cipher),941    tag: hex(tag),942  };943}944945// ---------------------------------------------------------------------------946// Encrypted plaintext envelope947// ---------------------------------------------------------------------------948949type VaultPayload = {950  entries: VaultEntry[];951  tombstones?: Record<string, number>;952  duressConfigured?: boolean;953};954955type DecryptedPayload = {956  entries: VaultEntry[];957  tombstones: Map<string, number>;958  duressConfigured: boolean;959};960961function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {962  const envelope: VaultPayload = {963    entries,964    tombstones: Object.fromEntries(tombstones),965    duressConfigured: duress,966  };967  return padPlaintext(JSON.stringify(envelope), minBytes);968}969970function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {971  const result = new Map<string, number>();972  if (raw && typeof raw === "object" && !Array.isArray(raw)) {973    for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {974      if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {975        result.set(id, timestamp);976      }977    }978  }979  // Older payloads had no ledger. Rebuild the durable portion from any980  // soft-deleted entries before they are eligible for a later purge.981  for (const entry of allEntries) {982    if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {983      continue;984    }985    if ((result.get(entry.id) ?? 0) < entry.deletedAt) {986      result.set(entry.id, entry.deletedAt);987    }988  }989  return result;990}991992function reconcileTombstones(): void {993  const byId = new Map(entries.map((entry) => [entry.id, entry]));994  for (const [id] of tombstones) {995    if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);996  }997  for (const entry of entries) {998    if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {999      if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1000        tombstones.set(entry.id, entry.deletedAt);1001      }1002    }1003  }1004}10051006function decodePayload(plain: string): DecryptedPayload {1007  const parsed = JSON.parse(plain) as VaultPayload;1008  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1009    throw new Error("invalid vault payload");1010  }1011  const normalized = parsed.entries.map(normalizeEntry);1012  return {1013    entries: normalized,1014    tombstones: decodeTombstones(parsed.tombstones, normalized),1015    duressConfigured: parsed.duressConfigured === true,1016  };1017}10181019/**1020 * Consolidate duplicate passkey records after registration.1021 *1022 * Multiple registrations for one RP can leave duplicate passkey records. This1023 * pass collapses the duplicates:1024 *1025 *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.1026 *   2. Within each group, the most recently-active credential wins (prefer1027 *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).1028 *   3. If a NON-passkey login entry exists for that RP (matched by url or1029 *      additionalUrls hostname), attach the winning passkey to it and1030 *      soft-delete every passkey-bearing entry in the group.1031 *   4. Otherwise, soft-delete every loser in the group, keeping the winner1032 *      as a passkey-only entry.1033 *1034 * Soft delete (deletedAt) rather than splice — the trash flow keeps the1035 * record around for retention and the user can restore if the heuristic1036 * picked wrong. The `merged` flag goes back to decodePayload so the1037 * unlock path persists the consolidated shape immediately.1038 */1039function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1040  let merged = false;1041  const now = Date.now();10421043  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1044  const byRpId = new Map<string, VaultEntry[]>();1045  for (const e of candidates) {1046    const rpId = e.passkey!.rpId;1047    const list = byRpId.get(rpId);1048    if (list) list.push(e);1049    else byRpId.set(rpId, [e]);1050  }10511052  for (const [rpId, group] of byRpId) {1053    if (group.length === 0) continue;1054    group.sort((a, b) => {1055      const aT =1056        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1057      const bT =1058        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1059      return bT - aT;1060    });1061    const winner = group[0]!;10621063    // Look for a NON-passkey login entry matching this RP. Use the URL1064    // hostname rather than a substring search so a "login.foo.com" entry1065    // doesn't accidentally absorb a passkey for "foo.com".1066    const loginTarget = entries.find((e) => {1067      if (e.deletedAt || e.passkey || e.type !== "login") return false;1068      if (e.url && extractDomain(e.url) === rpId) return true;1069      for (const extra of e.additionalUrls ?? []) {1070        if (extractDomain(extra) === rpId) return true;1071      }1072      return false;1073    });10741075    if (loginTarget) {1076      loginTarget.passkey = winner.passkey;1077      // A passkey entry may also carry the site's TOTP seed. Preserve it when1078      // the passkey is consolidated onto an existing login instead of1079      // soft-deleting the only record that held the 2FA configuration.1080      if (!loginTarget.totp && winner.totp) {1081        loginTarget.totp = winner.totp;1082        loginTarget.totpAlgorithm = winner.totpAlgorithm;1083        loginTarget.totpDigits = winner.totpDigits;1084        loginTarget.totpPeriod = winner.totpPeriod;1085      }1086      loginTarget.modifiedAt = now;1087      for (const dup of group) {1088        if (!dup.deletedAt) {1089          dup.deletedAt = now;1090          dup.modifiedAt = now;1091        }1092      }1093      merged = true;1094    } else if (group.length > 1) {1095      for (let i = 1; i < group.length; i += 1) {1096        const dup = group[i]!;1097        if (!dup.deletedAt) {1098          dup.deletedAt = now;1099          dup.modifiedAt = now;1100        }1101      }1102      merged = true;1103    }1104  }11051106  return { merged };1107}11081109// Validates that a `StoredVault` record on disk has the field shape we expect1110// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only1111// authenticates the static VERSION_BYTE AAD, not the `version` field on the1112// outer record — a corrupted `version: 42` slot still decrypts cleanly. This1113// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a1114// history-restore path instead of falsely reporting "wrong password".1115function hasValidVaultSchema(v: unknown): v is StoredVault {1116  if (typeof v !== "object" || v === null) return false;1117  const r = v as Record<string, unknown>;1118  return (1119    r.version === 1 &&1120    typeof r.salt === "string" &&1121    typeof r.nonce === "string" &&1122    typeof r.ciphertext === "string" &&1123    typeof r.tag === "string"1124  );1125}11261127export type TryDecryptResult =1128  | { status: "ok"; payload: DecryptedPayload }1129  | { status: "wrong_key" }1130  | { status: "corrupted"; reason: string };11311132// Exported solely for `unlockFromCodex` below, which needs to verify a1133// supplied passphrase against a slotA blob *without* committing anything to1134// storage. Keep this internal otherwise — callers crossing the messages.ts1135// boundary should always go through `unlock` / `verifyMainPassword` so the1136// constant-time invariants in Projects.md §3.4 are not bypassed.1137export async function tryDecryptSlot(1138  vault: StoredVault,1139  key: Uint8Array,1140  aadTag?: string,1141): Promise<DecryptedPayload | null> {1142  const res = await tryDecryptSlotResult(vault, key, aadTag);1143  return res.status === "ok" ? res.payload : null;1144}11451146export async function tryDecryptSlotResult(1147  vault: StoredVault,1148  key: Uint8Array,1149  aadTag?: string,1150): Promise<TryDecryptResult> {1151  if (!hasValidVaultSchema(vault)) {1152    return { status: "corrupted", reason: "invalid_slot_schema" };1153  }1154  let plain: string;1155  try {1156    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1157  } catch {1158    return { status: "wrong_key" };1159  }1160  try {1161    const parsed = JSON.parse(plain) as unknown;1162    if (typeof parsed !== "object" || parsed === null) {1163      return { status: "corrupted", reason: "invalid_payload_shape" };1164    }1165    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1166      return { status: "corrupted", reason: "invalid_payload_shape" };1167    }1168    return { status: "ok", payload: decodePayload(plain) };1169  } catch {1170    return { status: "corrupted", reason: "invalid_payload_json" };1171  }1172}11731174// ---------------------------------------------------------------------------1175// Core operations1176// ---------------------------------------------------------------------------11771178function buildDomainIndexSync(all: VaultEntry[]): void {1179  domainIndex = new Map();11801181  for (const entry of all) {1182    if (entry.deletedAt) continue;1183    if (entry.type === "note" || entry.type === "card") continue;11841185    // An entry can match multiple sites via `additionalUrls`. Index each1186    // hostname so `queryDomain` returns the entry regardless of which alias1187    // the user is currently visiting. Dedupe per-entry so a single entry1188    // never appears twice in one bucket.1189    const domains = new Set<string>();1190    const primary = extractDomain(entry.url ?? "");1191    if (primary) domains.add(primary);1192    for (const extra of entry.additionalUrls ?? []) {1193      const d = extractDomain(extra);1194      if (d) domains.add(d);1195    }1196    if (domains.size === 0) continue;11971198    for (const domain of domains) {1199      const bucket = domainIndex.get(domain) ?? [];1200      bucket.push(entry);1201      domainIndex.set(domain, bucket);1202    }1203  }1204}12051206export async function createVault(password: string): Promise<void> {1207  const salt = generateSalt();1208  derivedKey = await deriveKey(password, salt);1209  entries = [];1210  tombstones = new Map();1211  vaultMode = "real";1212  duressConfigured = false;1213  buildDomainIndexSync(entries);12141215  // Generate and wrap the root key for recovery word escrow.1216  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1217    await generateAndWrapRootKey();1218  rootKey = unhex(rawRootKey);12191220  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1221  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");12221223  // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate1224  // it without recovery words (H2/COR-006).1225  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12261227  const slotA: StoredVault = {1228    version: 1,1229    salt: hex(salt),1230    nonce,1231    ciphertext,1232    tag,1233    recovery_salt: recoverySalt,1234    recovery_wrapped_root_key: wrappedRootKey,1235    recovery_nonce: wrappedNonce,1236    recovery_tag: wrappedTag,1237    ...pwRoot,1238  };1239  await writeSlot(SLOT_A, slotA);12401241  // Matching-size noise blob for slot B, so a fresh install shows two1242  // similarly-shaped records regardless of whether duress is ever set up.1243  await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1244  await stampLocalVaultMutation();12451246  touchAutoLock();1247}12481249/**1250 * Creates a local vault from an existing vault root key received through the1251 * SAS-gated adoption protocol. The password is local to this device; it wraps1252 * the adopted root key at rest and does not need to match the source device.1253 */1254export async function createVaultFromRootKey(1255  password: string,1256  rootKeyHex: string,1257): Promise<{ vaultId: string }> {1258  if (typeof password !== "string" || password.length === 0) {1259    throw new Error("ADOPT_INVALID_PASSWORD");1260  }1261  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1262    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1263  }12641265  const rootKeyBytes = unhex(rootKeyHex);1266  if (rootKeyBytes.length !== 32) {1267    rootKeyBytes.fill(0);1268    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1269  }12701271  let nextDerivedKey: Uint8Array | null = null;1272  let sessionRootKey: Uint8Array | null = null;1273  try {1274    if ((await loadSlot(SLOT_A)) !== null) {1275      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1276    }12771278    const nextEntries: VaultEntry[] = [];1279    const nextDuressConfigured = false;1280    const vaultId = await computeVaultId(rootKeyBytes);1281    const salt = generateSalt();1282    nextDerivedKey = await deriveKey(password, salt);1283    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);12841285    sessionRootKey = new Uint8Array(rootKeyBytes);1286    const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1287    const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1288    const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1289    const slotA: StoredVault = {1290      version: 1,1291      salt: hex(salt),1292      nonce,1293      ciphertext,1294      tag,1295      recovery_salt: recovery.recoverySalt,1296      recovery_wrapped_root_key: recovery.wrappedRootKey,1297      recovery_nonce: recovery.wrappedNonce,1298      recovery_tag: recovery.wrappedTag,1299      ...pwRoot,1300    };1301    const slotB = generateNoiseBlob(ciphertext.length / 2);1302    await commitCreatedVaultSlots(slotA, slotB);13031304    const previousDerivedKey = derivedKey;1305    const previousRootKey = rootKey;1306    derivedKey = nextDerivedKey;1307    rootKey = sessionRootKey;1308    nextDerivedKey = null;1309    sessionRootKey = null;1310    entries = nextEntries;1311    tombstones = new Map();1312    vaultMode = "real";1313    duressConfigured = nextDuressConfigured;1314    buildDomainIndexSync(entries);1315    if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1316    if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1317    touchAutoLock();13181319    return { vaultId };1320  } finally {1321    rootKeyBytes.fill(0);1322    if (nextDerivedKey) zeroKey(nextDerivedKey);1323    if (sessionRootKey) sessionRootKey.fill(0);1324  }1325}13261327export async function hasVault(): Promise<boolean> {1328  return (await loadSlot(SLOT_A)) !== null;1329}13301331/**1332 * Returns whether the real vault has recorded that a duress passphrase is1333 * configured. Reliable only after a real-mode unlock — before unlock or in1334 * decoy mode, returns false because we cannot read slot A's plaintext.1335 */1336export async function isDuressEnabled(): Promise<boolean> {1337  return vaultMode === "real" && duressConfigured;1338}13391340export async function setupDuress(duressPassword: string): Promise<void> {1341  if (!derivedKey || vaultMode !== "real") {1342    throw new Error("vault must be unlocked in real mode");1343  }1344  // Encrypt the decoy payload (empty entries) under the duress key. Pad to1345  // at least the current slot A ciphertext size so the two slots look the1346  // same at rest at setup time.1347  const slotA = await loadSlot(SLOT_A);1348  if (!slotA) throw new Error("missing slot A");13491350  const decoySalt = generateSalt();1351  const decoyKey = await deriveKey(duressPassword, decoySalt);1352  try {1353    // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1354    // same ceiling by the persistSlotA() call below (duressConfigured is now1355    // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1356    // lengths cannot reveal that duress is configured (audit MEDIUM).1357    const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1358    const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1359    await writeSlot(SLOT_B, {1360      version: 1,1361      salt: hex(decoySalt),1362      nonce,1363      ciphertext,1364      tag,1365    });1366  } finally {1367    zeroKey(decoyKey);1368  }13691370  // Re-encrypt slot A with the duressConfigured flag set to true. No external1371  // flag is written — the bit lives inside the encrypted envelope.1372  duressConfigured = true;1373  await persistSlotA();1374}13751376export async function disableDuress(): Promise<void> {1377  if (!derivedKey || vaultMode !== "real") {1378    throw new Error("vault must be unlocked in real mode");1379  }1380  // Overwrite slot B with fresh noise that matches slot A's size. The slot1381  // remains occupied so its mere existence does not signal that duress was1382  // ever configured.1383  const slotA = await loadSlot(SLOT_A);1384  if (slotA) {1385    await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1386  }13871388  duressConfigured = false;1389  await persistSlotA();1390}13911392// L14/COR-015: this function intentionally checks slot A (real vault) only.1393// A duress-mode user attempting changePassword is rejected earlier by the1394// vaultMode === 'decoy' check at changePassword. The real-vs-decoy1395// distinction here is by design — duress users cannot rotate the real1396// password by guessing it, and the asymmetry in error timing is acceptable1397// because duress mode is opt-in and the user knows their own decoy key.1398export async function verifyMainPassword(password: string): Promise<boolean> {1399  const slotA = await loadSlot(SLOT_A);1400  if (!slotA) return false;1401  const key = await deriveKey(password, unhex(slotA.salt));1402  try {1403    return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1404  } finally {1405    zeroKey(key);1406  }1407}14081409export async function unlock(password: string): Promise<boolean> {1410  return attemptUnlockWithDerive(password, deriveKey);1411}14121413async function attemptUnlockWithDerive(1414  password: string,1415  derive: typeof deriveKey,1416): Promise<boolean> {1417  // Load both current-format slots in parallel. Both exist on a supported1418  // install; the1419  // parallel load and the parallel derivation below keep wall-clock time1420  // constant regardless of which slot's passphrase (real or decoy) the1421  // caller supplied, so a timing side channel cannot distinguish the1422  // duress path from the real-unlock path (DUR-002).1423  const [slotA, slotB] = await Promise.all([1424    loadSlot(SLOT_A),1425    loadSlot(SLOT_B),1426  ]);14271428  // Derive both keys in parallel, unconditionally.1429  const [keyA, keyB] = await Promise.all([1430    slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1431    slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1432  ]);14331434  // Attempt both decrypts in parallel. The Result variant lets us distinguish1435  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1436  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1437  const [resultA, resultB] = await Promise.all([1438    keyA && slotA1439      ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1440      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1441    keyB && slotB1442      ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1443      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1444  ]);1445  const payloadA = resultA.status === "ok" ? resultA.payload : null;1446  const payloadB = resultB.status === "ok" ? resultB.payload : null;14471448  if (payloadA && keyA) {1449    derivedKey = keyA;1450    void saveSessionKey(keyA, "real");1451    vaultMode = "real";1452    entries = payloadA.entries;1453    tombstones = payloadA.tombstones;1454    duressConfigured = payloadA.duressConfigured;1455    buildDomainIndexSync(entries);1456    await loadSettings();1457    touchAutoLock();1458    if (keyB) zeroKey(keyB);14591460    // Current vaults always carry a password-wrapped root key. Older vaults1461    // without this escrow are outside the supported storage format.1462    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1463    if (!rootKey) {1464      await lock();1465      return false;1466    }14671468    await lockedSyncUnlockHook?.().catch((error) => {1469      console.warn("[Peach sync] locked inbox drain failed:", error);1470    });1471    await resetPinFailedAttempts();1472    return true;1473  }14741475  if (payloadB && keyB) {1476    derivedKey = keyB;1477    void saveSessionKey(keyB, "decoy");1478    vaultMode = "decoy";1479    entries = payloadB.entries;1480    tombstones = payloadB.tombstones;1481    duressConfigured = false;1482    buildDomainIndexSync(entries);1483    await loadSettings();1484    touchAutoLock();1485    if (keyA) zeroKey(keyA);1486    return true;1487  }14881489  if (keyA) zeroKey(keyA);1490  if (keyB) zeroKey(keyB);1491  // Surface corruption (good AES tag, broken schema) distinctly from1492  // wrong-password so the popup can offer history-restore (M7/COR-007).1493  if (resultA.status === "corrupted") {1494    lastUnlockFailureReason = resultA.reason;1495  } else if (resultB.status === "corrupted") {1496    lastUnlockFailureReason = resultB.reason;1497  }1498  return false;1499}15001501// ---------------------------------------------------------------------------1502// MV3 service worker session key — survives SW restarts within a browser session1503// ---------------------------------------------------------------------------15041505// chrome.storage.session is cleared on browser close; survives SW restart.1506// Firefox does not implement it (persistent background script, no restart risk).1507const SESSION_KEY_STORAGE = "peach_session_dk";15081509// PB-26-203: the session-key escrow may only operate where startup positively1510// established trusted-only session storage. Defaults to available so direct1511// vault imports (tests, non-entry bootstraps) keep working; entry.ts applies1512// the real hardening result before the first restore.1513let sessionEscrowAvailable = true;15141515/**1516 * Apply the storage-hardening result at startup. When the trusted-only1517 * session state cannot be established, any stored session key is removed and1518 * the escrow stays off: the user unlocks again after a worker restart1519 * instead of trusting an unproven boundary.1520 */1521export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1522  if (sessionEscrow) return;1523  sessionEscrowAvailable = false;1524  await clearSessionKey();1525}15261527async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1528  if (!chrome.storage.session || !sessionEscrowAvailable) return;1529  await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1530}15311532async function clearSessionKey(): Promise<void> {1533  if (!chrome.storage.session) return;1534  await chrome.storage.session.remove(SESSION_KEY_STORAGE);1535}15361537// A lock that only holds while the service worker stays alive is no lock at1538// all: a cold worker's restoreFromSession() would find the surviving session1539// key and silently re-unlock ~a minute after the user locked. So lock()1540// bumps this epoch synchronously at entry and zeroes keys/indexes before any1541// await; a restore that started earlier sees the mismatch after its next1542// await and aborts instead of publishing.1543let vaultEpoch = 0;15441545// One in-flight restore shared by concurrent callers: storage is read once1546// and the locked-sync unlock hook runs once.1547let restoreInFlight: Promise<boolean> | null = null;15481549/**1550 * On SW restart, re-derive all in-memory vault state from the session key1551 * stored in chrome.storage.session (set at unlock time, cleared by lock();1552 * also cleared on browser close or when restoration fails).1553 * Returns true if the vault was restored, false if no session key was found.1554 * No-ops if the vault is already unlocked.1555 */1556export async function restoreFromSession(): Promise<boolean> {1557  if (derivedKey !== null) return true;1558  if (restoreInFlight) return restoreInFlight;1559  const task = restoreFromSessionInner();1560  restoreInFlight = task;1561  try {1562    return await task;1563  } finally {1564    restoreInFlight = null;1565  }1566}15671568async function restoreFromSessionInner(): Promise<boolean> {1569  const epoch = vaultEpoch;1570  const stale = () => epoch !== vaultEpoch;1571  if (derivedKey !== null) return true;1572  if (!sessionEscrowAvailable) return false;1573  if (!chrome.storage.session) return false;1574  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1575  if (stale()) return false;1576  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1577  if (!entry?.keyHex) return false;15781579  const key = unhex(entry.keyHex);1580  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1581  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1582  const slot = await loadSlot(slotKey);1583  if (stale()) { zeroKey(key); return false; }1584  if (!slot) { await clearSessionKey(); return false; }15851586  const result = await tryDecryptSlotResult(slot, key, aadTag);1587  if (stale()) { zeroKey(key); return false; }1588  if (result.status !== "ok") { await clearSessionKey(); return false; }15891590  // Decrypted payload stays in locals until the final epoch-checked commit1591  // below; nothing observable is published before then.1592  const payload = result.payload;1593  await loadSettings();1594  if (stale()) { zeroKey(key); return false; }15951596  // Rehydrate the root key used by sync identity derivation from the password1597  // escrow stored on the current vault format.1598  let freshRoot: Uint8Array | null = null;1599  if (entry.mode === "real") {1600    freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1601    if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1602    if (!freshRoot) {1603      await lock();1604      return false;1605    }1606  }16071608  if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1609  derivedKey = key;1610  vaultMode = entry.mode;1611  entries = payload.entries;1612  tombstones = payload.tombstones;1613  duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1614  if (freshRoot) rootKey = freshRoot;1615  buildDomainIndexSync(entries);1616  touchAutoLock();16171618  await lockedSyncUnlockHook?.().catch((error) => {1619    console.warn("[Peach sync] locked inbox drain failed:", error);1620  });1621  if (stale()) return false;16221623  return true;1624}16251626export async function lock(): Promise<void> {1627  // A user-initiated lock must be durable across MV3 service-worker restarts.1628  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1629  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1630  // If the session key survived lock(), that restore would silently re-unlock1631  // the vault ~a minute after the user locked it — a lock that only held1632  // while the SW happened to stay alive. The background-sync convenience the1633  // session key once bought (sync runner re-waking to a restored vault) is1634  // not worth an unlocked vault the user never asked to unlock; when locked,1635  // sync-runner correctly reports VAULT_LOCKED instead.1636  vaultEpoch++;1637  if (derivedKey) {1638    zeroKey(derivedKey);1639    derivedKey = null;1640  }1641  if (rootKey) {1642    rootKey.fill(0);1643    rootKey = null;1644  }16451646  entries = [];1647  tombstones = new Map();1648  domainIndex = new Map();1649  vaultMode = "real";1650  duressConfigured = false;1651  clearAutoLock();1652  await clearSessionKey();1653  await clearRecoveryConfirmPending();1654}16551656export function isUnlocked(): boolean {1657  return derivedKey !== null;1658}16591660// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1661// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1662// always derives from scratch via Argon2id.1663export async function unlockWithKey(1664  masterKeyHex: string,1665): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1666  try {1667    const masterKeyBytes = unhex(masterKeyHex);1668    const stored = await loadSlot(SLOT_A);1669    if (!stored) return { ok: false, error: "invalid_key" };1670    const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1671    if (!payload) return { ok: false, error: "invalid_key" };16721673    derivedKey = masterKeyBytes;1674    vaultMode = "real";1675    entries = payload.entries;1676    tombstones = payload.tombstones;1677    duressConfigured = payload.duressConfigured;1678    buildDomainIndexSync(entries);1679    await loadSettings();1680    touchAutoLock();16811682    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1683    if (!rootKey) {1684      await lock();1685      return { ok: false, error: "invalid_key" };1686    }1687    return { ok: true, entries };1688  } catch {1689    return { ok: false, error: "invalid_key" };1690  }1691}16921693// --- Adoption duress-slot helpers (passthrough, no decrypt) ---16941695/**1696 * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1697 * duress is configured and slot B exists, so the inline snapshot can ship it1698 * to the receiver. Returns null otherwise. Never decrypts.1699 */1700export async function exportDuressSlotForAdoption(): Promise<string | null> {1701  if (!(await isDuressEnabled())) return null;1702  const slotB = await loadSlot(SLOT_B);1703  if (!slotB) return null;1704  return JSON.stringify(slotB);1705}17061707/**1708 * Adoption RECEIVER helper: installs a duress slot received from a source1709 * device. Validates the StoredVault shape (same guard as unlockFromCodex)1710 * and writes it to slot B. Throws on invalid shape so the caller can treat1711 * it as a non-fatal snapshot failure. Never decrypts.1712 */1713export async function installAdoptedDuressSlot(json: string): Promise<void> {1714  let parsed: StoredVault;1715  try {1716    const raw = JSON.parse(json) as unknown;1717    if (!hasValidVaultSchema(raw)) {1718      throw new Error("invalid_duress_slot_shape");1719    }1720    parsed = raw;1721  } catch (err) {1722    if (err instanceof SyntaxError) {1723      throw new Error("invalid_duress_slot_shape");1724    }1725    throw err;1726  }1727  await writeSlot(SLOT_B, parsed);1728}17291730/**1731 * Restore the vault from a parsed Peach Codex payload.1732 *1733 * The supplied passphrase is verified against the Codex's slotA *before* any1734 * storage mutation — wrong passphrases return without touching disk. On1735 * success, the current vault state is snapshotted (reason: "restore") so the1736 * user can revert via Settings -> Vault History, then slotA/slotB are written1737 * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A1738 * regular `unlock()` follows so the rest of the SW sees the standard1739 * post-unlock state.1740 *1741 * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex1742 * printed by a Pro user must always be restorable, even on a free device.1743 *1744 * Timing is not held constant here (in contrast to `unlock`): the Codex flow1745 * is an explicit user action with a known passphrase, not a passive lock1746 * screen, so the duress-vs-real timing side channel does not apply.1747 */1748export async function unlockFromCodex(params: {1749  slotA: string; // JSON string of StoredVault for slot A1750  slotB: string | null; // JSON string of StoredVault for slot B, or null1751  timestamp: number;1752  masterPassphrase: string;1753}): Promise<1754  | { ok: true; entryCount: number }1755  | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1756> {1757  let parsedSlotA: StoredVault;1758  let parsedSlotB: StoredVault | null = null;1759  try {1760    const rawA = JSON.parse(params.slotA) as unknown;1761    if (!hasValidVaultSchema(rawA)) {1762      return { ok: false, reason: "invalid_slot_format" };1763    }1764    parsedSlotA = rawA;1765    if (params.slotB !== null) {1766      const rawB = JSON.parse(params.slotB) as unknown;1767      if (!hasValidVaultSchema(rawB)) {1768        return { ok: false, reason: "invalid_slot_format" };1769      }1770      parsedSlotB = rawB;1771    }1772  } catch {1773    return { ok: false, reason: "invalid_slot_format" };1774  }17751776  // Verify the passphrase against the supplied slotA WITHOUT writing anything.1777  // If this fails the on-disk vault is left completely untouched.1778  const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1779  const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1780  if (!trialPayload) {1781    zeroKey(trialKey);1782    return { ok: false, reason: "wrong_passphrase" };1783  }1784  // Drop the trial key; `unlock()` below derives its own copy. Holding two1785  // copies of the master key in memory at once would be a needless surface.1786  zeroKey(trialKey);17871788  // Snapshot the about-to-be-overwritten on-disk state so the user can revert1789  // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails.1790  await takeVaultSnapshot("restore");17911792  await writeSlot(SLOT_A, parsedSlotA);1793  if (parsedSlotB) {1794    await writeSlot(SLOT_B, parsedSlotB);1795  } else {1796    // Slot B must always exist with ciphertext length matching slot A1797    // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't1798    // configured); regenerate noise to satisfy the invariant.1799    await writeSlot(1800      SLOT_B,1801      generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1802    );1803  }1804  await chrome.storage.local.set({1805    peach_last_modified_at: String(params.timestamp),1806  });18071808  // Standard unlock path so domain index, settings, auto-lock, and1809  // recovery-key state all populate exactly as a normal unlock would.1810  const ok = await unlock(params.masterPassphrase);1811  if (!ok) {1812    // Should be unreachable — we just verified the passphrase against the1813    // same slotA we wrote. Treat as wrong_passphrase to keep the contract1814    // simple if some race or storage anomaly intrudes.1815    return { ok: false, reason: "wrong_passphrase" };1816  }1817  return { ok: true, entryCount: getEntries().length };1818}18191820// Returns the current in-memory derived key as a hex string. Only the PIN1821// unlock path needs this value, and it reads module state directly; this1822// accessor exists so unlockWithKey's root-key rehydration is testable.1823export /* @testable */ function getDerivedKeyHex(): string {1824  if (!derivedKey) throw new Error("vault_not_unlocked");1825  return hex(derivedKey);1826}18271828/**1829 * Returns the vault's stable root key as hex, or null when locked / not yet1830 * populated. The root key is random per-vault, generated at creation, escrowed1831 * under the recovery words, and re-wrapped (not regenerated) on every password1832 * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.1833 *1834 * This is the correct input for sync identity derivation (vault_id, the Noise1835 * NK static key, the Ed25519 manifest signing key, and per-entry envelope1836 * keys): paired devices keep the same identity and can still decrypt each1837 * other's entries after either side rotates its master password (SEV-2-B).1838 *1839 * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED1840 * instead of crashing; rootKey is populated on every supported real-mode1841 * unlock via deriveRootKeyFromDerivedKey.1842 */1843export function getRootKeyHex(): string | null {1844  return rootKey ? hex(rootKey) : null;1845}18461847export function getEntries(): VaultEntry[] {1848  if (!derivedKey) throw new Error("vault_locked");1849  return entries.filter((entry) => !entry.deletedAt);1850}18511852const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;18531854export function getIdentities(): SavedIdentity[] {1855  if (!derivedKey) throw new Error("vault_locked");1856  const active = entries.filter((e) => !e.deletedAt && e.type === "login");18571858  const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1859  const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();18601861  for (const entry of active) {1862    const domain = extractDomain(entry.url ?? "") ?? "";1863    const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;18641865    function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1866      const existing = map.get(v);1867      if (!existing || modifiedAt > existing.lastUsed) {1868        map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1869      } else {1870        map.set(v, { ...existing, count: existing.count + 1 });1871      }1872    }18731874    const uVal = entry.username?.trim();1875    if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);18761877    // Also count the dedicated email field — entries created via the inline1878    // popup new-entry form store email here rather than in username.1879    const eVal = entry.email?.trim();1880    if (eVal) tally(emailMap, eVal);1881  }18821883  const toIdentity = (type: "email" | "username") =>1884    ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1885      value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1886    });18871888  return [1889    ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1890    ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1891  ];1892}18931894export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1895  if (!derivedKey) throw new Error("vault_locked");18961897  const now = Date.now();1898  const added: VaultEntry[] = [];1899  for (const draft of drafts) {1900    try {1901      const sanitized = sanitizeEntryDraft(draft);1902      const nextEntry: VaultEntry = {1903        ...sanitized,1904        id: crypto.randomUUID(),1905        createdAt: now,1906        modifiedAt: monotonicEntryTimestamp(),1907      };1908      entries.push(nextEntry);1909      added.push(nextEntry);1910    } catch {1911      // Skip invalid drafts silently — caller can see diff between input and output length1912    }1913  }19141915  if (added.length > 0) {1916    await persistEntries();1917  }19181919  return added;1920}19211922/**1923 * Restore a set of entries in full fidelity — preserves id, createdAt,1924 * modifiedAt, and every other field unlike `batchAddEntries` which1925 * assigns fresh identity. Used by restore paths where the backup is the1926 * authoritative source of truth. `mode` controls how entries already in1927 * the vault are treated.1928 */1929export async function restoreEntries(1930  incoming: VaultEntry[],1931  mode: "replace" | "merge"1932): Promise<{ added: number; updated: number; skipped: number }> {1933  if (!derivedKey) throw new Error("vault_locked");19341935  // A replace with zero incoming entries is almost certainly a caller bug1936  // (e.g. an import that silently dropped everything). Refuse rather than1937  // wipe the vault.1938  if (mode === "replace" && incoming.length === 0) {1939    throw new Error("refuse to replace with empty entry set");1940  }19411942  let added = 0;1943  let updated = 0;1944  let skipped = 0;19451946  if (mode === "replace") {1947    entries = [];1948    for (const entry of incoming) {1949      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1950      if (!normalized) {1951        skipped += 1;1952        continue;1953      }1954      entries.push(normalized);1955      if (normalized.deletedAt) {1956        tombstones.set(1957          normalized.id,1958          Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),1959        );1960      } else {1961        tombstones.delete(normalized.id);1962      }1963      added += 1;1964    }1965  } else {1966    const byId = new Map<string, VaultEntry>();1967    for (const e of entries) byId.set(e.id, e);19681969    for (const entry of incoming) {1970      const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1971      if (!normalized) {1972        skipped += 1;1973        continue;1974      }1975      const existing = byId.get(normalized.id);1976      if (!existing) {1977        entries.push(normalized);1978        if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1979        else tombstones.delete(normalized.id);1980        added += 1;1981      } else if (normalized.modifiedAt > existing.modifiedAt) {1982        const idx = entries.findIndex((e) => e.id === existing.id);1983        if (idx !== -1) {1984          entries[idx] = normalized;1985          if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1986          else tombstones.delete(normalized.id);1987          updated += 1;1988        }1989      } else {1990        skipped += 1;1991      }1992    }1993  }19941995  await persistEntries("import");1996  return { added, updated, skipped };1997}19981999/**2000 * A Peach entry id is a UUID. A restored or imported file carries whatever id2001 * its author used, and no reader bounds it, so one oversized id would ride2002 * into every future sync frame and be rejected there — a permanent, opaque2003 * failure on both platforms, caused by data that was already local.2004 *2005 * The action is mint, not reject: this is the last point where an id is still2006 * ours to choose, so the worst outcome is one duplicate if the same vault is2007 * later restored beside a device still holding the old id. Rejecting would2008 * lose the user's import.2009 *2010 * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries2011 * also calls. Sync has to adopt a peer's id verbatim — that is the only thing2012 * keeping two devices agreed on which entry they are discussing — so reminting2013 * there would fork identity on every device, on every round.2014 */2015const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;20162017function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2018  if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2019  return { ...entry, id: crypto.randomUUID() };2020}20212022/**2023 * Validate + normalise a VaultEntry coming from a backup, import, or sync2024 * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies2025 * only known VaultEntry fields (unknown/attacker keys are dropped), and2026 * nested `passkey`/`canary` shapes are rebuilt field-by-field, never2027 * spread. Returns null for values the live vault shouldn't accept.2028 */2029function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2030  if (!entry || typeof entry !== "object") return null;2031  const title = typeof entry.title === "string" ? entry.title.trim() : "";2032  if (!title) return null;2033  const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2034  const now = Date.now();2035  const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2036  const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;20372038  // normalizeEntry whitelists every known VaultEntry field before the entry2039  // reaches the live vault.2040  const normalized = normalizeEntry({2041    ...entry,2042    id,2043    title,2044    type: entry.type ?? "login",2045    createdAt,2046    modifiedAt,2047  });20482049  if (normalized.passkey) {2050    normalized.passkey = sanitizePasskey(normalized.passkey);2051  }2052  if (normalized.canary) {2053    normalized.canary = sanitizeCanary(normalized.canary);2054  }2055  // PCH-01-016: bound tags on the restore path too, so both import funnels agree.2056  normalized.tags = sanitizeTags(normalized.tags);20572058  // Login entries from backup must carry a password (or be passkey-only,2059  // in which case the passkey sub-object replaces the password requirement).2060  if (2061    normalized.type === "login" &&2062    !normalized.passkey &&2063    typeof normalized.password !== "string"2064  ) {2065    return null;2066  }2067  if (entry.passkey && !normalized.passkey) {2068    // Entry claimed a passkey but the shape didn't validate — refuse the2069    // whole entry rather than silently importing a credential-less husk.2070    return null;2071  }20722073  return normalized;2074}20752076/**2077 * Rebuild a PasskeyData object from untrusted input, field by field.2078 * Returns undefined when required fields are missing/mistyped — a rogue2079 * passkey credential must not activate via import (MV-6 attack scenario).2080 */2081function sanitizePasskey(pk: unknown): PasskeyData | undefined {2082  if (!pk || typeof pk !== "object") return undefined;2083  const r = pk as Record<string, unknown>;2084  if (2085    typeof r.rpId !== "string" || !r.rpId ||2086    typeof r.credentialId !== "string" || !r.credentialId ||2087    typeof r.publicKeyCose !== "string" ||2088    typeof r.userHandle !== "string" ||2089    typeof r.userName !== "string"2090  ) {2091    return undefined;2092  }2093  return {2094    rpId: r.rpId,2095    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2096    credentialId: r.credentialId,2097    publicKeyCose: r.publicKeyCose,2098    privateKeyJwk:2099      r.privateKeyJwk && typeof r.privateKeyJwk === "object"2100        ? (r.privateKeyJwk as JsonWebKey)2101        : undefined,2102    userHandle: r.userHandle,2103    userName: r.userName,2104    userDisplayName:2105      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2106    algorithm: r.algorithm === -8 ? -8 : -7,2107    signCount: typeof r.signCount === "number" ? r.signCount : 0,2108    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2109    backupEligible: r.backupEligible === true,2110    backupState: r.backupState === true,2111    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2112    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2113    transports: Array.isArray(r.transports)2114      ? r.transports.filter((t): t is string => typeof t === "string")2115      : undefined,2116    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2117  };2118}21192120/**2121 * Rebuild a Canary object from untrusted input. The webhook must be a2122 * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing2123 * exfiltration endpoint that the canary subsystem will POST entry titles2124 * to (MV-6 attack scenario). Invalid → the canary is dropped, not the2125 * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire2126 * time (isAllowedCanaryWebhookUrl), so this import-time check is the2127 * outer of two gates.2128 */2129function sanitizeCanary(c: unknown): Canary | undefined {2130  if (!c || typeof c !== "object") return undefined;2131  const r = c as Record<string, unknown>;2132  if (typeof r.webhookUrl !== "string") return undefined;2133  let url: URL;2134  try {2135    url = new URL(r.webhookUrl);2136  } catch {2137    return undefined;2138  }2139  if (url.protocol !== "https:") return undefined;2140  return {2141    webhookUrl: url.toString(),2142    label: typeof r.label === "string" ? r.label : undefined,2143    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2144    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2145  };2146}21472148/**2149 * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted.2150 * Keep only non-empty strings, cap each at 256 chars, and cap the count at 2552151 * (the same per-entry ceiling the export codec enforces, peachPayload.ts).2152 * Non-array or all-invalid input collapses to undefined.2153 */2154function sanitizeTags(tags: unknown): string[] | undefined {2155  if (!Array.isArray(tags)) return undefined;2156  const out: string[] = [];2157  for (const t of tags) {2158    if (typeof t !== "string") continue;2159    const trimmed = t.trim();2160    if (!trimmed) continue;2161    out.push(trimmed.slice(0, 256));2162    if (out.length >= 255) break;2163  }2164  return out.length > 0 ? out : undefined;2165}21662167// Soft-deletes every non-deleted entry in one call. The literal string2168// argument is a tripwire against accidental calls — any caller that would2169// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this2170// to a boolean.2171export async function clearVaultEntries(2172  confirmation: "REPLACE_ALL"2173): Promise<void> {2174  if (confirmation !== "REPLACE_ALL") {2175    throw new Error("clearVaultEntries requires explicit confirmation");2176  }2177  if (!derivedKey) throw new Error("vault_locked");2178  for (const entry of entries) {2179    if (!entry.deletedAt) {2180      const stamp = monotonicEntryTimestamp(Math.max(2181        entry.modifiedAt,2182        tombstones.get(entry.id) ?? 0,2183      ));2184      entry.deletedAt = stamp;2185      entry.modifiedAt = stamp;2186      tombstones.set(entry.id, stamp);2187    }2188  }2189  await persistEntries();2190}21912192export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2193  if (!derivedKey) throw new Error("vault_locked");21942195  const sanitized = sanitizeEntryDraft(entry);2196  const now = Date.now();2197  const stamp = monotonicEntryTimestamp();2198  const nextEntry: VaultEntry = {2199    ...sanitized,2200    id: crypto.randomUUID(),2201    createdAt: now,2202    modifiedAt: stamp,2203    // Stamp passwordChangedAt at creation so age tracking is accurate from2204    // day one. Non-login entries carry the same stamp harmlessly.2205    passwordChangedAt: sanitized.password ? stamp : undefined,2206  };22072208  // Clone-and-replace so a failed persist rolls back the in-memory state2209  // (COR-008). Without this, a quota-full or storage error leaves the2210  // module-level `entries` ahead of what's actually on disk.2211  const rollback = entries;2212  entries = [...entries, nextEntry];2213  try {2214    await persistEntries();2215  } catch (err) {2216    entries = rollback;2217    throw err;2218  }22192220  // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set)2221  if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2222    checkBreach(nextEntry.password).then(async (count) => {2223      if (count > 0) {2224        const idx = entries.findIndex((e) => e.id === nextEntry.id);2225        if (idx !== -1) {2226          entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2227          await persistEntries();2228        }2229      }2230    });2231  }22322233  fireSyncTrigger("vault_change");22342235  return nextEntry;2236}22372238/**2239 * Run the passkey-merge pass on the live in-memory entries and persist if2240 * anything changed. Called immediately after any passkey registration so that2241 * a same-domain TOTP-only entry already in the vault gets consolidated right2242 * away instead of waiting until the next lock/unlock cycle.2243 */2244export async function consolidatePasskeyEntries(): Promise<void> {2245  if (!derivedKey) return;2246  const { merged } = mergePasskeyOnlyIntoLogins(entries);2247  if (merged) {2248    await persistEntries();2249  }2250}22512252export async function updateEntry(2253  id: string,2254  patch: Partial<VaultEntry>2255): Promise<void> {2256  if (!derivedKey) throw new Error("vault_locked");22572258  const idx = entries.findIndex((entry) => entry.id === id);2259  if (idx === -1 || entries[idx].deletedAt) {2260    throw new Error("entry_not_found");2261  }22622263  const current = entries[idx];2264  const sanitizedPatch = sanitizeEntryPatch(patch);2265  // Refresh passwordChangedAt only when the password value actually changes —2266  // a metadata-only update (title, tags, etc.) must not reset the age clock2267  // that drives the Pro health rotation recommendation.2268  const passwordChanged =2269    "password" in patch &&2270    typeof sanitizedPatch.password === "string" &&2271    sanitizedPatch.password !== current.password;2272  const stamp = monotonicEntryTimestamp(current.modifiedAt);2273  // When the password actually changes, push the outgoing one onto the history2274  // (newest first) so the user can reveal/copy/restore it later. We record the2275  // age it carried (passwordChangedAt, falling back to createdAt) rather than2276  // `now`, so the list reflects when each password was last in effect. Only2277  // capture a non-empty prior value — clearing a password leaves no secret to2278  // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)2279  // to avoid noise.2280  const priorPassword = current.password;2281  const shouldCapture =2282    passwordChanged &&2283    typeof priorPassword === "string" &&2284    priorPassword.length > 0 &&2285    current.passwordHistory?.[0]?.password !== priorPassword;2286  const nextHistory = shouldCapture2287    ? [2288        { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2289        ...(current.passwordHistory ?? []),2290      ].slice(0, PASSWORD_HISTORY_MAX)2291    : current.passwordHistory;2292  entries[idx] = {2293    ...current,2294    ...sanitizedPatch,2295    modifiedAt: stamp,2296    ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2297  };2298229923002301  // Card entries must retain their card number2302  if (entries[idx].type === "card" && !entries[idx].cardNumber) {2303    entries[idx] = current;2304    throw new Error("card number is required for card entries");2305  }23062307  // Roll the in-memory entry back to its pre-edit state if the disk write2308  // fails (e.g. storage quota). Without this the module-level `entries` array2309  // would hold an edit that never landed on disk, diverging from the vault2310  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.2311  try {2312    await persistEntries();2313  } catch (err) {2314    entries[idx] = current;2315    throw err;2316  }23172318  // Breach check when password changes: clear immediately so old breach doesn't linger,2319  // then async-check the new password and only re-set if it's also found.2320  if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2321    entries[idx] = { ...entries[idx], breach: undefined };2322    await persistEntries();2323    const password = entries[idx].password;2324    if (password) {2325      checkBreach(password).then(async (count) => {2326        const i = entries.findIndex((e) => e.id === id);2327        if (i !== -1 && count > 0) {2328          entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2329          await persistEntries();2330        }2331      });2332    }2333  }23342335  fireSyncTrigger("vault_change");2336}23372338export async function deleteEntry(id: string): Promise<void> {2339  if (!derivedKey) throw new Error("vault_locked");23402341  const entry = entries.find((item) => item.id === id);2342  if (!entry || entry.deletedAt) {2343    throw new Error("entry_not_found");2344  }23452346  // Tombstone timestamp must be strictly greater than the entry's last2347  // modification so the deletion wins LWW against any in-flight edit, even2348  // under clock skew (SEV-2-E).2349  const previousModifiedAt = entry.modifiedAt;2350  const previousTombstone = tombstones.get(id);2351  const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2352  entry.deletedAt = stamp;2353  entry.modifiedAt = stamp;2354  tombstones.set(id, stamp);2355  // Roll the tombstone back if the disk write fails, so a quota error doesn't2356  // leave the entry deleted in memory but alive on disk (SEV-3-A).2357  try {2358    await persistEntries();2359  } catch (err) {2360    delete entry.deletedAt;2361    entry.modifiedAt = previousModifiedAt;2362    if (previousTombstone === undefined) tombstones.delete(id);2363    else tombstones.set(id, previousTombstone);2364    throw err;2365  }2366  fireSyncTrigger("vault_change");2367}23682369export function getDeletedEntries(): VaultEntry[] {2370  if (!derivedKey) return [];2371  return entries.filter((e) => !!e.deletedAt);2372}23732374/** Return all durable deletion timestamps, including purged full entries. */2375export function getTombstoneManifest(): Record<string, number> {2376  if (!derivedKey) return {};2377  return Object.fromEntries(tombstones);2378}23792380/**2381 * Returns the vault's monotonic version — the `peach_last_modified_at` value2382 * stamped on every user-initiated write. Remote (sync) writes do NOT advance2383 * this version so a sync round never cascades into a second outbound sync.2384 */2385export async function getVaultVersion(): Promise<number> {2386  try {2387    const raw = await chrome.storage.local.get("peach_last_modified_at");2388    const v = (raw as Record<string, unknown>).peach_last_modified_at;2389    if (typeof v === "number") return v;2390    if (typeof v === "string" && v !== "") {2391      const n = Number.parseInt(v, 10);2392      return Number.isFinite(n) ? n : 0;2393    }2394    return 0;2395  } catch {2396    return 0;2397  }2398}23992400/**2401 * Apply a batch of remote entries + tombstones from a sync peer.2402 * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when2403 * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and2404 * persists with reason `sync_received` — does NOT fire the sync dispatcher, so2405 * applying a sync round cannot cascade into another outbound sync.2406 *2407 * `applied` counts entries that actually changed local state (new add,2408 * overwrite-by-newer, or new tombstone). `received` is the raw payload size2409 * the caller can surface in the sync result UI.2410 */2411export async function applyMergedRemoteEntries(2412  remoteEntries: VaultEntry[],2413  remoteTombstones: Record<string, number>,2414  _context: { deviceName?: string } = {},2415): Promise<{ received: number; applied: number; conflicts: number }> {2416  // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices2417  // concurrently edit different fields of the same entry, the one with the2418  // newer modifiedAt wins the whole entry and the other device's field edits2419  // are dropped silently. This is the deliberate trade-off for a simple,2420  // conflict-free merge — per-field CRDT merge is out of scope. Guidance for2421  // users: edit a given entry on one device at a time.2422  if (!derivedKey) throw new Error("vault_locked");24232424  const received = remoteEntries.length + Object.keys(remoteTombstones).length;2425  let applied = 0;2426  let conflicts = 0;24272428  const byId = new Map<string, VaultEntry>();2429  for (const e of entries) byId.set(e.id, e);24302431  const mergeNow = Date.now();2432  const remoteTombstoneTimes = new Map<string, number>();2433  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2434    if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2435      remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2436    }2437  }24382439  for (const remote of remoteEntries) {2440    const sanitized = sanitizeRestoredEntry(remote);2441    if (!sanitized) continue;2442    // HV-1a: a far-future remote modifiedAt must not permanently pin a2443    // malicious value against honest local edits. Clamp BEFORE any2444    // comparison against local timestamps.2445    sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2446    if (typeof sanitized.deletedAt === "number") {2447      sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2448    }2449    const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2450    const localTombstoneTs = tombstones.get(sanitized.id);2451    const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2452    if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2453      continue;2454    }2455    const local = byId.get(sanitized.id);2456    if (!local) {2457      entries.push(sanitized);2458      byId.set(sanitized.id, sanitized);2459      tombstones.delete(sanitized.id);2460      applied += 1;2461    } else if (sanitized.modifiedAt > local.modifiedAt) {2462      const idx = entries.findIndex((e) => e.id === local.id);2463      if (idx !== -1) {2464        entries[idx] = sanitized;2465        byId.set(sanitized.id, sanitized);2466        tombstones.delete(sanitized.id);2467        applied += 1;2468        if (!local.deletedAt) conflicts += 1;2469      }2470    }2471  }24722473  for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2474    if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2475    // HV-1a/1c: clamp the tombstone timestamp before every comparison.2476    const deletedAt = remoteTombstoneTimes.get(id)!;2477    const knownTombstone = tombstones.get(id);2478    if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2479    const local = byId.get(id);2480    if (!local) {2481      tombstones.set(id, deletedAt);2482      applied += 1;2483      continue;2484    }2485    if (local.deletedAt && local.deletedAt >= deletedAt) continue;2486    if (deletedAt < local.modifiedAt) continue;2487    const idx = entries.findIndex((e) => e.id === id);2488    if (idx !== -1) {2489      entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2490      tombstones.set(id, deletedAt);2491      applied += 1;2492    }2493  }24942495  if (applied > 0) {2496    await persistEntries("sync_received");2497    buildDomainIndexSync(entries);2498  }2499  return { received, applied, conflicts };2500}25012502export async function restoreEntry(id: string): Promise<void> {2503  if (!derivedKey) throw new Error("vault_locked");2504  const entry = entries.find((e) => e.id === id && e.deletedAt);2505  if (!entry) throw new Error("deleted entry not found");2506  const previousDeletedAt = entry.deletedAt;2507  const previousModifiedAt = entry.modifiedAt;2508  const previousTombstone = tombstones.get(id);2509  delete entry.deletedAt;2510  entry.modifiedAt = monotonicEntryTimestamp(Math.max(2511    entry.modifiedAt,2512    previousTombstone ?? 0,2513  ));2514  tombstones.delete(id);2515  try {2516    await persistEntries();2517  } catch (err) {2518    entry.deletedAt = previousDeletedAt;2519    entry.modifiedAt = previousModifiedAt;2520    if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2521    throw err;2522  }2523  buildDomainIndexSync(entries);2524}25252526export async function purgeEntry(id: string): Promise<void> {2527  if (!derivedKey) throw new Error("vault_locked");2528  const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2529  if (idx === -1) throw new Error("deleted entry not found");2530  const entry = entries[idx]!;2531  tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2532  entries.splice(idx, 1);2533  await persistEntries();2534}25352536/** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */2537export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2538  if (!derivedKey || retentionMs === 0) return 0;2539  const cutoff = Date.now() - retentionMs;2540  const before = entries.length;2541  for (const entry of entries) {2542    if (entry.deletedAt && entry.deletedAt < cutoff) {2543      tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2544    }2545  }2546  entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2547  const purged = before - entries.length;2548  if (purged > 0) await persistEntries();2549  return purged;2550}25512552export function queryDomain(domain: string): VaultEntry[] {2553  return domainIndex.get(domain) ?? [];2554}25552556export function getEntryById(id: string): VaultEntry | undefined {2557  return entries.find((entry) => entry.id === id && !entry.deletedAt);2558}25592560async function persistSlotA(2561  reason: VaultSnapshot["reason"] = "local_save",2562): Promise<StoredVault> {2563  if (!derivedKey) throw new Error("vault_locked");25642565  const slotA = await loadSlot(SLOT_A);2566  if (!slotA) throw new Error("missing slot A");25672568  // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures2569  // are swallowed by takeVaultSnapshot — history must never block a save.2570  await takeVaultSnapshot(reason);25712572  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2573  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");25742575  // Preserve existing recovery escrow fields.2576  const nextSlotA: StoredVault = {2577    ...slotA,2578    nonce,2579    ciphertext,2580    tag,2581    // recovery_salt and recovery_wrapped_root_key are already set from loadSlot2582  };2583  await writeSlot(SLOT_A, nextSlotA);25842585  // Slot A (the real vault) is now committed. Wrap every later write so a2586  // mid-persist failure (e.g. a storage error after slot A landed) restores the2587  // prior slot A before rethrowing. Without this, a "failed" save that already2588  // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/2589  // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces2590  // as a duplicate on the next unlock (observed under storage-quota pressure).2591  try {2592    // When duress is not configured, keep slot B size-matched with slot A by2593    // regenerating its noise. When duress IS configured, leave slot B alone —2594    // we don't have the decoy key to re-encrypt it. This is a known trade-off.2595    if (!duressConfigured) {2596      await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2597    }2598    // Only advance the vault version for locally-originated writes. Entries2599    // applied from a sync peer ("sync_received") must NOT bump the counter —2600    // otherwise applying a remote round makes us look newer than the peer and2601    // triggers a redundant outbound sync, defeating the no-op optimisation2602    // and risking a ping-pong between two devices (SEV-2-C).2603    if (reason !== "sync_received") {2604      await stampLocalVaultMutation();2605    }2606  } catch (err) {2607    // Best-effort restore — the prior slot A is smaller-or-equal, so this write2608    // fits even when the failure was a quota overflow.2609    await writeSlot(SLOT_A, slotA).catch(() => {});2610    throw err;2611  }26122613  return nextSlotA;2614}26152616async function persistSlotB(): Promise<void> {2617  if (!derivedKey) throw new Error("vault_locked");26182619  const slotB = await loadSlot(SLOT_B);2620  if (!slotB) throw new Error("missing slot B");26212622  // Decoy plaintext always reports duressConfigured=false — the decoy user2623  // sees a clean slate. persistSlotB only runs in decoy mode, which only2624  // exists once duress is configured, so pad to the fixed duress ceiling to2625  // keep slot B's length equal to slot A's (audit MEDIUM — deniability).2626  const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2627  const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");26282629  await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2630  // Symmetric with persistSlotA: if the version stamp fails after the decoy2631  // slot committed, restore the prior slot B so a failed decoy-mode save can't2632  // leave disk ahead of the in-memory rollback.2633  try {2634    await stampLocalVaultMutation();2635  } catch (err) {2636    await writeSlot(SLOT_B, slotB).catch(() => {});2637    throw err;2638  }2639}26402641async function persistEntries(2642  reason: VaultSnapshot["reason"] = "local_save",2643): Promise<void> {2644  if (!derivedKey) throw new Error("vault_locked");26452646  // Reconcile direct soft-delete callers while restoring the previous map if2647  // storage rejects the save.2648  const previousTombstones = tombstones;2649  tombstones = new Map(tombstones);2650  reconcileTombstones();2651  try {2652    if (vaultMode === "decoy") {2653      await persistSlotB();2654    } else {2655      await persistSlotA(reason);2656    }2657  } catch (err) {2658    tombstones = previousTombstones;2659    throw err;2660  }2661  buildDomainIndexSync(entries);2662  await refreshLockedSyncState();2663}26642665// PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage2666// boundary. Schemeless values (e.g. "github.com") are inert and kept — they2667// pre-date this guard in existing data. Unparseable values are kept: the2668// reviewed render paths never execute them (favicons via _favicon/, domains2669// via tldts); this is defense-in-depth against future sinks.2670function allowHttpUrl(url: string | undefined): string {2671  const trimmed = url?.trim() ?? "";2672  if (!trimmed) return "";2673  try {2674    const parsed = new URL(trimmed);2675    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2676  } catch {2677    // No scheme to reject.2678  }2679  return trimmed;2680}26812682function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2683  if (!Array.isArray(values)) return undefined;2684  const seen = new Set<string>();2685  const cleaned: string[] = [];2686  for (const value of values) {2687    const trimmed = typeof value === "string" ? value.trim() : "";2688    if (!trimmed) continue;2689    if (!allowHttpUrl(trimmed)) continue;2690    const key = trimmed.toLowerCase();2691    if (seen.has(key)) continue;2692    seen.add(key);2693    cleaned.push(trimmed);2694  }2695  return cleaned.length > 0 ? cleaned : undefined;2696}26972698function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2699  const title = entry.title.trim();2700  const type: VaultEntry["type"] = entry.type ?? "login";27012702  if (!title) throw new Error("title is required");27032704  if (entry.passkey) {2705    const p = entry.passkey;2706    if (2707      !p.rpId ||2708      !p.credentialId ||2709      !p.publicKeyCose ||2710      !p.userHandle ||2711      !p.userName ||2712      (p.algorithm !== -7 && p.algorithm !== -8)2713    ) {2714      throw new Error("invalid passkey credential");2715    }2716  }2717  if (type === "card") {2718    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2719      throw new Error("cardholder name and card number are required");2720    }2721  }27222723  return {2724    type,2725    title,2726    username: type === "login" ? entry.username?.trim() ?? "" : undefined,2727    email: type === "login" ? entry.email?.trim() || undefined : undefined,2728    password: type === "login" ? entry.password : undefined,2729    url: type === "login" ? allowHttpUrl(entry.url) : undefined,2730    additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2731    totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2732    // PCH-01-019: preserve non-default TOTP params through batch-add/import so2733    // they are not silently stripped (they fall back to SHA-1/6/30 when absent).2734    totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2735    totpDigits: type === "login" ? entry.totpDigits : undefined,2736    totpPeriod: type === "login" ? entry.totpPeriod : undefined,2737    notes: entry.notes?.trim() || undefined,2738    breach: type === "login" ? entry.breach : undefined,2739    // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags2740    // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here.2741    canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2742    tags: sanitizeTags(entry.tags),2743    passkey: entry.passkey,2744    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2745    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2746    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2747    expYear: type === "card" ? entry.expYear?.trim() : undefined,2748    cvc: type === "card" ? entry.cvc?.trim() : undefined,2749    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2750    network: type === "card" ? entry.network : undefined,2751    last4: type === "card" ? entry.last4?.trim() : undefined,2752  };2753}27542755function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2756  const nextPatch: Partial<VaultEntry> = {};27572758  if ("type" in patch) {2759    nextPatch.type = patch.type;2760  }27612762  if ("title" in patch) {2763    const title = patch.title?.trim() ?? "";2764    if (!title) throw new Error("title is required");2765    nextPatch.title = title;2766  }27672768  if ("url" in patch) {2769    nextPatch.url = allowHttpUrl(patch.url);2770  }27712772  if ("additionalUrls" in patch) {2773    const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2774    const seen = new Set<string>();2775    const cleaned: string[] = [];2776    for (const value of incoming) {2777      const trimmed = typeof value === "string" ? value.trim() : "";2778      if (!trimmed || !allowHttpUrl(trimmed)) continue;2779      const key = trimmed.toLowerCase();2780      if (seen.has(key)) continue;2781      seen.add(key);2782      cleaned.push(trimmed);2783    }2784    nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2785  }27862787  if ("username" in patch) {2788    nextPatch.username = patch.username?.trim() ?? "";2789  }27902791  if ("email" in patch) {2792    nextPatch.email = patch.email?.trim() || undefined;2793  }27942795  if ("password" in patch) {2796    const password = patch.password ?? "";2797    // Allow clearing a password (e.g. on passkey-only entries)2798    nextPatch.password = password || undefined;2799  }28002801  if ("totp" in patch) {2802    nextPatch.totp = patch.totp?.trim() || undefined;2803  }28042805  if ("notes" in patch) {2806    nextPatch.notes = patch.notes?.trim() || undefined;2807  }28082809  if ("breach" in patch) {2810    nextPatch.breach = patch.breach;2811  }28122813  if ("canary" in patch) {2814    // PCH-02-001: route update patches through the same canary whitelist used2815    // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.2816    nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2817  }28182819  if ("passkey" in patch) {2820    // A patch with `passkey: undefined` removes the credential from the entry2821    // (used by "remove passkey" without deleting the whole entry).2822    if (patch.passkey === undefined || patch.passkey === null) {2823      nextPatch.passkey = undefined;2824    } else {2825      const sanitized = sanitizePasskey(patch.passkey);2826      if (!sanitized) throw new Error("invalid passkey credential");2827      nextPatch.passkey = sanitized;2828    }2829  }28302831  if ("tags" in patch) {2832    // PCH-02-001: bound tags exactly like both import funnels.2833    nextPatch.tags = sanitizeTags(patch.tags);2834  }28352836  if ("cardholderName" in patch) {2837    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2838  }2839  if ("cardNumber" in patch) {2840    const n = patch.cardNumber ?? "";2841    nextPatch.cardNumber = n || undefined;2842  }2843  if ("expMonth" in patch) {2844    nextPatch.expMonth = patch.expMonth?.trim() || undefined;2845  }2846  if ("expYear" in patch) {2847    nextPatch.expYear = patch.expYear?.trim() || undefined;2848  }2849  if ("cvc" in patch) {2850    nextPatch.cvc = patch.cvc?.trim() || undefined;2851  }2852  if ("cardNickname" in patch) {2853    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2854  }2855  if ("network" in patch) {2856    nextPatch.network = patch.network;2857  }2858  if ("last4" in patch) {2859    nextPatch.last4 = patch.last4?.trim() || undefined;2860  }28612862  return nextPatch;2863}28642865function normalizeEntry(entry: VaultEntry): VaultEntry {2866  const base: VaultEntry = {2867    id: entry.id,2868    type: entry.type,2869    title: entry.title,2870    username: entry.username,2871    email: entry.email,2872    password: entry.password,2873    url: entry.url,2874    additionalUrls:2875      "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2876        ? entry.additionalUrls2877        : undefined,2878    totp: entry.totp,2879    // PCH-01-019: carry TOTP params through the normalize/restore path too.2880    totpAlgorithm: entry.totpAlgorithm,2881    totpDigits: entry.totpDigits,2882    totpPeriod: entry.totpPeriod,2883    notes: entry.notes,2884    breach: entry.breach,2885    // Preserve the password-age clock across lock/unlock. Without this the2886    // field is dropped on every decode and the advanced-health "aged password"2887    // signal silently resets to createdAt on each unlock (SEV-2-A).2888    passwordChangedAt:2889      "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2890    // Preserve past-password history across lock/unlock. normalizeEntry is a2891    // field whitelist, so an unlisted field is silently dropped on every decode2892    // (same class as the passwordChangedAt SEV-2-A above).2893    passwordHistory:2894      "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2895        ? entry.passwordHistory2896        : undefined,2897    canary: entry.canary,2898    passkey: entry.passkey,2899    createdAt: entry.createdAt,2900    modifiedAt: entry.modifiedAt,2901    deletedAt: entry.deletedAt,2902    tags: entry.tags,2903    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2904    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2905    expMonth: "expMonth" in entry ? entry.expMonth : undefined,2906    expYear: "expYear" in entry ? entry.expYear : undefined,2907    cvc: "cvc" in entry ? entry.cvc : undefined,2908    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2909    network: "network" in entry ? entry.network : undefined,2910    last4: "last4" in entry ? entry.last4 : undefined,2911  };29122913  return base;2914}291529162917// ---------------------------------------------------------------------------2918// Deduplication2919// ---------------------------------------------------------------------------29202921function entryDupeKey(e: VaultEntry): string {2922  if (e.type === "note") return `note|${e.title.toLowerCase()}`;2923  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins2924  // on different sites that happen to share a username are still distinct;2925  // two passkey credentials for the same RP+user are duplicates regardless2926  // of any login fields they may also carry.2927  if (e.passkey) {2928    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2929  }2930  return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2931}29322933/** Returns the number of duplicate entries (those that would be removed). */2934export function countDuplicates(): number {2935  const best = new Map<string, VaultEntry>();2936  let dupeCount = 0;2937  for (const e of entries.filter((e) => !e.deletedAt)) {2938    const key = entryDupeKey(e);2939    const existing = best.get(key);2940    if (!existing) {2941      best.set(key, e);2942    } else {2943      dupeCount++;2944      // Keep the more recently modified one in the map2945      if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2946        best.set(key, e);2947      }2948    }2949  }2950  return dupeCount;2951}29522953/**2954 * Soft-deletes duplicate entries, keeping the most recently modified copy of2955 * each group. Returns the number of entries moved to trash.2956 */2957export async function deduplicateEntries(): Promise<number> {2958  if (!derivedKey) throw new Error("vault_locked");2959  // Dedup reshapes slot B permanently under the decoy key, which is2960  // forensically distinguishable from a freshly-seeded decoy vault. Refuse.2961  if (vaultMode === "decoy") {2962    throw new Error("deduplicate disabled in decoy mode");2963  }29642965  const best = new Map<string, VaultEntry>();2966  for (const e of entries.filter((en) => !en.deletedAt)) {2967    const key = entryDupeKey(e);2968    const existing = best.get(key);2969    if (!existing) {2970      best.set(key, e);2971    } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2972      best.set(key, e);2973    }2974  }29752976  const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2977  let removed = 0;2978  entries = entries.map((e) => {2979    if (!e.deletedAt && !keepIds.has(e.id)) {2980      removed++;2981      const deletedAt = monotonicEntryTimestamp(Math.max(2982        e.modifiedAt,2983        tombstones.get(e.id) ?? 0,2984      ));2985      tombstones.set(e.id, deletedAt);2986      return { ...e, deletedAt };2987    }2988    return e;2989  });29902991  if (removed > 0) await persistEntries();2992  return removed;2993}29942995// ---------------------------------------------------------------------------2996// Vault history — snapshot BEFORE every vault write so the saved blob is2997// what is about to be lost, not what was just written. Capped at 5. The2998// stored blobs are AES-256-GCM ciphertext under the master password, so2999// history adds zero security surface.3000// ---------------------------------------------------------------------------30013002const HISTORY_KEY = "peach_vault_history";3003const HISTORY_MAX = 5;30043005// PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the3006// plaintext snapshot field cannot reveal exact vault size or single-entry change3007// deltas. Buckets widen as the vault grows so absolute leakage stays roughly3008// proportional rather than exact.3009function bucketEntryCount(n: number): number {3010  if (n <= 0) return 0;3011  if (n <= 10) return 10;3012  if (n <= 25) return 25;3013  if (n <= 50) return 50;3014  if (n <= 100) return 100;3015  if (n <= 250) return 250;3016  if (n <= 500) return 500;3017  return Math.ceil(n / 500) * 500;3018}30193020/**3021 * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE3022 * overwriting peach_vault_a so the saved blob captures the about-to-be-lost3023 * state. Identical-to-most-recent blobs are skipped to avoid filling history3024 * with no-op writes.3025 */3026export async function takeVaultSnapshot(3027  reason: VaultSnapshot["reason"],3028): Promise<void> {3029  try {3030    const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3031    const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3032    const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];30333034    // Nothing to snapshot if the real slot doesn't exist yet (fresh install,3035    // pre-createVault). createVault skips snapshots intentionally — there is3036    // no prior state to preserve.3037    if (!slotA) return;30383039    const vault_a = JSON.stringify(slotA);3040    const vault_b = JSON.stringify(slotB ?? slotA);30413042    // Best-effort entry count: only meaningful when the in-memory state is3043    // unlocked AND in real mode. Decoy mode would leak the decoy entry3044    // count; locked mode has no entries to count. Both fall through to null.3045    // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage3046    // .local sits next to the AES-GCM blobs in plaintext, so an exact count would3047    // leak vault size and per-write deltas (the metadata padding hides this).3048    let entryCount: number | null = null;3049    if (derivedKey && vaultMode === "real") {3050      try {3051        entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3052      } catch {3053        entryCount = null;3054      }3055    }30563057    const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3058    const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3059      ? existingRaw3060      : { snapshots: [] };30613062    // Deduplication: if the most recent snapshot has byte-identical blobs,3063    // skip — saves the same vault N times in a row would otherwise burn the3064    // entire history window with no recovery value.3065    const latest = history.snapshots[0];3066    if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3067      return;3068    }30693070    const snapshot: VaultSnapshot = {3071      id: crypto.randomUUID(),3072      savedAt: Date.now(),3073      reason,3074      vault_a,3075      vault_b,3076      entryCount,3077    };30783079    history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3080    await chrome.storage.local.set({ [HISTORY_KEY]: history });3081  } catch (err) {3082    // Non-fatal — history must never block a save. A failed snapshot just3083    // means the user loses one rung of the safety ladder for this write.3084    // M4/QUA-006: log so the failure is visible; if it looks like a quota3085    // problem, notify the popup so the user can prune history themselves.3086    console.warn('[Peach] takeVaultSnapshot failed:', err);3087    if (String(err).includes('QUOTA') || String(err).includes('quota')) {3088      try {3089        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3090      } catch {3091        // popup may be closed; surfaced via console.warn above3092      }3093    }3094  }3095}30963097/** Wipe all vault history. Called by Panic / DESTROY_VAULT. */3098export async function clearVaultHistory(): Promise<void> {3099  try {3100    await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3101  } catch {3102    // Non-fatal — DESTROY_VAULT also runs storage.local.clear().3103  }3104}31053106/** Read all snapshots, newest first, for the Settings vault history view. */3107export async function getVaultHistory(): Promise<VaultSnapshot[]> {3108  const raw = await chrome.storage.local.get(HISTORY_KEY);3109  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3110  if (!history || !Array.isArray(history.snapshots)) return [];3111  return history.snapshots;3112}31133114/**3115 * Replace the on-disk vault with the chosen snapshot's blobs and lock the3116 * vault. The current state is itself snapshotted with reason 'restore' first3117 * so the restore is reversible. After this returns, the user must unlock3118 * with the password that matches the restored blob.3119 */3120export async function restoreVaultSnapshot(3121  snapshotId: string,3122): Promise<{ ok: true } | { ok: false; error: string }> {3123  const history = await getVaultHistory();3124  const snapshot = history.find((s) => s.id === snapshotId);3125  if (!snapshot) return { ok: false, error: "snapshot_not_found" };31263127  // Snapshot the about-to-be-replaced current state so the restore is3128  // reversible. takeVaultSnapshot is no-op when the most recent entry is3129  // byte-identical, so a restore-of-a-restore stays clean.3130  await takeVaultSnapshot("restore");31313132  let restoredA: StoredVault;3133  let restoredB: StoredVault;3134  try {3135    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3136    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3137  } catch {3138    return { ok: false, error: "snapshot_corrupt" };3139  }31403141  await writeSlot(SLOT_A, restoredA);3142  await writeSlot(SLOT_B, restoredB);3143  await stampLocalVaultMutation();31443145  // Lock so the user must prove they can decrypt the restored blob. If they3146  // restored a pre-password-change snapshot they discover it immediately at3147  // the lock screen rather than silently holding an inaccessible vault.3148  await lock();31493150  return { ok: true };3151}31523153/**3154 * Rotate the vault's sync identity by generating a FRESH ROOT KEY and3155 * re-wrapping everything under it (MV-8). Because every sync credential —3156 * vault_id, the Noise static keypair, the Ed25519 manifest signing key,3157 * and the per-entry envelope keys — derives from the root key, rotation3158 * makes a lost/stolen device's retained key material useless. All paired3159 * devices must re-pair afterwards.3160 *3161 * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from3162 * the root key, so rotation INVALIDATES the old recovery phrase. The flow3163 * sets peach_recovery_pending so the popup re-presents the new words for3164 * confirmation, same as the initial recovery-word confirmation path.3165 */3166export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3167  if (!derivedKey || vaultMode !== "real") {3168    return { ok: false, error: "vault_locked" };3169  }3170  const slotA = await loadSlot(SLOT_A);3171  if (!slotA) return { ok: false, error: "missing_slot_a" };31723173  // 1. Fresh root key + recovery escrow (new words).3174  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3175    await generateAndWrapRootKey();31763177  // 2. Re-wrap the new root key under the current password-derived key.3178  const newRootKey = unhex(rawRootKey);3179  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);31803181  // 3. Snapshot, then single atomic slot write — both escrow wrappings of3182  //    the new root key land together (same transactional rule as3183  //    changePassword). Entries are untouched: they are sealed under the3184  //    password-derived key, not the root key.3185  await takeVaultSnapshot("local_save");3186  const nextSlotA: StoredVault = {3187    ...slotA,3188    recovery_salt: recoverySalt,3189    recovery_wrapped_root_key: wrappedRootKey,3190    recovery_nonce: wrappedNonce,3191    recovery_tag: wrappedTag,3192    ...pwRoot,3193  };3194  await writeSlot(SLOT_A, nextSlotA);31953196  // 4. Swap in memory only after disk committed.3197  if (rootKey) zeroKey(rootKey);3198  rootKey = newRootKey;31993200  // 5. Old peers' identities derive from the dead root key — drop them all.3201  await clearPairedDevices();32023203  // 6. Force re-presentation of the NEW recovery words on next popup view.3204  await setRecoveryConfirmPending(); // same flag the create-vault flow uses32053206  await stampLocalVaultMutation();3207  return { ok: true };3208}32093210/**3211 * Change the master password. Re-encrypts slot A with a fresh salt + new3212 * derived key. Slot B (duress or noise) is left untouched when duress is3213 * configured — it uses an independent key derived from the duress password —3214 * and regenerated as noise when duress is not configured.3215 */3216export async function changePassword(3217  currentPassword: string,3218  newPassword: string3219): Promise<boolean> {3220  if (!derivedKey || vaultMode !== "real") return false;32213222  // Verify current password before allowing any change3223  const valid = await verifyMainPassword(currentPassword);3224  if (!valid) return false;32253226  const slotA = await loadSlot(SLOT_A);3227  if (!slotA) return false;3228  if (!rootKey) return false;32293230  // Derive fresh key from new password + new random salt3231  const newSalt = generateSalt();3232  const newKey = await deriveKey(newPassword, newSalt);32333234  // Re-encrypt the in-memory entries with the new key3235  const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3236  const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");32373238  // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey3239  // under recovery words and remain untouched — recovery phrase keeps working3240  // across password changes (H4/COR-002).3241  const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);32423243  const finalSlotA: StoredVault = {3244    ...slotA,3245    salt: hex(newSalt),3246    nonce,3247    ciphertext,3248    tag,3249    ...pwRoot,3250  };32513252  // Snapshot the OLD-key blob before overwriting it. If the user later3253  // restores from this snapshot they will need the OLD password — the3254  // post-restore lock surfaces that immediately.3255  await takeVaultSnapshot("local_save");32563257  // Single atomic write: ciphertext, salt, and pw_root_* fields all land in3258  // one storage transaction. An SW kill before this point leaves the old3259  // slot intact; after this point everything tied to the new password is3260  // already on disk together (M6/COR-004).3261  await writeSlot(SLOT_A, finalSlotA);32623263  // Swap the in-memory key synchronously before any further await. If we3264  // threw between the Slot A write and this swap, the next persistEntries()3265  // would overwrite Slot A with old-key ciphertext, locking the user out.3266  zeroKey(derivedKey);3267  derivedKey = newKey;32683269  // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,3270  // the next service-worker restore decrypts the new-key Slot A with the stale3271  // key, fails tryDecryptSlotResult, and silently drops the user to a lock3272  // screen. Re-escrow the new key so restore survives a password change.3273  void saveSessionKey(newKey, "real");32743275  // Regenerate noise for slot B when duress is not in use. Runs after the3276  // key swap so a failure here is a disposable noise regeneration, not a3277  // lockout.3278  if (!duressConfigured) {3279    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3280  }3281  await clearPinUnlockStorage();3282  await stampLocalVaultMutation();32833284  return true;3285}