messages.ts

75.6 KB · raw

1/**2 * messages.ts — the only background message handler.3 *4 * All `chrome.runtime.onMessage` handling for the service worker lives here.5 * Do not handle messages inline in entry.ts, alarms.ts, or any other6 * background file. Every background-bound MessageType in shared/types.ts7 * must have a case below. Content-script-bound message types (PING,8 * SUMMON_AUTOFILL, VAULT_LOCKED) are documented in shared/types.ts and9 * intentionally have no case here — they are sent *to* content scripts.10 */1112import { copyToClipboard, getEffectiveClearSeconds } from "./clipboard";13import { getPendingSyncAlerts, clearPendingSyncAlerts } from "./sync-alerts";14import { extractDomain } from "../shared/domain";15import { computeVaultId } from "../shared/sync-identity";16import {17  getPendingVouchedDevice,18  listPendingVouchedDevices,19  removePendingVouchedDevice,20} from "./pending-vouched-devices";21import { isRecoveryConfirmPending } from "./recovery-pending";22import { setupAutoLock, touchAutoLock } from "./alarms";23import { runSelfTest } from "./self-test";24import * as vault from "./vault";25import {26  handleEstimateCodexPages,27  handleGetCodexVaultData,28  handleRestoreFromCodex,29} from "./codex-handlers";30import type { CodexRestorePayload } from "../shared/codex-types";31import {32  activateLicense,33  deactivateLicense,34  getLicenseInfo,35  getLicenseKey,36  isProUser,37  markCelebrationPlayed,38  shouldPlayCelebration,39} from "./license";40import {41  completeFirefoxPasskeyCreate,42  getPasskeyProviderState,43  getPendingPasskeyRequest,44  handleFirefoxPasskeyCreate,45  handleFirefoxPasskeyGet,46  handlePasskeyHasCredentials,47  initiateFirefoxPasskeyCreate,48  isPasskeyOfferDeclined,49  onVaultUnlocked,50  recordPasskeyOfferDecline,51  resolvePendingPasskeyRequest,52  setPasskeyProviderEnabled,53} from "./passkey";54import { buildAdvancedHealthReport } from "./health";55import { storageClear } from "../shared/storage";56import { findInvalidWords } from "../shared/recovery-words";57import type {58  Message,59  MessageResponse,60  MessageType,61  ProFeature,62  VaultEntry,63} from "../shared/types";64import { isPasskeyOnly } from "../shared/types";65import { generateTOTP } from "../crypto/totp";66import { isValidRpId } from "../shared/passkey-utils";67import { isNativePlatform, nativePlugin } from "../shared/native";68import { predictBatch as mlPredictBatch } from "./ml";69import type { MLFieldFeatures } from "../shared/ml";70import {71  buildLocalIdentity,72  getOrCreateDeviceId,73  setDeviceName as setDeviceNameV3,74} from "./device-identity";75import {76  clearDeviceRevocation,77  isDeviceRevoked,78  listPairedDevices as listPairedDevicesV3,79  revokeDeviceById as revokeDeviceByIdV3,80  upsertPairedDevice as upsertPairedDeviceV3,81} from "./paired-devices";82// Sync and pairing are imported statically, never with `await import()`.83// `import()` throws `TypeError: import() is disallowed on84// ServiceWorkerGlobalScope` at runtime in MV3, and Vite's preload helper85// reports the failure through `window.dispatchEvent`, which does not exist in86// a worker either — so the real reason was replaced by "window is not defined"87// and the pairing UI just stopped. scripts/check-sw-static-imports.mjs fails88// the build if a runtime import() reappears anywhere in the worker's graph.89import {90  cancelRelayAdoptionSource,91  isRelayAdoptionSourceInFlight,92  resolveSourceSasConfirmation,93  startRelayAdoptionReceiver,94  startRelayAdoptionSource,95} from "./relay-adoption-bg";96import {97  getSyncSettings,98  setRelayUrl,99  setSyncMode,100  type SyncMode,101} from "./sync-settings";102import {103  getSyncState,104  hydratePersistedSyncState,105  setSyncRunner,106  triggerSyncIfReachable,107} from "./sync-trigger";108import { productionSyncRunner } from "./sync-runner";109110// Compile-time E2E flag, folded to a literal by Vite's `define` (false in111// every production build — verified in the bundled output). Mirrors the112// create-path bypass in passkey.ts so the Playwright passkey smoke can enable113// the provider without a real Pro licence; the gate is fully intact for users.114declare const __PEACH_E2E_PASSKEYS__: boolean;115const PASSKEY_E2E_PRO_BYPASS = __PEACH_E2E_PASSKEYS__ === true;116117let nativeSyncRunnerBootstrapped = false;118119function unhexBytes(hex: string): Uint8Array {120  if (hex.length % 2 !== 0) throw new Error("invalid hex");121  const out = new Uint8Array(hex.length / 2);122  for (let i = 0; i < out.length; i++) {123    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);124  }125  return out;126}127128/**129 * The single Pro gate. Every gated handler calls this first; the broadcast130 * lets the popup react (navigate to the License section, highlight the131 * relevant feature). Background never touches the popup view directly —132 * the popup owns navigation. See Projects.md "Pro gate rule".133 */134async function requirePro(135  feature: ProFeature,136  trigger: "user" | "background" = "user"137): Promise<{ allowed: boolean }> {138  if (PASSKEY_E2E_PRO_BYPASS && feature === "passkeys") return { allowed: true };139  if (await isProUser()) return { allowed: true };140141  if (trigger === "user") {142    // User deliberately triggered this feature — redirect the popup immediately.143    chrome.runtime144      .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })145      .catch(() => {146        // Popup may be closed — the gate signal is best-effort.147      });148  } else {149    // Background trigger (e.g. passive sync initiation) — no popup is open.150    // Queue a notice so the next popup open shows a soft banner instead of151    // silently failing.152    if (chrome.storage.session) {153      chrome.storage.session154        .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })155        .catch(() => {});156    }157  }158159  return { allowed: false };160}161162// ---------------------------------------------------------------------------163// PCH-01-018: operations that irreversibly destroy or replace vault contents,164// plus handlers that export key material or register paired devices. These are165// gated to trusted extension-page senders in handleMessage; the confirmation166// UX otherwise lives only in the popup. Keep this in sync when adding new167// destructive or sensitive handlers.168// Codex Security 2026-08-04 (HIGH): GET_ADOPTION_MATERIAL (returns the root169// key) and COMPLETE_PAIRING (registers a device, lifts its revocation, and170// triggers sync) were reachable from any content script on isUnlocked() alone;171// COMPLETE_FIREFOX_PASSKEY_CREATE (MEDIUM) stored a passkey for a172// caller-chosen rpId with no ceremony binding. All three now require a173// trusted extension-page sender; GET_ADOPTION_MATERIAL/COMPLETE_PAIRING have174// no legitimate runtime senders (adoption runs in-background via175// relay-adoption-bg.ts) and are deletion candidates post-release.176const PRIVILEGED_MESSAGE_TYPES: ReadonlySet<string> = new Set([177  "DESTROY_VAULT",178  "CLEAR_VAULT_ENTRIES",179  "RESTORE_VAULT_SNAPSHOT",180  "RESTORE_FROM_CODEX",181  "GET_ADOPTION_MATERIAL",182  "COMPLETE_PAIRING",183  "COMPLETE_FIREFOX_PASSKEY_CREATE",184]);185186// LV-3: minimum interval between non-test canary pings per webhook+entry.187const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour188189const PRIVATE_IPV4_RANGES = [190  { prefix: "10." },191  { prefix: "127." },192  { prefix: "169.254." },193  { prefix: "192.168." },194];195196export function setupMessageHandler(): void {197  chrome.runtime.onMessage.addListener(198    (msg: unknown, sender, sendResponse) => {199      if (!isMessage(msg)) {200        sendResponse({ ok: false, error: "invalid_message" });201        return true;202      }203      handleMessage(msg, sender)204        .then(sendResponse)205        .catch((err) =>206          sendResponse({207            ok: false,208            error: err instanceof Error ? err.message : String(err),209          })210        );211      return true;212    }213  );214215  // Android: the chrome shim queues sendMessage calls until this handler is216  // registered. Without this hookup, every message hangs on Capacitor builds.217  // eslint-disable-next-line @typescript-eslint/no-explicit-any218  const reg = (globalThis as any).__peachRegisterMessageHandler;219  if (typeof reg === "function") {220    reg(async (m: unknown) => {221      if (!isMessage(m)) return { ok: false, error: "invalid_message" };222      try {223        return await handleMessage(m);224      } catch (err) {225        return {226          ok: false,227          error: err instanceof Error ? err.message : String(err),228        };229      }230    });231  }232  if (isNativePlatform()) {233    void bootstrapNativeSyncRunner();234  }235}236237async function bootstrapNativeSyncRunner(): Promise<void> {238  if (nativeSyncRunnerBootstrapped) return;239  nativeSyncRunnerBootstrapped = true;240  try {241    setSyncRunner(productionSyncRunner);242    await hydratePersistedSyncState();243  } catch (err) {244    nativeSyncRunnerBootstrapped = false;245    console.warn("[Peach] failed to bootstrap native sync runner:", err);246  }247}248249function isMessage(m: unknown): m is Message {250  return typeof m === "object" && m !== null && "type" in m;251}252253/**254 * The browser-attested origin of the page that originated this message. A web255 * page cannot call chrome.runtime.sendMessage directly (no externally_connectable),256 * so every page-driven message arrives via the content-script relay and the257 * browser stamps `sender.origin`/`sender.url` with the real frame origin — a258 * value the page cannot forge. This is the ONLY trustworthy origin for passkey259 * rpId<->origin enforcement; payload-supplied origins are attacker-controlled.260 */261function attestedSenderOrigin(sender: Partial<chrome.runtime.MessageSender>): string | undefined {262  if (sender.origin) return sender.origin;263  if (sender.url) {264    try { return new URL(sender.url).origin; } catch { return undefined; }265  }266  return undefined;267}268269// Resolves once restoreFromSession() has run, so handlers see the correct270// post-restore unlock state. entry.ts registers this BEFORE the message271// handler so the onMessage listener is added synchronously at SW startup272// (MV3 requirement) while unlock-dependent handlers still wait for restore.273let restoreReady: Promise<void> = Promise.resolve();274275/** Called by entry.ts at startup with the in-flight restoreFromSession promise. */276export function setRestoreReady(p: Promise<unknown>): void {277  restoreReady = p.then(() => undefined, () => undefined);278}279280export async function handleMessage(281  msg: Message,282  sender: Partial<chrome.runtime.MessageSender> = {}283): Promise<MessageResponse> {284  try {285    // Wait for session-restore to finish before reading any unlock/vault state.286    // On a cold SW start the popup's first message arrives while restore is287    // still in flight; without this gate IS_UNLOCKED would report locked (a288    // spurious lock screen) and the storage reads could race. The onMessage289    // listener itself is registered synchronously (entry.ts) so this gate, not290    // listener registration, is what absorbs the cold-start window.291    await restoreReady;292    // Validate sender — only allow messages from our extension pages/scripts.293    // `sender.id` is undefined for messages originating from extension UI294    // (popup/options), which is legitimate; reject only when it is defined295    // and does not match our extension id.296    if (297      sender.id !== undefined &&298      sender.id !== chrome.runtime.id299    ) {300      return { ok: false, error: "invalid_sender" };301    }302303    // PCH-01-018: destructive and sensitive operations must originate from a304    // trusted extension page (popup/options), never from a content script. The305    // sender.id check above only proves the message came from *our* extension —306    // a content script injected into an attacker page passes it. A single307    // XSS-class bug in the injected UI would otherwise escalate to vault308    // destruction, root-key exfiltration, or attacker device registration.309    // Extension pages carry an extension-origin sender.url; content scripts310    // carry a page URL.311    if (PRIVILEGED_MESSAGE_TYPES.has(msg.type)) {312      const extensionOrigin = chrome.runtime.getURL("");313      if (!sender.url?.startsWith(extensionOrigin)) {314        return { ok: false, error: "invalid_sender" };315      }316    }317318    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {319      touchAutoLock();320    }321322    switch (msg.type) {323      case "CREATE_VAULT": {324        const { password } = msg.payload as { password: string };325        // Refuse to create a vault under an empty master password — the derived326        // key would be publicly known and the at-rest AES-GCM protection void327        // (audit HIGH; the CSV-import path previously passed ""). Length/strength328        // policy lives in the onboarding UI; this is the non-negotiable floor.329        if (typeof password !== "string" || password.length === 0) {330          return { ok: false, error: "invalid_payload" };331        }332        await vault.createVault(password);333        await setupAutoLock();334        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });335        onVaultUnlocked();336        return { ok: true };337      }338      case "CREATE_VAULT_FROM_ROOT_KEY": {339        console.log("[Peach diag] CREATE_VAULT_FROM_ROOT_KEY handler entered");340        const { password, rootKeyHex } = (msg.payload ?? {}) as {341          password?: unknown;342          rootKeyHex?: unknown;343        };344        console.log("[Peach diag] payload types:", typeof password, typeof rootKeyHex, "lengths:", (password as string)?.length, (rootKeyHex as string)?.length);345        if (typeof password !== "string" || password.length === 0 || typeof rootKeyHex !== "string") {346          console.log("[Peach diag] payload invalid, returning invalid_payload");347          return { ok: false, error: "invalid_payload" };348        }349350        try {351          console.log("[Peach diag] calling vault.createVaultFromRootKey");352          const { vaultId } = await vault.createVaultFromRootKey(password, rootKeyHex);353          console.log("[Peach diag] createVaultFromRootKey returned vaultId", vaultId);354          await setupAutoLock();355          console.log("[Peach diag] setupAutoLock done");356          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });357          onVaultUnlocked();358          console.log("[Peach diag] handler completing successfully");359          return { ok: true, vaultId };360        } catch (err) {361          console.error("[Peach diag] handler caught error:", err);362          return { ok: false, error: err instanceof Error ? err.message : String(err) };363        }364      }365      case "UNLOCK": {366        const { password } = msg.payload as { password: string };367        const ok = await vault.unlock(password);368        if (ok) {369          // KAT: verify the WebCrypto stack is healthy before the user relies on it.370          // Runs after unlock so latency is hidden behind the vault-load time.371          const kat = await runSelfTest();372          if (!kat.ok) {373            console.error("[Peach] Crypto self-test FAILED:", kat.error);374            await vault.lock();375            return { ok: false, error: "self_test_failed" };376          }377          await setupAutoLock();378          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });379          onVaultUnlocked();380          if (isNativePlatform()) await publishInMemoryVault(vault.getEntries());381          return { ok: true };382        }383        // Distinguish corruption from wrong password so the popup can offer384        // a history-restore path instead of falsely accusing the user of385        // mistyping (M7/COR-007).386        const failure = vault.consumeLastUnlockFailureReason();387        if (failure) {388          return {389            ok: false,390            error: "vault_corrupted",391            canRestoreFromHistory: true,392          } as MessageResponse;393        }394        return { ok: false };395      }396      case "VALIDATE_RECOVERY_WORDS": {397        const { words } = msg.payload as { words: string[] };398        const invalid = findInvalidWords(words);399        return { ok: true, data: { valid: invalid.length === 0, invalid } };400      }401      case "GET_RECOVERY_WORDS": {402        // Must be unlocked and have recovery set up (vault must be v2.12+).403        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };404        // LV-4: the recovery words re-derive the ROOT KEY — the highest-405        // value secret in the vault. A session unlock (which may be hours406        // old on a shared machine) is not enough; require a fresh password407        // proof, matching the 1Password/Bitwarden pattern for revealing408        // escrow material. EXEMPTION: the create-vault / rotation confirmation409        // window (peach_recovery_pending) shows the words immediately after410        // the user authenticated, and that confirmation UI has no password to411        // resend — skip the re-auth there only.412        if (!(await isRecoveryConfirmPending())) {413          const { password } = (msg.payload ?? {}) as { password?: string };414          if (typeof password !== "string" || !password) {415            return { ok: false, error: "password_required" };416          }417          if (!(await vault.verifyMainPassword(password))) {418            return { ok: false, error: "wrong_password" };419          }420        }421        try {422          const words = await vault.getRecoveryWords();423          return { ok: true, data: words };424        } catch (e) {425          // pre-v2.12 vault that hasn't migrated426          return { ok: false, error: "recovery_not_setup" };427        }428      }429      case "RECOVER_WITH_WORDS": {430        // User has lost their password but has their 12 recovery words.431        // We: (1) validate the words, (2) re-encrypt vault with new password.432        const { words, newPassword } = msg.payload as {433          words: string[];434          newPassword: string;435        };436        const stored = await vault.loadSlotA();437        if (!stored) return { ok: false, error: "no_vault" };438        if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {439          return { ok: false, error: "recovery_not_setup" };440        }441        // Unwrap the root key from the recovery escrow.442        try {443          await vault.unwrapRootKeyForRecovery(444            stored.recovery_wrapped_root_key,445            stored.recovery_nonce ?? "",446            stored.recovery_tag ?? "",447            stored.recovery_salt,448            words449          );450        } catch {451          return { ok: false, error: "invalid_recovery_words" };452        }453        // Re-key: derive fresh salt + key from new password.454        const newSalt = vault.generateSalt();455        const newKey = await vault.deriveKey(newPassword, newSalt);456        // Re-wrap root key under the new password's derived key so the same457        // recovery words continue to work after the password change.458        await vault.rewrapRootKeyForPassword(newKey, newSalt);459        // Re-encrypt vault entries with the new key.460        await vault.changePasswordWithNewKey(newSalt, newKey);461        await setupAutoLock();462        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });463        return { ok: true };464      }465      case "LOCK":466        vault.lock();467        if (isNativePlatform()) await clearInMemoryVault();468        return { ok: true };469      case "IS_UNLOCKED":470        return { ok: true, data: vault.isUnlocked() };471      case "GET_VAULT_STATUS":472        return { ok: true, unlocked: vault.isUnlocked() };473      case "HAS_VAULT":474        return { ok: true, data: await vault.hasVault() };475      case "GET_ENTRIES":476        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };477        return { ok: true, data: vault.getEntries() };478      case "ADD_ENTRY": {479        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };480        const payload = msg.payload;481        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {482          return { ok: false, error: "invalid_payload" };483        }484        const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);485        return { ok: true, data: entry };486      }487      case "BATCH_ADD_ENTRIES": {488        const payload = msg.payload as { entries?: unknown } | null | undefined;489        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {490          return { ok: false, error: "invalid_payload" };491        }492        if (!Array.isArray(payload.entries)) {493          return { ok: false, error: "invalid_payload" };494        }495        const added = await vault.batchAddEntries(payload.entries as Parameters<typeof vault.batchAddEntries>[0]);496        return { ok: true, data: added };497      }498      case "RESTORE_ENTRIES": {499        const payload = msg.payload as500          | { entries?: unknown; mode?: unknown; settings?: unknown }501          | null502          | undefined;503        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {504          return { ok: false, error: "invalid_payload" };505        }506        if (!Array.isArray(payload.entries)) {507          return { ok: false, error: "invalid_payload" };508        }509        if (payload.mode !== "replace" && payload.mode !== "merge") {510          return { ok: false, error: "invalid_payload" };511        }512        const summary = await vault.restoreEntries(513          payload.entries as Parameters<typeof vault.restoreEntries>[0],514          payload.mode515        );516        if (payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)) {517          await vault.updateSettings(payload.settings as vault.VaultSettings);518        }519        return { ok: true, data: summary };520      }521      case "CLEAR_VAULT_ENTRIES": {522        await vault.clearVaultEntries("REPLACE_ALL");523        return { ok: true };524      }525      case "GET_VAULT_HISTORY": {526        const snapshots = await vault.getVaultHistory();527        return { ok: true, data: snapshots };528      }529      case "RESTORE_VAULT_SNAPSHOT": {530        const { snapshotId } = msg.payload as { snapshotId: string };531        const result = await vault.restoreVaultSnapshot(snapshotId);532        if (!result.ok) return { ok: false, error: result.error };533        broadcastToContentScripts({ type: "VAULT_LOCKED" });534        return { ok: true };535      }536      case "COUNT_DUPLICATES": {537        return { ok: true, data: vault.countDuplicates() };538      }539      case "DEDUPLICATE_ENTRIES": {540        const removed = await vault.deduplicateEntries();541        return { ok: true, data: removed };542      }543      case "UPDATE_ENTRY": {544        const payload = msg.payload as { id?: unknown; patch?: unknown } | null | undefined;545        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {546          return { ok: false, error: "invalid_payload" };547        }548        const { id, patch } = payload;549        if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {550          return { ok: false, error: "invalid_payload" };551        }552        // Single enforcement point for the Canary Entries Pro gate. Only553        // gates the transition into a canary entry — clearing/disabling a554        // canary (`canary: undefined`) is always allowed, so existing555        // canary entries remain manageable.556        const incomingCanary = (patch as { canary?: unknown }).canary;557        if (incomingCanary && typeof incomingCanary === "object") {558          const existing = vault.getEntryById(id);559          if (!existing?.canary) {560            const gate = await requirePro("canary_entry");561            if (!gate.allowed) return { ok: false, error: "pro_required" };562          }563        }564        await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);565        return { ok: true };566      }567      case "ADD_ADDITIONAL_URL": {568        const { id, url } = msg.payload as { id: string; url: string };569        const trimmed = typeof url === "string" ? url.trim() : "";570        if (!trimmed) return { ok: false, error: "url_required" };571        const existing = vault.getEntryById(id);572        if (!existing) return { ok: false, error: "entry_not_found" };573        if (existing.type !== "login") return { ok: false, error: "login_entries_only" };574        const merged = [...(existing.additionalUrls ?? []), trimmed];575        await vault.updateEntry(id, { additionalUrls: merged });576        return { ok: true };577      }578      case "DELETE_ENTRY": {579        const { id } = msg.payload as { id: string };580        await vault.deleteEntry(id);581        return { ok: true };582      }583      case "GET_DELETED_ENTRIES":584        return { ok: true, data: vault.getDeletedEntries() };585      case "RESTORE_ENTRY": {586        const { id } = msg.payload as { id: string };587        await vault.restoreEntry(id);588        return { ok: true };589      }590      case "PURGE_ENTRY": {591        const { id } = msg.payload as { id: string };592        await vault.purgeEntry(id);593        return { ok: true };594      }595      case "PURGE_EXPIRED_TRASH": {596        const { retentionMs } = msg.payload as { retentionMs: number };597        const purged = await vault.purgeExpiredTrash(retentionMs);598        return { ok: true, data: purged };599      }600      case "SAVE_NOTE": {601        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };602        const { id, title, body } = msg.payload as { id?: string; title: string; body: string };603        const now = Date.now();604        if (id) {605          const existing = vault.getEntryById(id);606          if (!existing) return { ok: false, error: "not_found" };607          await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });608          return { ok: true, data: vault.getEntryById(id) };609        } else {610          // PCH-01-026 #10: addEntry stamps its own createdAt/modifiedAt611          // (EntryDraft omits both), so passing them was the only reason the612          // `as any` cast existed. Dropping them makes the call type-check.613          const entry = await vault.addEntry({614            type: "note",615            title: title || "Untitled",616            notes: body,617          });618          return { ok: true, data: entry };619        }620      }621      case "DELETE_NOTE": {622        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };623        const { id } = msg.payload as { id: string };624        await vault.deleteEntry(id);625        return { ok: true };626      }627      case "GET_NOTES": {628        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };629        const notes = vault.getEntries().filter(630          (e) => e.type === "note" && !e.deletedAt631        );632        return { ok: true, data: notes } as MessageResponse;633      }634      case "SEARCH_VAULT": {635        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };636        // AUD-002: shape-validate the payload before calling string methods.637        // A raw cast lets a malformed message crash the handler with TypeError.638        const { query } = (msg.payload ?? {}) as { query?: unknown };639        if (typeof query !== "string") {640          return { ok: false, error: "invalid_payload" };641        }642        const q = query.toLowerCase();643        const searchResults = vault.getEntries().filter(644          (e) =>645            !e.deletedAt &&646            (e.title.toLowerCase().includes(q) ||647              e.notes?.toLowerCase().includes(q) ||648              e.username?.toLowerCase().includes(q))649        );650        return { ok: true, data: searchResults } as MessageResponse;651      }652      case "AUTOFILL_QUERY": {653        if (!vault.isUnlocked()) {654          return { ok: false, error: "vault_locked" };655        }656        let { includeAll } = msg.payload as { includeAll?: boolean };657        const { domain, kind, userGesture } = msg.payload as {658          domain: string;659          kind?: "login" | "card" | "totp";660          userGesture?: boolean;661        };662        // MV-2: the vault-wide inventory (every title/username/email/url)663        // may only leave the background on an explicit user gesture — the664        // user opening the search affordance — never on passive form focus.665        // userGesture is self-declared by our own content script; the value666        // of this gate is that PASSIVE code paths (which never set it)667        // cannot ship the inventory, shrinking what a compromised page that668        // merely triggers focus events can observe.669        if (includeAll && userGesture !== true) {670          includeAll = false;671        }672        // kind="card": return saved cards as compact summaries for the673        // inline card silver platter. Cards are not domain-scoped — every674        // saved card is a candidate on every checkout page — so domain is675        // ignored when kind === "card".676        if (kind === "card") {677          const cards = vault.getEntries().filter((e) => e.type === "card");678          return {679            ok: true,680            data: cards.map((e) => ({681              id: e.id,682              title: e.cardNickname || e.title,683              cardholderName: e.cardholderName,684              last4: e.last4,685              expMonth: e.expMonth,686              expYear: e.expYear,687              network: e.network,688            })),689          };690        }691        if (kind === "totp") {692          const matches = (693            includeAll694              ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")695              : vault.queryDomain(domain)696          ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));697          return {698            ok: true,699            data: matches.map((e) => ({700              id: e.id,701              title: e.title,702              username: e.username,703              email: e.email,704              url: e.url,705              hasTotp: true,706            })),707          };708        }709        // The inline selector fills username+password into a form. Passkey-710        // only entries have no password to fill; sign-in for those goes711        // through the WebAuthn path instead. Filter them out everywhere so712        // the user doesn't see four "GitHub" rows that mostly do nothing.713        // Notes and cards never belonged here either.714        const matches = (715          includeAll716            ? vault717                .getEntries()718                .filter((entry) => entry.type !== "note" && entry.type !== "card")719            : vault.queryDomain(domain)720        ).filter((entry) => !isPasskeyOnly(entry));721        return {722          ok: true,723          data: matches.map((e) =>724            includeAll725              ? {726                  id: e.id,727                  title: e.title,728                  // username/email/url shipped so the inline popup's search729                  // affordance can match on them, not just titles. No secrets730                  // cross the boundary — same shape as the domain-matched731                  // response below minus hasTotp (which the search list732                  // doesn't surface).733                  username: e.username,734                  email: e.email,735                  url: e.url,736                  hasPasskey: e.passkey !== undefined,737                }738              : {739                  id: e.id,740                  title: e.title,741                  username: e.username,742                  email: e.email,743                  url: e.url,744                  hasTotp: Boolean(e.totp),745                  hasPasskey: e.passkey !== undefined,746                }747          ),748        };749      }750      case "GET_ENTRY_FOR_FILL": {751        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };752        const { id, explicitSummon } = msg.payload as {753          id: string;754          explicitSummon?: boolean;755        };756        const entry = vault.getEntryById(id);757        if (!entry) return { ok: false, error: "entry_not_found" };758        // Passkey-only entries (passkey credential, no password) cannot be759        // filled into a form — sign-in goes through the WebAuthn path. A760        // login that ALSO has a passkey is still fillable for its password.761        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };762763        // MV-1: re-validate the entry against the requesting tab's origin.764        // sender.tab.url is browser-attested — a page cannot forge it. When765        // the request does not come from a tab at all (popup surfaces use766        // GET_ENTRY_FOR_EDIT, not this), refuse.767        if (sender.tab !== undefined || sender.url !== undefined) {768          let senderDomain: string | null = null;769          try {770            const rawUrl = sender.tab?.url ?? sender.url ?? "";771            senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;772          } catch {773            senderDomain = null;774          }775          if (!senderDomain) return { ok: false, error: "origin_unverified" };776777          const entryDomains = new Set<string>();778          const primary = extractDomain(entry.url ?? "");779          if (primary) entryDomains.add(primary);780          for (const extra of entry.additionalUrls ?? []) {781            const d = extractDomain(extra);782            if (d) entryDomains.add(d);783          }784785          // explicitSummon=true means the user actively picked this entry786          // from the cross-domain search affordance — an intentional787          // off-domain fill. Passive fill paths must stay domain-bound.788          if (explicitSummon !== true && !entryDomains.has(senderDomain)) {789            // Domain mismatch — refuse silently (no oracle about which ids exist).790            return { ok: false, error: "entry_not_found" };791          }792        }793        // Strip the passkey credential AND the raw base32 TOTP seed from the794        // response. Content scripts never need either: the private key is the795        // authenticator's, and handing over the TOTP seed would let a796        // compromised content script mint valid codes indefinitely (SEV-3-C).797        // Generate the *current* 6-digit code here in the background and send798        // only that, plus the seconds left so the UI can render a countdown.799        // Also drop passwordHistory: filling needs only the *current* password.800        // Handing every past password to a content script during passive801        // autofill on an arbitrary page would widen the blast radius of a802        // compromised page far beyond the one credential being filled.803        const {804          passkey: _strippedPasskey,805          totp: seed,806          totpAlgorithm,807          totpDigits,808          totpPeriod,809          passwordHistory: _ph,810          ...safeEntry811        } = entry;812        let totpCode: string | undefined;813        let totpRemaining: number | undefined;814        if (seed) {815          try {816            // PCH-01-019: honour stored algorithm/digits/period (defaults apply817            // when these are undefined, i.e. legacy bare-seed entries).818            const generated = generateTOTP(seed, totpAlgorithm, totpDigits, totpPeriod);819            totpCode = generated.code;820            totpRemaining = generated.remaining;821          } catch {822            totpCode = undefined;823          }824        }825        return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };826      }827      // Trusted full-entry fetch for Peach's own inline-popup surfaces that828      // genuinely need the raw TOTP seed: the edit form (which re-saves the829      // seed and would otherwise wipe it) and nothing else. Unlike830      // GET_ENTRY_FOR_FILL this fires only on an explicit user action inside831      // the inline popup, never during passive autofill on an arbitrary page.832      // The passkey credential is still stripped — the edit form never touches it.833      case "GET_ENTRY_FOR_EDIT": {834        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };835        const { id, explicitSummon } = msg.payload as {836          id: string;837          explicitSummon?: boolean;838        };839        const entry = vault.getEntryById(id);840        if (!entry) return { ok: false, error: "entry_not_found" };841842        // PCH-01-006: re-validate the browser-attested sender origin, mirroring843        // GET_ENTRY_FOR_FILL (MV-1). The edit channel ships the raw TOTP seed and844        // full password history — at least as sensitive as the fill response — so845        // the boundary must be enforced here, not only by UI reachability. Today846        // the sole caller is the popup (no sender.tab, so this is a structural847        // no-op), but a future tab-context caller or an id leak would otherwise848        // inherit a full-credential oracle. Unlike the fill path we SKIP (not849        // refuse) when no sender domain resolves, to keep the popup caller working.850        if (sender.tab !== undefined || sender.url !== undefined) {851          let senderDomain: string | null = null;852          try {853            const rawUrl = sender.tab?.url ?? sender.url ?? "";854            senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;855          } catch {856            senderDomain = null;857          }858          if (senderDomain) {859            const entryDomains = new Set<string>();860            const primary = extractDomain(entry.url ?? "");861            if (primary) entryDomains.add(primary);862            for (const extra of entry.additionalUrls ?? []) {863              const d = extractDomain(extra);864              if (d) entryDomains.add(d);865            }866            if (explicitSummon !== true && !entryDomains.has(senderDomain)) {867              return { ok: false, error: "entry_not_found" }; // no id oracle868            }869          }870        }871872        const { passkey: _passkey, ...safeEntry } = entry;873        return { ok: true, data: safeEntry };874      }875      case "COPY": {876        // AUD-002: shape-validate the payload before passing to the clipboard877        // helper. A raw cast lets a malformed message reach the clipboard API878        // with a non-string and throw.879        const { text, label, alreadyWritten } = (msg.payload ?? {}) as {880          text?: unknown;881          label?: unknown;882          alreadyWritten?: unknown;883        };884        if (typeof text !== "string" || typeof label !== "string") {885          return { ok: false, error: "invalid_payload" };886        }887        await copyToClipboard(text, label, Boolean(alreadyWritten));888        return { ok: true, clearSeconds: getEffectiveClearSeconds() };889      }890      case "CHECK_ALL_BREACHES": {891        const allEntries = vault.getEntries();892        const results: Record<string, number> = {};893        for (const entry of allEntries) {894          if (entry.type === "note") continue;895          if (!entry.password) continue;896          results[entry.id] = await vault.checkBreach(entry.password);897          // Small delay — be a good citizen to the HIBP API898          await new Promise((r) => setTimeout(r, 200));899        }900        // Persist any non-zero counts back to entries901        for (const [id, count] of Object.entries(results)) {902          const entry = vault.getEntryById(id);903          if (entry && (entry.breach ?? 0) !== count) {904            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });905          }906        }907        return { ok: true, data: results };908      }909      case "GET_SETTINGS":910        return { ok: true, data: await vault.ensureSettingsLoaded() };911      case "UPDATE_SETTINGS": {912        const patch = msg.payload;913        if (!patch || typeof patch !== "object" || Array.isArray(patch)) {914          return { ok: false, error: "invalid_payload" };915        }916        await vault.updateSettings(patch as vault.VaultSettings);917        if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {918          await setupAutoLock();919          if (vault.isUnlocked()) touchAutoLock();920        }921        return { ok: true };922      }923      case "GET_PENDING_PASSKEY":924        return { ok: true, data: await getPendingPasskeyRequest() };925      case "APPROVE_PASSKEY": {926        const { requestId } = msg.payload as { requestId: number };927        await resolvePendingPasskeyRequest(requestId, true);928        return { ok: true };929      }930      case "DENY_PASSKEY": {931        const { requestId } = msg.payload as { requestId: number };932        await resolvePendingPasskeyRequest(requestId, false);933        return { ok: true };934      }935      case "GET_PASSKEY_PROVIDER_STATE":936        return { ok: true, data: await getPasskeyProviderState() };937      case "SET_PASSKEY_PROVIDER_ENABLED": {938        const { enabled } = msg.payload as { enabled: boolean };939        // Disabling is always free; gating fires only when a free user is940        // trying to turn passkey interception on.941        if (enabled) {942          const gate = await requirePro("passkeys");943          if (!gate.allowed) return { ok: false, error: "pro_required" };944        }945        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };946      }947      // AUD-003: this handler POSTs `entryTitle` to a user-configured HTTPS948      // endpoint. Intentional product behavior — users configure their own949      // dead-man's-switch. isAllowedCanaryWebhookUrl blocks localhost, private950      // IPs, and non-HTTPS schemes, but cannot defend against every DNS-951      // rebinding case. The UI under CanarySection (Entry.tsx) discloses to952      // users that the entry title leaves the device when the canary fires.953      case "FIRE_CANARY_PING": {954        // Canary alert fires unconditionally for any entry with canary: true.955        // The Pro gate only governs creation/flagging of canary entries; once956        // an entry is flagged, the alert path is a security property and must957        // never be license-gated.958        const { webhookUrl, entryTitle, trigger } = msg.payload as {959          webhookUrl: string;960          entryTitle: string;961          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";962        };963        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {964          return { ok: false, error: "invalid_webhook_url" };965        }966        // LV-3: debounce non-test pings per (webhook, entry) so a page that967        // strobes focus/fill events can't turn the canary into a request968        // firehose at the user's endpoint. Test pings are exempt — they are969        // explicit user clicks in the settings UI and are awaited for970        // feedback. The FIRST ping in a window always goes out, so the971        // dead-man's-switch property (canary fires on access) is preserved;972        // only repeats within the window are coalesced.973        if (trigger !== "test") {974          const rlKey = `canary-last-fetch:${webhookUrl}:${entryTitle}`;975          const stored = await chrome.storage.local.get(rlKey);976          const last = (stored as Record<string, unknown>)[rlKey];977          if (typeof last === "number" && Date.now() - last < CANARY_MIN_INTERVAL_MS) {978            return { ok: true, data: { rateLimited: true } };979          }980          await chrome.storage.local.set({ [rlKey]: Date.now() });981        }982        const body = JSON.stringify({983          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",984          entry: entryTitle,985          trigger,986          timestamp: new Date().toISOString(),987          source: "peach-extension",988        });989        if (trigger === "test") {990          // Permitted outbound fetch: user-configured canary alert endpoint991          // (validated by isAllowedCanaryWebhookUrl above). This is the992          // only non-HIBP outbound call in the extension — see993          // Projects.md §3.7. Test pings are awaited so the UI can show994          // success/failure.995          try {996            const res = await fetch(webhookUrl, {997              method: "POST",998              headers: { "Content-Type": "application/json" },999              body,1000            });1001            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1002          } catch (err) {1003            return { ok: false, error: err instanceof Error ? err.message : "network_error" };1004          }1005        }1006        // Non-test triggers are fire-and-forget1007        fetch(webhookUrl, {1008          method: "POST",1009          headers: { "Content-Type": "application/json" },1010          body,1011        }).catch(() => {});1012        return { ok: true };1013      }1014      case "ACTIVATE_LICENSE": {1015        const { key } = msg.payload as { key: string };1016        const licenseInfo = await activateLicense(key);1017        return { ok: true, data: licenseInfo };1018      }1019      case "GET_LICENSE": {1020        const licenseInfo = await getLicenseInfo();1021        return { ok: true, data: licenseInfo };1022      }1023      case "GET_LICENSE_KEY": {1024        const key = await getLicenseKey();1025        return { ok: true, data: key };1026      }1027      case "DEACTIVATE_LICENSE": {1028        await deactivateLicense();1029        return { ok: true };1030      }1031      case "SHOULD_PLAY_CELEBRATION": {1032        const should = await shouldPlayCelebration();1033        return { ok: true, data: should };1034      }1035      case "MARK_CELEBRATION_PLAYED": {1036        await markCelebrationPlayed();1037        return { ok: true };1038      }1039      case "GET_CODEX_VAULT_DATA": {1040        // Pro-gated. requirePro broadcasts PRO_GATE_TRIGGERED on miss so the1041        // popup navigates to the License section without further coordination.1042        const gate = await requirePro("codex");1043        if (!gate.allowed) return { ok: false, error: "pro_required" };1044        return await handleGetCodexVaultData();1045      }1046      case "ESTIMATE_CODEX_PAGES": {1047        const gate = await requirePro("codex");1048        if (!gate.allowed) return { ok: false, error: "pro_required" };1049        return await handleEstimateCodexPages();1050      }1051      case "RESTORE_FROM_CODEX": {1052        // !! IMPORTANT: intentionally NOT Pro-gated. A free device must still1053        // be able to restore a vault that was printed by a Pro user.1054        // DO NOT add a Pro gate here.1055        const payload = msg.payload as CodexRestorePayload;1056        return await handleRestoreFromCodex(payload);1057      }1058      case "OPEN_PRO_GATE": {1059        // Content script requests: open popup and navigate to License section.1060        const { feature } = (msg.payload ?? {}) as { feature?: string };1061        if (chrome.storage.session) {1062          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });1063        }1064        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }1065        return { ok: true };1066      }1067      case "OPEN_POPUP": {1068        // Content script asks to open the trusted extension popup — used by the1069        // inline locked bubble so the master password is entered in the popup,1070        // never in page-hosted DOM (audit HIGH). Best-effort: openPopup needs a1071        // user gesture + Chrome 127+/Firefox; on failure the user clicks the1072        // toolbar icon. The badge already signals Peach needs attention.1073        //1074        // Set a session flag so the popup knows it was opened for inline unlock1075        // and should close itself once the vault is unlocked (returning the user1076        // to the inline autofill selector on the page).1077        if (chrome.storage.session) {1078          await chrome.storage.session.set({ peach_inline_unlock: true });1079        }1080        try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }1081        return { ok: true };1082      }1083      case "GET_IDENTITIES": {1084        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1085        return { ok: true, data: vault.getIdentities() };1086      }1087      case "TOUCH_AUTO_LOCK": {1088        // touchAutoLock() is already called by shouldRefreshAutoLock above;1089        // this case just gives the popup a typed message to keep the session alive.1090        return { ok: true };1091      }1092      case "GET_ADVANCED_HEALTH_REPORT": {1093        const gate = await requirePro("advanced_health");1094        if (!gate.allowed) return { ok: false, error: "pro_required" };1095        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1096        const report = await buildAdvancedHealthReport(vault.getEntries());1097        return { ok: true, data: report };1098      }1099      case "IS_DURESS_ENABLED": {1100        const enabled = await vault.isDuressEnabled();1101        return { ok: true, data: enabled };1102      }1103      case "SETUP_DURESS": {1104        // Single enforcement point for the Duress Vault Pro gate. Free users1105        // who somehow reach this handler are rejected here; the popup also1106        // pre-checks isPro() to avoid showing the setup form to free users,1107        // but server-side is the actual security boundary.1108        const gate = await requirePro("duress_vault");1109        if (!gate.allowed) return { ok: false, error: "pro_required" };1110        // MV3 service workers restart after inactivity, losing the in-memory1111        // derived key. Detect this before calling setupDuress so the popup1112        // can show a meaningful prompt to re-lock and re-unlock.1113        if (!vault.isUnlocked()) return { ok: false, error: "session_expired" };1114        const { duressPassword } = msg.payload as { duressPassword: string };1115        await vault.setupDuress(duressPassword);1116        return { ok: true };1117      }1118      case "DISABLE_DURESS": {1119        await vault.disableDuress();1120        return { ok: true };1121      }1122      case "VERIFY_MAIN_PASSWORD": {1123        const { password } = msg.payload as { password: string };1124        const ok = await vault.verifyMainPassword(password);1125        return { ok: true, data: ok };1126      }1127      case "CHANGE_PASSWORD": {1128        const { currentPassword, newPassword } = msg.payload as {1129          currentPassword: string;1130          newPassword: string;1131        };1132        const changed = await vault.changePassword(currentPassword, newPassword);1133        return changed1134          ? { ok: true }1135          : { ok: false, error: "incorrect_current_password" };1136      }1137      case "GET_SITE_PASSWORD_POLICY": {1138        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });1139        if (!tab?.id) return { ok: true, data: null };1140        try {1141          const response = await chrome.tabs.sendMessage(tab.id, {1142            type: "GET_SITE_PASSWORD_POLICY",1143          });1144          return (response as MessageResponse) ?? { ok: true, data: null };1145        } catch {1146          return { ok: true, data: null };1147        }1148      }1149      case "RECORD_MANUAL_SUMMON": {1150        const { domain } = msg.payload as { domain: string };1151        const settings = vault.getSettings();1152        const existing = settings.manualSummonDomains?.[domain];1153        const now = Date.now();1154        await vault.updateSettings({1155          manualSummonDomains: {1156            ...settings.manualSummonDomains,1157            [domain]: {1158              firstSummonedAt: existing?.firstSummonedAt ?? now,1159              summonCount: (existing?.summonCount ?? 0) + 1,1160              lastSummonedAt: now,1161            },1162          },1163        });1164        return { ok: true };1165      }1166      case "DECLINE_CARD_SAVE": {1167        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1168        await vault.declineCardSaveOffer(last4, expMonth, expYear);1169        return { ok: true };1170      }1171      case "IS_CARD_DECLINED": {1172        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1173        const declined = await vault.isCardDeclined(last4, expMonth, expYear);1174        return { ok: true, data: declined };1175      }1176      case "INITIATE_FIREFOX_PASSKEY_CREATE": {1177        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1178          rpId?: string;1179          rpName?: string;1180          userName?: string;1181          userDisplayName?: string;1182        };1183        if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };1184        return await initiateFirefoxPasskeyCreate({1185          rpId,1186          rpName: rpName ?? rpId,1187          userName,1188          userDisplayName,1189        }, attestedSenderOrigin(sender));1190      }11911192      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1193        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1194        if (!payload) return { ok: false, error: "missing_payload" };1195        return await completeFirefoxPasskeyCreate(payload);1196      }11971198      case "PASSKEY_OFFER_DECLINE": {1199        const { domain } = (msg.payload ?? {}) as { domain?: string };1200        if (!domain) return { ok: false, error: "domain_required" };1201        await recordPasskeyOfferDecline(domain);1202        return { ok: true };1203      }12041205      case "GET_PASSKEY_OFFER_DECLINED": {1206        const { domain } = (msg.payload ?? {}) as { domain?: string };1207        if (!domain) return { ok: false, error: "domain_required" };1208        return { ok: true, data: await isPasskeyOfferDeclined(domain) };1209      }12101211      case "PASSKEY_HAS_CREDENTIALS": {1212        const payload = msg.payload as {1213          rpId?: string;1214          allowCredentials?: Array<{ id: string; type?: string }>;1215        } | undefined;1216        if (!payload?.rpId) return { ok: false, error: "rpid_required" };1217        return await handlePasskeyHasCredentials({1218          rpId: payload.rpId,1219          allowCredentials: payload.allowCredentials ?? [],1220        }, attestedSenderOrigin(sender));1221      }12221223      case "PASSKEY_CREATE": {1224        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1225        if (!payload?.rpId || !payload?.userName) {1226          return { ok: false, error: "rpid_and_username_required" };1227        }1228        return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1229      }12301231      case "PASSKEY_GET": {1232        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1233        if (!payload?.rpId) return { ok: false, error: "rpid_required" };1234        return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1235      }12361237      case "PASSKEY_AUTH_RESPONSE": {1238        const { requestId, approved } = (msg.payload ?? {}) as {1239          requestId?: string | number;1240          approved?: boolean;1241        };1242        if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1243          return { ok: false, error: 'requestId and approved required' };1244        }1245        await resolvePendingPasskeyRequest(Number(requestId), approved);1246        return { ok: true };1247      }12481249      case "GET_PASSKEY_FOR_DOMAIN": {1250        const { domain } = (msg.payload ?? {}) as { domain?: string };1251        if (!domain) return { ok: false, error: "domain_required" };1252        // Scope to the caller's own origin: a page may only ask about passkeys1253        // for a domain it actually owns, so an arbitrary page cannot enumerate1254        // which sites the user holds passkeys for or read the username (audit MEDIUM).1255        const origin = attestedSenderOrigin(sender);1256        if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1257        if (!vault.isUnlocked()) return { ok: true, data: null };1258        const match = vault1259          .getEntries()1260          .find((entry) => entry.passkey?.rpId === domain);1261        if (!match || !match.passkey) return { ok: true, data: null };1262        // Slim shape — only the fields State 11 needs. Never expose private1263        // key material, even though this is locked behind isUnlocked.1264        return {1265          ok: true,1266          data: {1267            rpId: match.passkey.rpId,1268            userName: match.passkey.userName,1269            browser: match.passkey.browser,1270          },1271        };1272      }12731274      case "DESTROY_VAULT": {1275        // Irreversible by design. Lock zeroes the derived key + in-memory entries;1276        // storage.local.clear() wipes every Peach key (slots, settings, license,1277        // onboarded flag, history, sync state, declined cards, recall, etc.);1278        // session.clear() wipes pending passkey requests + pro-notice queues.1279        // Caller (Panic.tsx) handles UI navigation back to onboarding.1280        //1281        // The Pro license is the one thing that must survive: the user paid for1282        // it, and destroying a vault (panic wipe, fresh start) is not a refund.1283        // Capture it before the clear and restore it after. peach_pro_celebrated1284        // rides along so the Pro celebration doesn't replay on the next unlock.1285        const preserved = await chrome.storage.local.get([1286          "peach_license",1287          "peach_pro_celebrated",1288        ]);1289        vault.lock();1290        await storageClear();1291        await vault.clearVaultHistory();1292        const toRestore: Record<string, unknown> = {};1293        if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1294        if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1295        if (Object.keys(toRestore).length > 0) {1296          await chrome.storage.local.set(toRestore);1297        }1298        if (chrome.storage.session) {1299          await chrome.storage.session.clear();1300        }1301        return { ok: true };1302      }13031304      // Android-only: biometric unlock with pre-derived key.1305      case "UNLOCK_WITH_KEY": {1306        const { masterKeyHex } = msg.payload as { masterKeyHex: string };1307        if (!isNativePlatform()) {1308          return { ok: false, error: "desktop_only_uses_password" };1309        }1310        try {1311          const result = await vault.unlockWithKey(masterKeyHex);1312          if (result.ok) {1313            onVaultUnlocked();1314            await publishInMemoryVault(result.entries);1315          }1316          return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };1317        } catch {1318          return { ok: false, error: "invalid_key" };1319        }1320      }1321      // Android: returns current in-memory derived key as hex. Used by biometric enable flow.1322      case "GET_DERIVED_KEY": {1323        if (!isNativePlatform()) {1324          return { ok: false, error: "android_only" };1325        }1326        if (!vault.isUnlocked()) {1327          return { ok: false, error: "vault_locked" };1328        }1329        return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };1330      }1331      case "GET_IDENTITY_SUGGESTIONS": {1332        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1333        const entries = vault.getEntries();1334        const suggestions = buildIdentitySuggestions(entries);1335        return { ok: true, data: { suggestions } };1336      }1337      case "POPULATE_INLINE_VAULT": {1338        if (!isNativePlatform()) return { ok: false };1339        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1340        const entries = vault.getEntries();1341        await publishInMemoryVault(entries);1342        return { ok: true };1343      }1344      case "ML_INIT":1345        return { ok: true };13461347      case "ML_PREDICT_BATCH": {1348        // AUD-002: shape-validate before forwarding to the ML helper.1349        const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1350        if (!Array.isArray(featuresArray)) {1351          return { ok: false, error: "invalid_payload" };1352        }1353        return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };1354      }13551356      // ── Vault sync (v3) ───────────────────────────────────────────────1357      // Returns the public-facing local identity needed by the popup to1358      // drive Phase-1 audio pairing. The private long-term key never1359      // leaves the background.1360      case "GET_LOCAL_IDENTITY": {1361        // Not Pro-gated: a free device may attempt to pair so it can RECEIVE a1362        // Pro license from an already-Pro peer (at least one device must be Pro).1363        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1364        // Identity derives from the stable root key, not the password-derived1365        // key, so pairing survives a master-password change (SEV-2-B).1366        const masterKeyHex = vault.getRootKeyHex();1367        if (!masterKeyHex) return { ok: false, error: "vault_locked" };1368        const masterKey = unhexBytes(masterKeyHex);1369        try {1370          const identity = await buildLocalIdentity(masterKey);1371          // If this device is Pro, advertise the signed license so the peer can1372          // activate it after SAS confirmation — pairing carries Pro across.1373          if (await isProUser()) {1374            const license = await getLicenseKey();1375            if (license) (identity as { license?: string }).license = license;1376          }1377          return { ok: true, data: identity };1378        } finally {1379          masterKey.fill(0);1380        }1381      }13821383      case "GET_ADOPTION_MATERIAL": {1384        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1385        const rootKeyHex = vault.getRootKeyHex();1386        if (!rootKeyHex) return { ok: false, error: "vault_locked" };1387        const rootKeyBytes = unhexBytes(rootKeyHex);1388        try {1389          const data: { rootKeyHex: string; vaultId: string; license?: string } = {1390            rootKeyHex,1391            vaultId: await computeVaultId(rootKeyBytes),1392          };1393          if (await isProUser()) {1394            const license = await getLicenseKey();1395            if (license) data.license = license;1396          }1397          return { ok: true, data };1398        } finally {1399          rootKeyBytes.fill(0);1400        }1401      }14021403      case "COMPLETE_PAIRING": {1404        // Not Pro-gated — see GET_LOCAL_IDENTITY.1405        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1406        const paired = (msg.payload as { paired?: unknown })?.paired;1407        if (typeof paired !== "object" || paired === null) {1408          return { ok: false, error: "invalid_payload" };1409        }1410        const candidate = paired as Record<string, unknown>;1411        if (1412          typeof candidate.deviceId !== "string" || !candidate.deviceId ||1413          typeof candidate.deviceName !== "string" || !candidate.deviceName ||1414          typeof candidate.deviceType !== "string" ||1415          typeof candidate.longTermPublicKeyHex !== "string" ||1416          !/^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) ||1417          (1418            candidate.signingPublicKeyHex !== undefined &&1419            (1420              typeof candidate.signingPublicKeyHex !== "string" ||1421              !/^[0-9a-f]{64}$/.test(candidate.signingPublicKeyHex)1422            )1423          ) ||1424          typeof candidate.pairedAt !== "number"1425        ) {1426          return { ok: false, error: "invalid_paired_device" };1427        }1428        // A fresh SAS-confirmed direct pairing is the one operation allowed to1429        // lift an old terminal revocation. Without this, re-pairing recreated1430        // an impossible state where the same device was both paired and1431        // revoked, and a later Remove became a permanent no-op.1432        await clearDeviceRevocation(candidate.deviceId);1433        await upsertPairedDeviceV3(candidate as unknown as import("../shared/sync-types").PairedDevice);1434        // Newly paired device → kick a sync so it gets its first round1435        // without waiting for the next 10-minute alarm tick. Fire-and-forget;1436        // the in-flight guard handles overlap with any other trigger.1437        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1438        return { ok: true };1439      }14401441      case "LIST_PAIRED_DEVICES": {1442        const devices = await listPairedDevicesV3();1443        return { ok: true, data: { devices } };1444      }14451446      case "UNPAIR_DEVICE": {1447        const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1448        if (typeof deviceId !== "string" || !deviceId) {1449          return { ok: false, error: "invalid_payload" };1450        }1451        // Removing a device REVOKES it rather than just dropping the local1452        // row: a plain removal is undone the moment any other peer vouches it1453        // back through a manifest, which is exactly what the relay makes easy.1454        await revokeDeviceByIdV3(deviceId, await getOrCreateDeviceId());1455        // Sync against the remaining hosts — the unpaired device no longer1456        // matters but the next alarm tick is up to ten minutes away.1457        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1458        return { ok: true };1459      }14601461      case "RENAME_LOCAL_DEVICE": {1462        const { name } = (msg.payload ?? {}) as { name?: string };1463        if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1464        await setDeviceNameV3(name);1465        return { ok: true };1466      }14671468      // ── Relay pairing ─────────────────────────────────────────────────────1469      // Not Pro-gated (a free device may pair to receive Pro/sync). The whole NK1470      // session runs in the background; the SAS and the terminal result reach1471      // the popup as ADOPTION_SOURCE_SAS / ADOPTION_SOURCE_RESULT pushes.1472      case "START_ADOPTION_SOURCE": {1473        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1474        if (isRelayAdoptionSourceInFlight()) {1475          return { ok: false, error: "adoption_source_in_progress" };1476        }1477        try {1478          return { ok: true, data: await startRelayAdoptionSource() };1479        } catch (error) {1480          return {1481            ok: false,1482            error: error instanceof Error ? error.message : "adoption_source_failed",1483          };1484        }1485      }14861487      // Onboarding receiver — empty extension shows a QR, phone scans and1488      // sends the vault. The popup supplies the local-only password here so1489      // the background can wrap the adopted root key at rest without ever1490      // asking the user to retype it.1491      case "START_ADOPTION_RECEIVER": {1492        if (isRelayAdoptionSourceInFlight()) {1493          return { ok: false, error: "adoption_receiver_in_progress" };1494        }1495        const password = (msg.payload as { password?: unknown } | undefined)?.password;1496        if (typeof password !== "string" || password.length === 0) {1497          return { ok: false, error: "invalid_payload" };1498        }1499        try {1500          return { ok: true, data: await startRelayAdoptionReceiver(password) };1501        } catch (error) {1502          return {1503            ok: false,1504            error: error instanceof Error ? error.message : "adoption_receiver_failed",1505          };1506        }1507      }15081509      // Popup→bg counterpart to ADOPTION_SOURCE_SAS (receiver flow).1510      // The receiver now auto-accepts the SAS (both sides derive it from the1511      // same Noise handshake hash, so the codes match by construction) so1512      // the popup never has a SAS screen to tap on. The id round-trips the1513      // same way so a stale late response can't approve the wrong session.1514      // Popup→bg counterpart for the source flow: Confirm/Cancel on the1515      // source's SAS screen.15161517      // Source-side mirror of RECEIVER_SAS_DECISION: the popup's confirm/cancel1518      // on the source's SAS screen. The id round-trips the same way so a stale1519      // late response can't approve the wrong session.1520      case "SOURCE_SAS_DECISION": {1521        const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1522        if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1523          return { ok: false, error: "invalid_payload" };1524        }1525        return { ok: resolveSourceSasConfirmation(payload.id, payload.ok) };1526      }15271528      case "CANCEL_ADOPTION_SOURCE": {1529        cancelRelayAdoptionSource();1530        return { ok: true };1531      }15321533      case "TRIGGER_SYNC": {1534        const payload = (msg.payload ?? {}) as {1535          reason?: "vault_change" | "foreground" | "manual";1536          force?: boolean;1537        };1538        const reason = payload.reason ?? "foreground";15391540        void triggerSyncIfReachable({ reason, force: payload.force === true });1541        return { ok: true };1542      }15431544      case "GET_SYNC_STATE": {1545        return { ok: true, data: getSyncState() };1546      }15471548      case "GET_SYNC_SETTINGS": {1549        return { ok: true, data: await getSyncSettings() };1550      }15511552      case "SET_SYNC_SETTINGS": {1553        const payload = (msg.payload ?? {}) as {1554          mode?: unknown;1555          relayUrl?: unknown;1556        };1557        try {1558          // A custom relay URL implies private mode; Peach's own relay stays1559          // public. See setRelayUrl.1560          if (payload.relayUrl !== undefined) {1561            if (typeof payload.relayUrl !== "string") {1562              return { ok: false, error: "invalid_relay_url" };1563            }1564            return { ok: true, data: await setRelayUrl(payload.relayUrl) };1565          }1566          if (payload.mode !== undefined) {1567            if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1568              return { ok: false, error: "invalid_sync_mode" };1569            }1570            return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };1571          }1572          return { ok: true, data: await getSyncSettings() };1573        } catch (error) {1574          return {1575            ok: false,1576            error: error instanceof Error ? error.message : "invalid_relay_url",1577          };1578        }1579      }15801581      case "GET_SYNC_ALERTS": {1582        const { consume } = (msg.payload ?? {}) as { consume?: boolean };1583        const alerts = await getPendingSyncAlerts();1584        // The popup reads on open and acknowledges with consume:true after1585        // rendering, so a popup crash between read and render doesn't lose1586        // the alerts.1587        if (consume) await clearPendingSyncAlerts();1588        return { ok: true, data: alerts };1589      }15901591      case "GET_PENDING_VOUCHED_DEVICES": {1592        return { ok: true, data: await listPendingVouchedDevices() };1593      }1594      case "CONFIRM_VOUCHED_DEVICE": {1595        const { deviceId } = msg.payload as { deviceId: string };1596        const pending = await getPendingVouchedDevice(deviceId);1597        if (!pending) return { ok: false, error: "not_pending" };1598        // Last gate before a vouched device becomes a sync peer. A queue entry1599        // can predate the revocation that names it, so revocation is re-checked1600        // here rather than trusted to have swept the queue.1601        if (await isDeviceRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {1602          await removePendingVouchedDevice(deviceId);1603          return { ok: false, error: "device_revoked" };1604        }1605        await upsertPairedDeviceV3({1606          deviceId: pending.deviceId,1607          deviceName: pending.deviceName,1608          deviceType:1609            pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1610          longTermPublicKeyHex: pending.longTermPublicKeyHex,1611          signingPublicKeyHex: pending.signingPublicKeyHex,1612          wsPort: pending.wsPort,1613          pairedAt: pending.pairedAt,1614          trustSource: "vouched",1615        });1616        await removePendingVouchedDevice(deviceId);1617        // Newly confirmed vouched device is now a sync target — kick a round1618        // so the mesh introduction pays off without a 10-minute wait.1619        void triggerSyncIfReachable({ reason: "device_list_changed" }).catch(() => undefined);1620        return { ok: true };1621      }1622      case "REJECT_VOUCHED_DEVICE": {1623        const { deviceId } = msg.payload as { deviceId: string };1624        await removePendingVouchedDevice(deviceId);1625        return { ok: true };1626      }1627      case "ROTATE_SYNC_IDENTITY": {1628        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1629        const result = await vault.rotateSyncIdentity();1630        return result.ok ? { ok: true } : { ok: false, error: result.error };1631      }16321633      default:1634        return { ok: false, error: "unknown_message_type" };1635    }1636  } catch (err) {1637    return {1638      ok: false,1639      error: err instanceof Error ? err.message : "operation_failed",1640    };1641  }1642}16431644// --- Mobile platform helpers ---16451646// Android: populates the in-memory vault used by PeachAutofillService.1647async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {1648  const plugin = nativePlugin<{1649    setEntries?: (args: {1650      entries: Array<{1651        id: string;1652        title: string;1653        username: string;1654        email: string;1655        password: string;1656        url: string;1657        totp: string;1658        additionalUrls: string[];1659      }>;1660    }) => Promise<void>;1661  }>("InMemoryVaultPlugin");1662  if (!plugin?.setEntries) return;1663  await plugin.setEntries({1664    entries: entries.map((e) => ({1665      id: e.id,1666      title: e.title ?? "",1667      username: e.username ?? "",1668      email: (e as VaultEntry & { email?: string }).email ?? "",1669      password: e.password ?? "",1670      url: e.url ?? "",1671      totp: (e as VaultEntry & { totp?: string }).totp ?? "",1672      additionalUrls: (e as VaultEntry & { additionalUrls?: string[] }).additionalUrls ?? [],1673    })),1674  });1675}16761677// Android: clear the autofill in-memory store on lock.1678async function clearInMemoryVault(): Promise<void> {1679  const plugin = nativePlugin<{ clearEntries?: () => Promise<void> }>("InMemoryVaultPlugin");1680  if (!plugin?.clearEntries) return;1681  await plugin.clearEntries();1682}16831684// Returns username use-counts from the vault, sorted by frequency. Each row1685// also carries the entry's email so the popup can suggest the email/username1686// pair the user has actually used together.1687function buildIdentitySuggestions(1688  entries: VaultEntry[],1689): Array<{ email: string; username: string; count: number }> {1690  const usernameCounts = new Map<string, number>();1691  const usernameEmail = new Map<string, string>();1692  for (const entry of entries) {1693    const username = entry.username ?? "";1694    const email = (entry as VaultEntry & { email?: string }).email ?? "";1695    if (!username && !email) continue;1696    const key = username || email;1697    usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1698    if (!usernameEmail.has(key)) usernameEmail.set(key, email);1699  }1700  return Array.from(usernameCounts.entries())1701    .sort((a, b) => b[1] - a[1])1702    .map(([username, count]) => ({1703      email: usernameEmail.get(username) ?? "",1704      username,1705      count,1706    }));1707}17081709/** Notify all content scripts that the vault just became available. */1710function broadcastToContentScripts(message: { type: string }): void {1711  if (!chrome.tabs?.query) return;1712  chrome.tabs.query({}, (tabs) => {1713    for (const tab of tabs) {1714      if (tab.id !== undefined) {1715        chrome.tabs.sendMessage(tab.id, message).catch(() => {1716          // Tab may not have a content script — silently ignore1717        });1718      }1719    }1720  });1721}17221723function shouldRefreshAutoLock(type: MessageType): boolean {1724  return (1725    type !== "HAS_VAULT" &&1726    type !== "IS_UNLOCKED" &&1727    type !== "GET_VAULT_STATUS" &&1728    type !== "LOCK" &&1729    type !== "UPDATE_SETTINGS" &&1730    type !== "GET_PENDING_PASSKEY" &&1731    type !== "GET_PASSKEY_PROVIDER_STATE" &&1732    type !== "DECLINE_CARD_SAVE" &&1733    type !== "IS_CARD_DECLINED"1734  );1735}17361737function isAllowedCanaryWebhookUrl(value: string): boolean {1738  try {1739    const url = new URL(value);1740    if (url.protocol !== "https:") return false;17411742    const hostname = url.hostname.toLowerCase();1743    if (1744      hostname === "localhost" ||1745      hostname.endsWith(".localhost") ||1746      isPrivateIpv4(hostname) ||1747      isPrivateIpv6(hostname)1748    ) {1749      return false;1750    }17511752    return true;1753  } catch {1754    return false;1755  }1756}17571758function isPrivateIpv4(hostname: string): boolean {1759  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1760    return true;1761  }17621763  const octets = hostname.split(".").map((part) => Number(part));1764  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1765    return false;1766  }17671768  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1769}17701771function isPrivateIpv6(hostname: string): boolean {1772  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1773  // IPv4-mapped IPv6 literals embed a plain IPv4 address that the prefix1774  // list below can never match (WHATWG URL serializes [::ffff:192.168.0.1]1775  // as ::ffff:c0a8:1). Unwrap and test the embedded IPv4; unparseable mapped1776  // tails fail closed. (Codex Security 2026-08-04 LOW,1777  // ssrf.webhook-url-private-ip-filter.) DNS rebinding is out of scope for a1778  // one-shot host check.1779  const mapped = /^::ffff:(.+)$/.exec(normalized);1780  if (mapped) {1781    const tail = mapped[1] ?? "";1782    if (tail.includes(".")) return isPrivateIpv4(tail);1783    const parts = tail.split(":");1784    const [hi, lo] = parts;1785    if (1786      parts.length !== 2 ||1787      !hi ||1788      !lo ||1789      !/^[0-9a-f]{1,4}$/.test(hi) ||1790      !/^[0-9a-f]{1,4}$/.test(lo)1791    ) {1792      return true;1793    }1794    const octets = [1795      (Number.parseInt(hi, 16) >>> 8) & 0xff,1796      Number.parseInt(hi, 16) & 0xff,1797      (Number.parseInt(lo, 16) >>> 8) & 0xff,1798      Number.parseInt(lo, 16) & 0xff,1799    ];1800    return isPrivateIpv4(octets.join("."));1801  }1802  return (1803    normalized === "::1" ||1804    normalized.startsWith("fc") ||1805    normalized.startsWith("fd") ||1806    normalized.startsWith("fe80:")1807  );1808}