messages.ts

57.7 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 } from "./clipboard";13import { getPendingSyncAlerts, clearPendingSyncAlerts } from "./sync-alerts";14import { setupAutoLock, touchAutoLock } from "./alarms";15import { runSelfTest } from "./self-test";16import * as vault from "./vault";17import {18  handleEstimateCodexPages,19  handleGetCodexVaultData,20  handleRestoreFromCodex,21} from "./codex-handlers";22import type { CodexRestorePayload } from "../shared/codex-types";23import {24  activateLicense,25  deactivateLicense,26  getLicenseInfo,27  getLicenseKey,28  isProUser,29  markCelebrationPlayed,30  shouldPlayCelebration,31} from "./license";32import {33  completeFirefoxPasskeyCreate,34  getPasskeyProviderState,35  getPendingPasskeyRequest,36  handleFirefoxPasskeyCreate,37  handleFirefoxPasskeyGet,38  handlePasskeyHasCredentials,39  initiateFirefoxPasskeyCreate,40  isPasskeyOfferDeclined,41  onVaultUnlocked,42  recordPasskeyOfferDecline,43  resolvePendingPasskeyRequest,44  setPasskeyProviderEnabled,45} from "./passkey";46import { buildAdvancedHealthReport } from "./health";47import { storageClear } from "../shared/storage";48import { findInvalidWords } from "../shared/recovery-words";49import type {50  Message,51  MessageResponse,52  MessageType,53  ProFeature,54  VaultEntry,55} from "../shared/types";56import { isPasskeyOnly } from "../shared/types";57import { generateTOTP } from "../crypto/totp";58import { isValidRpId } from "../shared/passkey-utils";59import { isNativePlatform, nativePlugin } from "../shared/native";60import { predictBatch as mlPredictBatch } from "./ml";61import type { MLFieldFeatures } from "../shared/ml";62import { buildLocalIdentity, setDeviceName as setDeviceNameV3 } from "./chirp-identity";63import {64  listPairedDevices as listPairedDevicesV3,65  removePairedDeviceById as removePairedDeviceByIdV3,66  upsertPairedDevice as upsertPairedDeviceV3,67} from "./paired-devices";6869// Compile-time E2E flag, folded to a literal by Vite's `define` (false in70// every production build — verified in the bundled output). Mirrors the71// create-path bypass in passkey.ts so the Playwright passkey smoke can enable72// the provider without a real Pro licence; the gate is fully intact for users.73declare const __PEACH_E2E_PASSKEYS__: boolean;74const PASSKEY_E2E_PRO_BYPASS = __PEACH_E2E_PASSKEYS__ === true;7576let nativeSyncRunnerBootstrapped = false;7778function unhexBytes(hex: string): Uint8Array {79  if (hex.length % 2 !== 0) throw new Error("invalid hex");80  const out = new Uint8Array(hex.length / 2);81  for (let i = 0; i < out.length; i++) {82    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);83  }84  return out;85}8687/**88 * The single Pro gate. Every gated handler calls this first; the broadcast89 * lets the popup react (navigate to the License section, highlight the90 * relevant feature). Background never touches the popup view directly —91 * the popup owns navigation. See Projects.md "Pro gate rule".92 */93async function requirePro(94  feature: ProFeature,95  trigger: "user" | "background" = "user"96): Promise<{ allowed: boolean }> {97  if (PASSKEY_E2E_PRO_BYPASS && feature === "passkeys") return { allowed: true };98  if (await isProUser()) return { allowed: true };99100  if (trigger === "user") {101    // User deliberately triggered this feature — redirect the popup immediately.102    chrome.runtime103      .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })104      .catch(() => {105        // Popup may be closed — the gate signal is best-effort.106      });107  } else {108    // Background trigger (e.g. passive sync initiation) — no popup is open.109    // Queue a notice so the next popup open shows a soft banner instead of110    // silently failing.111    if (chrome.storage.session) {112      chrome.storage.session113        .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })114        .catch(() => {});115    }116  }117118  return { allowed: false };119}120121// ---------------------------------------------------------------------------122// Feedback submission target. Empty disables network submission while the UI123// continues to function as a no-op (the handler returns ok:true without124// calling fetch). When set, the URL is validated by125// `isAllowedCanaryWebhookUrl` before any request.126// ---------------------------------------------------------------------------127128const FEEDBACK_ENDPOINT = "";129130const PRIVATE_IPV4_RANGES = [131  { prefix: "10." },132  { prefix: "127." },133  { prefix: "169.254." },134  { prefix: "192.168." },135];136137export function setupMessageHandler(): void {138  chrome.runtime.onMessage.addListener(139    (msg: unknown, sender, sendResponse) => {140      if (!isMessage(msg)) {141        sendResponse({ ok: false, error: "invalid_message" });142        return true;143      }144      handleMessage(msg, sender)145        .then(sendResponse)146        .catch((err) =>147          sendResponse({148            ok: false,149            error: err instanceof Error ? err.message : String(err),150          })151        );152      return true;153    }154  );155156  // Android: the chrome shim queues sendMessage calls until this handler is157  // registered. Without this hookup, every message hangs on Capacitor builds.158  // eslint-disable-next-line @typescript-eslint/no-explicit-any159  const reg = (globalThis as any).__peachRegisterMessageHandler;160  if (typeof reg === "function") {161    reg(async (m: unknown) => {162      if (!isMessage(m)) return { ok: false, error: "invalid_message" };163      try {164        return await handleMessage(m);165      } catch (err) {166        return {167          ok: false,168          error: err instanceof Error ? err.message : String(err),169        };170      }171    });172  }173  if (isNativePlatform()) {174    void bootstrapNativeSyncRunner();175  }176}177178async function bootstrapNativeSyncRunner(): Promise<void> {179  if (nativeSyncRunnerBootstrapped) return;180  nativeSyncRunnerBootstrapped = true;181  try {182    const { setSyncRunner, hydratePersistedSyncState } = await import("./sync-trigger");183    const { productionSyncRunner } = await import("./chirp-sync-runner");184    setSyncRunner(productionSyncRunner);185    await hydratePersistedSyncState();186  } catch (err) {187    nativeSyncRunnerBootstrapped = false;188    console.warn("[Peach] failed to bootstrap native sync runner:", err);189  }190}191192function isMessage(m: unknown): m is Message {193  return typeof m === "object" && m !== null && "type" in m;194}195196/**197 * The browser-attested origin of the page that originated this message. A web198 * page cannot call chrome.runtime.sendMessage directly (no externally_connectable),199 * so every page-driven message arrives via the content-script relay and the200 * browser stamps `sender.origin`/`sender.url` with the real frame origin — a201 * value the page cannot forge. This is the ONLY trustworthy origin for passkey202 * rpId<->origin enforcement; payload-supplied origins are attacker-controlled.203 */204function attestedSenderOrigin(sender: Partial<chrome.runtime.MessageSender>): string | undefined {205  if (sender.origin) return sender.origin;206  if (sender.url) {207    try { return new URL(sender.url).origin; } catch { return undefined; }208  }209  return undefined;210}211212// Resolves once restoreFromSession() has run, so handlers see the correct213// post-restore unlock state. entry.ts registers this BEFORE the message214// handler so the onMessage listener is added synchronously at SW startup215// (MV3 requirement) while unlock-dependent handlers still wait for restore.216let restoreReady: Promise<void> = Promise.resolve();217218/** Called by entry.ts at startup with the in-flight restoreFromSession promise. */219export function setRestoreReady(p: Promise<unknown>): void {220  restoreReady = p.then(() => undefined, () => undefined);221}222223export async function handleMessage(224  msg: Message,225  sender: Partial<chrome.runtime.MessageSender> = {}226): Promise<MessageResponse> {227  try {228    // Wait for session-restore to finish before reading any unlock/vault state.229    // On a cold SW start the popup's first message arrives while restore is230    // still in flight; without this gate IS_UNLOCKED would report locked (a231    // spurious lock screen) and the storage reads could race. The onMessage232    // listener itself is registered synchronously (entry.ts) so this gate, not233    // listener registration, is what absorbs the cold-start window.234    await restoreReady;235    // Validate sender — only allow messages from our extension pages/scripts.236    // `sender.id` is undefined for messages originating from extension UI237    // (popup/options), which is legitimate; reject only when it is defined238    // and does not match our extension id.239    if (240      sender.id !== undefined &&241      sender.id !== chrome.runtime.id242    ) {243      return { ok: false, error: "invalid_sender" };244    }245246    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {247      touchAutoLock();248    }249250    switch (msg.type) {251      case "CREATE_VAULT": {252        const { password } = msg.payload as { password: string };253        // Refuse to create a vault under an empty master password — the derived254        // key would be publicly known and the at-rest AES-GCM protection void255        // (audit HIGH; the CSV-import path previously passed ""). Length/strength256        // policy lives in the onboarding UI; this is the non-negotiable floor.257        if (typeof password !== "string" || password.length === 0) {258          return { ok: false, error: "invalid_payload" };259        }260        await vault.createVault(password);261        await setupAutoLock();262        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });263        onVaultUnlocked();264        return { ok: true };265      }266      case "UNLOCK": {267        const { password } = msg.payload as { password: string };268        const ok = await vault.unlock(password);269        if (ok) {270          // KAT: verify the WebCrypto stack is healthy before the user relies on it.271          // Runs after unlock so latency is hidden behind the vault-load time.272          const kat = await runSelfTest();273          if (!kat.ok) {274            console.error("[Peach] Crypto self-test FAILED:", kat.error);275            await vault.lock();276            return { ok: false, error: "self_test_failed" };277          }278          await setupAutoLock();279          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });280          onVaultUnlocked();281          if (isNativePlatform()) await publishInMemoryVault(vault.getEntries());282          return { ok: true };283        }284        // Distinguish corruption from wrong password so the popup can offer285        // a history-restore path instead of falsely accusing the user of286        // mistyping (M7/COR-007).287        const failure = vault.consumeLastUnlockFailureReason();288        if (failure) {289          return {290            ok: false,291            error: "vault_corrupted",292            canRestoreFromHistory: true,293          } as MessageResponse;294        }295        return { ok: false };296      }297      case "VALIDATE_RECOVERY_WORDS": {298        const { words } = msg.payload as { words: string[] };299        const invalid = findInvalidWords(words);300        return { ok: true, data: { valid: invalid.length === 0, invalid } };301      }302      case "GET_RECOVERY_WORDS": {303        // Must be unlocked and have recovery set up (vault must be v2.12+).304        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };305        try {306          const words = await vault.getRecoveryWords();307          return { ok: true, data: words };308        } catch (e) {309          // pre-v2.12 vault that hasn't migrated310          return { ok: false, error: "recovery_not_setup" };311        }312      }313      case "RECOVER_WITH_WORDS": {314        // User has lost their password but has their 12 recovery words.315        // We: (1) validate the words, (2) re-encrypt vault with new password.316        const { words, newPassword } = msg.payload as {317          words: string[];318          newPassword: string;319        };320        const stored = await vault.loadSlotA();321        if (!stored) return { ok: false, error: "no_vault" };322        if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {323          return { ok: false, error: "recovery_not_setup" };324        }325        // Unwrap the root key from the recovery escrow.326        try {327          await vault.unwrapRootKeyForRecovery(328            stored.recovery_wrapped_root_key,329            stored.recovery_nonce ?? "",330            stored.recovery_tag ?? "",331            stored.recovery_salt,332            words333          );334        } catch {335          return { ok: false, error: "invalid_recovery_words" };336        }337        // Re-key: derive fresh salt + key from new password.338        const newSalt = vault.generateSalt();339        const newKey = await vault.deriveKey(newPassword, newSalt);340        // Re-wrap root key under the new password's derived key so the same341        // recovery words continue to work after the password change.342        await vault.rewrapRootKeyForPassword(newKey, newSalt);343        // Re-encrypt vault entries with the new key.344        await vault.changePasswordWithNewKey(newSalt, newKey);345        await setupAutoLock();346        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });347        return { ok: true };348      }349      case "LOCK":350        vault.lock();351        if (isNativePlatform()) await clearInMemoryVault();352        return { ok: true };353      case "IS_UNLOCKED":354        return { ok: true, data: vault.isUnlocked() };355      case "GET_VAULT_STATUS":356        return { ok: true, unlocked: vault.isUnlocked() };357      case "HAS_VAULT":358        return { ok: true, data: await vault.hasVault() };359      case "GET_ENTRIES":360        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };361        return { ok: true, data: vault.getEntries() };362      case "ADD_ENTRY": {363        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };364        const payload = msg.payload;365        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {366          return { ok: false, error: "invalid_payload" };367        }368        const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);369        return { ok: true, data: entry };370      }371      case "BATCH_ADD_ENTRIES": {372        const payload = msg.payload as { entries?: unknown } | null | undefined;373        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {374          return { ok: false, error: "invalid_payload" };375        }376        if (!Array.isArray(payload.entries)) {377          return { ok: false, error: "invalid_payload" };378        }379        const added = await vault.batchAddEntries(payload.entries as Parameters<typeof vault.batchAddEntries>[0]);380        return { ok: true, data: added };381      }382      case "RESTORE_ENTRIES": {383        const payload = msg.payload as384          | { entries?: unknown; mode?: unknown; settings?: unknown }385          | null386          | undefined;387        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {388          return { ok: false, error: "invalid_payload" };389        }390        if (!Array.isArray(payload.entries)) {391          return { ok: false, error: "invalid_payload" };392        }393        if (payload.mode !== "replace" && payload.mode !== "merge") {394          return { ok: false, error: "invalid_payload" };395        }396        const summary = await vault.restoreEntries(397          payload.entries as Parameters<typeof vault.restoreEntries>[0],398          payload.mode399        );400        if (payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)) {401          await vault.updateSettings(payload.settings as vault.VaultSettings);402        }403        return { ok: true, data: summary };404      }405      case "CLEAR_VAULT_ENTRIES": {406        await vault.clearVaultEntries("REPLACE_ALL");407        return { ok: true };408      }409      case "GET_VAULT_HISTORY": {410        const snapshots = await vault.getVaultHistory();411        return { ok: true, data: snapshots };412      }413      case "RESTORE_VAULT_SNAPSHOT": {414        const { snapshotId } = msg.payload as { snapshotId: string };415        const result = await vault.restoreVaultSnapshot(snapshotId);416        if (!result.ok) return { ok: false, error: result.error };417        broadcastToContentScripts({ type: "VAULT_LOCKED" });418        return { ok: true };419      }420      case "COUNT_DUPLICATES": {421        return { ok: true, data: vault.countDuplicates() };422      }423      case "DEDUPLICATE_ENTRIES": {424        const removed = await vault.deduplicateEntries();425        return { ok: true, data: removed };426      }427      case "UPDATE_ENTRY": {428        const payload = msg.payload as { id?: unknown; patch?: unknown } | null | undefined;429        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {430          return { ok: false, error: "invalid_payload" };431        }432        const { id, patch } = payload;433        if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {434          return { ok: false, error: "invalid_payload" };435        }436        // Single enforcement point for the Canary Entries Pro gate. Only437        // gates the transition into a canary entry — clearing/disabling a438        // canary (`canary: undefined`) is always allowed, so existing439        // canary entries remain manageable.440        const incomingCanary = (patch as { canary?: unknown }).canary;441        if (incomingCanary && typeof incomingCanary === "object") {442          const existing = vault.getEntryById(id);443          if (!existing?.canary) {444            const gate = await requirePro("canary_entry");445            if (!gate.allowed) return { ok: false, error: "pro_required" };446          }447        }448        await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);449        return { ok: true };450      }451      case "ADD_ADDITIONAL_URL": {452        const { id, url } = msg.payload as { id: string; url: string };453        const trimmed = typeof url === "string" ? url.trim() : "";454        if (!trimmed) return { ok: false, error: "url_required" };455        const existing = vault.getEntryById(id);456        if (!existing) return { ok: false, error: "entry_not_found" };457        if (existing.type !== "login") return { ok: false, error: "login_entries_only" };458        const merged = [...(existing.additionalUrls ?? []), trimmed];459        await vault.updateEntry(id, { additionalUrls: merged });460        return { ok: true };461      }462      case "DELETE_ENTRY": {463        const { id } = msg.payload as { id: string };464        await vault.deleteEntry(id);465        return { ok: true };466      }467      case "GET_DELETED_ENTRIES":468        return { ok: true, data: vault.getDeletedEntries() };469      case "RESTORE_ENTRY": {470        const { id } = msg.payload as { id: string };471        await vault.restoreEntry(id);472        return { ok: true };473      }474      case "PURGE_ENTRY": {475        const { id } = msg.payload as { id: string };476        await vault.purgeEntry(id);477        return { ok: true };478      }479      case "PURGE_EXPIRED_TRASH": {480        const { retentionMs } = msg.payload as { retentionMs: number };481        const purged = await vault.purgeExpiredTrash(retentionMs);482        return { ok: true, data: purged };483      }484      case "SAVE_NOTE": {485        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };486        const { id, title, body } = msg.payload as { id?: string; title: string; body: string };487        const now = Date.now();488        if (id) {489          const existing = vault.getEntryById(id);490          if (!existing) return { ok: false, error: "not_found" };491          await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });492          return { ok: true, data: vault.getEntryById(id) };493        } else {494          const entry = await vault.addEntry({495            type: "note",496            title: title || "Untitled",497            notes: body,498            createdAt: now,499            modifiedAt: now,500          } as any);501          return { ok: true, data: entry };502        }503      }504      case "DELETE_NOTE": {505        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };506        const { id } = msg.payload as { id: string };507        await vault.deleteEntry(id);508        return { ok: true };509      }510      case "GET_NOTES": {511        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };512        const notes = vault.getEntries().filter(513          (e) => e.type === "note" && !e.deletedAt514        );515        return { ok: true, data: notes } as MessageResponse;516      }517      case "SEARCH_VAULT": {518        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };519        // AUD-002: shape-validate the payload before calling string methods.520        // A raw cast lets a malformed message crash the handler with TypeError.521        const { query } = (msg.payload ?? {}) as { query?: unknown };522        if (typeof query !== "string") {523          return { ok: false, error: "invalid_payload" };524        }525        const q = query.toLowerCase();526        const searchResults = vault.getEntries().filter(527          (e) =>528            !e.deletedAt &&529            (e.title.toLowerCase().includes(q) ||530              e.notes?.toLowerCase().includes(q) ||531              e.username?.toLowerCase().includes(q))532        );533        return { ok: true, data: searchResults } as MessageResponse;534      }535      case "AUTOFILL_QUERY": {536        if (!vault.isUnlocked()) {537          return { ok: false, error: "vault_locked" };538        }539        const { domain, includeAll, kind } = msg.payload as {540          domain: string;541          includeAll?: boolean;542          kind?: "login" | "card" | "totp";543        };544        // kind="card": return saved cards as compact summaries for the545        // inline card silver platter. Cards are not domain-scoped — every546        // saved card is a candidate on every checkout page — so domain is547        // ignored when kind === "card".548        if (kind === "card") {549          const cards = vault.getEntries().filter((e) => e.type === "card");550          return {551            ok: true,552            data: cards.map((e) => ({553              id: e.id,554              title: e.cardNickname || e.title,555              cardholderName: e.cardholderName,556              last4: e.last4,557              expMonth: e.expMonth,558              expYear: e.expYear,559              network: e.network,560            })),561          };562        }563        if (kind === "totp") {564          const matches = (565            includeAll566              ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")567              : vault.queryDomain(domain)568          ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));569          return {570            ok: true,571            data: matches.map((e) => ({572              id: e.id,573              title: e.title,574              username: e.username,575              email: e.email,576              url: e.url,577              hasTotp: true,578            })),579          };580        }581        // The inline selector fills username+password into a form. Passkey-582        // only entries have no password to fill; sign-in for those goes583        // through the WebAuthn path instead. Filter them out everywhere so584        // the user doesn't see four "GitHub" rows that mostly do nothing.585        // Notes and cards never belonged here either.586        const matches = (587          includeAll588            ? vault589                .getEntries()590                .filter((entry) => entry.type !== "note" && entry.type !== "card")591            : vault.queryDomain(domain)592        ).filter((entry) => !isPasskeyOnly(entry));593        return {594          ok: true,595          data: matches.map((e) =>596            includeAll597              ? {598                  id: e.id,599                  title: e.title,600                  // username/email/url shipped so the inline popup's search601                  // affordance can match on them, not just titles. No secrets602                  // cross the boundary — same shape as the domain-matched603                  // response below minus hasTotp (which the search list604                  // doesn't surface).605                  username: e.username,606                  email: e.email,607                  url: e.url,608                  hasPasskey: e.passkey !== undefined,609                }610              : {611                  id: e.id,612                  title: e.title,613                  username: e.username,614                  email: e.email,615                  url: e.url,616                  hasTotp: Boolean(e.totp),617                  hasPasskey: e.passkey !== undefined,618                }619          ),620        };621      }622      case "GET_ENTRY_FOR_FILL": {623        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };624        const { id } = msg.payload as { id: string };625        const entry = vault.getEntryById(id);626        if (!entry) return { ok: false, error: "entry_not_found" };627        // Passkey-only entries (passkey credential, no password) cannot be628        // filled into a form — sign-in goes through the WebAuthn path. A629        // login that ALSO has a passkey is still fillable for its password.630        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };631        // Strip the passkey credential AND the raw base32 TOTP seed from the632        // response. Content scripts never need either: the private key is the633        // authenticator's, and handing over the TOTP seed would let a634        // compromised content script mint valid codes indefinitely (SEV-3-C).635        // Generate the *current* 6-digit code here in the background and send636        // only that, plus the seconds left so the UI can render a countdown.637        // Also drop passwordHistory: filling needs only the *current* password.638        // Handing every past password to a content script during passive639        // autofill on an arbitrary page would widen the blast radius of a640        // compromised page far beyond the one credential being filled.641        const { passkey: _strippedPasskey, totp: seed, passwordHistory: _ph, ...safeEntry } = entry;642        let totpCode: string | undefined;643        let totpRemaining: number | undefined;644        if (seed) {645          try {646            const generated = generateTOTP(seed);647            totpCode = generated.code;648            totpRemaining = generated.remaining;649          } catch {650            totpCode = undefined;651          }652        }653        return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };654      }655      // Trusted full-entry fetch for Peach's own inline-popup surfaces that656      // genuinely need the raw TOTP seed: the edit form (which re-saves the657      // seed and would otherwise wipe it) and nothing else. Unlike658      // GET_ENTRY_FOR_FILL this fires only on an explicit user action inside659      // the inline popup, never during passive autofill on an arbitrary page.660      // The passkey credential is still stripped — the edit form never touches it.661      case "GET_ENTRY_FOR_EDIT": {662        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };663        const { id } = msg.payload as { id: string };664        const entry = vault.getEntryById(id);665        if (!entry) return { ok: false, error: "entry_not_found" };666        const { passkey: _passkey, ...safeEntry } = entry;667        return { ok: true, data: safeEntry };668      }669      case "COPY": {670        // AUD-002: shape-validate the payload before passing to the clipboard671        // helper. A raw cast lets a malformed message reach the clipboard API672        // with a non-string and throw.673        const { text, label, alreadyWritten } = (msg.payload ?? {}) as {674          text?: unknown;675          label?: unknown;676          alreadyWritten?: unknown;677        };678        if (typeof text !== "string" || typeof label !== "string") {679          return { ok: false, error: "invalid_payload" };680        }681        await copyToClipboard(text, label, Boolean(alreadyWritten));682        return { ok: true };683      }684      case "CHECK_ALL_BREACHES": {685        const allEntries = vault.getEntries();686        const results: Record<string, number> = {};687        for (const entry of allEntries) {688          if (entry.type === "note") continue;689          if (!entry.password) continue;690          results[entry.id] = await vault.checkBreach(entry.password);691          // Small delay — be a good citizen to the HIBP API692          await new Promise((r) => setTimeout(r, 200));693        }694        // Persist any non-zero counts back to entries695        for (const [id, count] of Object.entries(results)) {696          const entry = vault.getEntryById(id);697          if (entry && (entry.breach ?? 0) !== count) {698            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });699          }700        }701        return { ok: true, data: results };702      }703      case "GET_SETTINGS":704        return { ok: true, data: await vault.ensureSettingsLoaded() };705      case "UPDATE_SETTINGS": {706        const patch = msg.payload;707        if (!patch || typeof patch !== "object" || Array.isArray(patch)) {708          return { ok: false, error: "invalid_payload" };709        }710        await vault.updateSettings(patch as vault.VaultSettings);711        if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {712          await setupAutoLock();713          if (vault.isUnlocked()) touchAutoLock();714        }715        return { ok: true };716      }717      case "GET_PENDING_PASSKEY":718        return { ok: true, data: await getPendingPasskeyRequest() };719      case "APPROVE_PASSKEY": {720        const { requestId } = msg.payload as { requestId: number };721        await resolvePendingPasskeyRequest(requestId, true);722        return { ok: true };723      }724      case "DENY_PASSKEY": {725        const { requestId } = msg.payload as { requestId: number };726        await resolvePendingPasskeyRequest(requestId, false);727        return { ok: true };728      }729      case "GET_PASSKEY_PROVIDER_STATE":730        return { ok: true, data: await getPasskeyProviderState() };731      case "SET_PASSKEY_PROVIDER_ENABLED": {732        const { enabled } = msg.payload as { enabled: boolean };733        // Disabling is always free; gating fires only when a free user is734        // trying to turn passkey interception on.735        if (enabled) {736          const gate = await requirePro("passkeys");737          if (!gate.allowed) return { ok: false, error: "pro_required" };738        }739        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };740      }741      // AUD-003: this handler POSTs `entryTitle` to a user-configured HTTPS742      // endpoint. Intentional product behavior — users configure their own743      // dead-man's-switch. isAllowedCanaryWebhookUrl blocks localhost, private744      // IPs, and non-HTTPS schemes, but cannot defend against every DNS-745      // rebinding case. The UI under CanarySection (Entry.tsx) discloses to746      // users that the entry title leaves the device when the canary fires.747      case "FIRE_CANARY_PING": {748        // Canary alert fires unconditionally for any entry with canary: true.749        // The Pro gate only governs creation/flagging of canary entries; once750        // an entry is flagged, the alert path is a security property and must751        // never be license-gated.752        const { webhookUrl, entryTitle, trigger } = msg.payload as {753          webhookUrl: string;754          entryTitle: string;755          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";756        };757        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {758          return { ok: false, error: "invalid_webhook_url" };759        }760        const body = JSON.stringify({761          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",762          entry: entryTitle,763          trigger,764          timestamp: new Date().toISOString(),765          source: "peach-extension",766        });767        if (trigger === "test") {768          // Permitted outbound fetch: user-configured canary alert endpoint769          // (validated by isAllowedCanaryWebhookUrl above). This is the770          // only non-HIBP outbound call in the extension — see771          // Projects.md §3.7. Test pings are awaited so the UI can show772          // success/failure.773          try {774            const res = await fetch(webhookUrl, {775              method: "POST",776              headers: { "Content-Type": "application/json" },777              body,778            });779            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };780          } catch (err) {781            return { ok: false, error: err instanceof Error ? err.message : "network_error" };782          }783        }784        // Non-test triggers are fire-and-forget785        fetch(webhookUrl, {786          method: "POST",787          headers: { "Content-Type": "application/json" },788          body,789        }).catch(() => {});790        return { ok: true };791      }792      case "ACTIVATE_LICENSE": {793        const { key } = msg.payload as { key: string };794        const licenseInfo = await activateLicense(key);795        return { ok: true, data: licenseInfo };796      }797      case "GET_LICENSE": {798        const licenseInfo = await getLicenseInfo();799        return { ok: true, data: licenseInfo };800      }801      case "GET_LICENSE_KEY": {802        const key = await getLicenseKey();803        return { ok: true, data: key };804      }805      case "DEACTIVATE_LICENSE": {806        await deactivateLicense();807        return { ok: true };808      }809      case "SHOULD_PLAY_CELEBRATION": {810        const should = await shouldPlayCelebration();811        return { ok: true, data: should };812      }813      case "MARK_CELEBRATION_PLAYED": {814        await markCelebrationPlayed();815        return { ok: true };816      }817      case "GET_CODEX_VAULT_DATA": {818        // Pro-gated. requirePro broadcasts PRO_GATE_TRIGGERED on miss so the819        // popup navigates to the License section without further coordination.820        const gate = await requirePro("codex");821        if (!gate.allowed) return { ok: false, error: "pro_required" };822        return await handleGetCodexVaultData();823      }824      case "ESTIMATE_CODEX_PAGES": {825        const gate = await requirePro("codex");826        if (!gate.allowed) return { ok: false, error: "pro_required" };827        return await handleEstimateCodexPages();828      }829      case "RESTORE_FROM_CODEX": {830        // !! IMPORTANT: intentionally NOT Pro-gated. A free device must still831        // be able to restore a vault that was printed by a Pro user.832        // DO NOT add a Pro gate here.833        const payload = msg.payload as CodexRestorePayload;834        return await handleRestoreFromCodex(payload);835      }836      case "OPEN_PRO_GATE": {837        // Content script requests: open popup and navigate to License section.838        const { feature } = (msg.payload ?? {}) as { feature?: string };839        if (chrome.storage.session) {840          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });841        }842        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }843        return { ok: true };844      }845      case "OPEN_POPUP": {846        // Content script asks to open the trusted extension popup — used by the847        // inline locked bubble so the master password is entered in the popup,848        // never in page-hosted DOM (audit HIGH). Best-effort: openPopup needs a849        // user gesture + Chrome 127+/Firefox; on failure the user clicks the850        // toolbar icon. The badge already signals Peach needs attention.851        //852        // Set a session flag so the popup knows it was opened for inline unlock853        // and should close itself once the vault is unlocked (returning the user854        // to the inline autofill selector on the page).855        if (chrome.storage.session) {856          await chrome.storage.session.set({ peach_inline_unlock: true });857        }858        try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }859        return { ok: true };860      }861      case "GET_IDENTITIES": {862        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };863        return { ok: true, data: vault.getIdentities() };864      }865      case "TOUCH_AUTO_LOCK": {866        // touchAutoLock() is already called by shouldRefreshAutoLock above;867        // this case just gives the popup a typed message to keep the session alive.868        return { ok: true };869      }870      case "GET_ADVANCED_HEALTH_REPORT": {871        const gate = await requirePro("advanced_health");872        if (!gate.allowed) return { ok: false, error: "pro_required" };873        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };874        const report = await buildAdvancedHealthReport(vault.getEntries());875        return { ok: true, data: report };876      }877      case "IS_DURESS_ENABLED": {878        const enabled = await vault.isDuressEnabled();879        return { ok: true, data: enabled };880      }881      case "SETUP_DURESS": {882        // Single enforcement point for the Duress Vault Pro gate. Free users883        // who somehow reach this handler are rejected here; the popup also884        // pre-checks isPro() to avoid showing the setup form to free users,885        // but server-side is the actual security boundary.886        const gate = await requirePro("duress_vault");887        if (!gate.allowed) return { ok: false, error: "pro_required" };888        // MV3 service workers restart after inactivity, losing the in-memory889        // derived key. Detect this before calling setupDuress so the popup890        // can show a meaningful prompt to re-lock and re-unlock.891        if (!vault.isUnlocked()) return { ok: false, error: "session_expired" };892        const { duressPassword } = msg.payload as { duressPassword: string };893        await vault.setupDuress(duressPassword);894        return { ok: true };895      }896      case "DISABLE_DURESS": {897        await vault.disableDuress();898        return { ok: true };899      }900      case "VERIFY_MAIN_PASSWORD": {901        const { password } = msg.payload as { password: string };902        const ok = await vault.verifyMainPassword(password);903        return { ok: true, data: ok };904      }905      case "CHANGE_PASSWORD": {906        const { currentPassword, newPassword } = msg.payload as {907          currentPassword: string;908          newPassword: string;909        };910        const changed = await vault.changePassword(currentPassword, newPassword);911        return changed912          ? { ok: true }913          : { ok: false, error: "incorrect_current_password" };914      }915      case "GET_SITE_PASSWORD_POLICY": {916        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });917        if (!tab?.id) return { ok: true, data: null };918        try {919          const response = await chrome.tabs.sendMessage(tab.id, {920            type: "GET_SITE_PASSWORD_POLICY",921          });922          return (response as MessageResponse) ?? { ok: true, data: null };923        } catch {924          return { ok: true, data: null };925        }926      }927      case "RECORD_MANUAL_SUMMON": {928        const { domain } = msg.payload as { domain: string };929        const settings = vault.getSettings();930        const existing = settings.manualSummonDomains?.[domain];931        const now = Date.now();932        await vault.updateSettings({933          manualSummonDomains: {934            ...settings.manualSummonDomains,935            [domain]: {936              firstSummonedAt: existing?.firstSummonedAt ?? now,937              summonCount: (existing?.summonCount ?? 0) + 1,938              lastSummonedAt: now,939            },940          },941        });942        return { ok: true };943      }944      case "DECLINE_CARD_SAVE": {945        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };946        await vault.declineCardSaveOffer(last4, expMonth, expYear);947        return { ok: true };948      }949      case "IS_CARD_DECLINED": {950        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };951        const declined = await vault.isCardDeclined(last4, expMonth, expYear);952        return { ok: true, data: declined };953      }954      case "INITIATE_FIREFOX_PASSKEY_CREATE": {955        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {956          rpId?: string;957          rpName?: string;958          userName?: string;959          userDisplayName?: string;960        };961        if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };962        return await initiateFirefoxPasskeyCreate({963          rpId,964          rpName: rpName ?? rpId,965          userName,966          userDisplayName,967        });968      }969970      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {971        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;972        if (!payload) return { ok: false, error: "missing_payload" };973        return await completeFirefoxPasskeyCreate(payload);974      }975976      case "PASSKEY_OFFER_DECLINE": {977        const { domain } = (msg.payload ?? {}) as { domain?: string };978        if (!domain) return { ok: false, error: "domain_required" };979        await recordPasskeyOfferDecline(domain);980        return { ok: true };981      }982983      case "GET_PASSKEY_OFFER_DECLINED": {984        const { domain } = (msg.payload ?? {}) as { domain?: string };985        if (!domain) return { ok: false, error: "domain_required" };986        return { ok: true, data: await isPasskeyOfferDeclined(domain) };987      }988989      case "PASSKEY_HAS_CREDENTIALS": {990        const payload = msg.payload as {991          rpId?: string;992          allowCredentials?: Array<{ id: string; type?: string }>;993        } | undefined;994        if (!payload?.rpId) return { ok: false, error: "rpid_required" };995        return await handlePasskeyHasCredentials({996          rpId: payload.rpId,997          allowCredentials: payload.allowCredentials ?? [],998        }, attestedSenderOrigin(sender));999      }10001001      case "PASSKEY_CREATE": {1002        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1003        if (!payload?.rpId || !payload?.userName) {1004          return { ok: false, error: "rpid_and_username_required" };1005        }1006        return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));1007      }10081009      case "PASSKEY_GET": {1010        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1011        if (!payload?.rpId) return { ok: false, error: "rpid_required" };1012        return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));1013      }10141015      case "PASSKEY_AUTH_RESPONSE": {1016        const { requestId, approved } = (msg.payload ?? {}) as {1017          requestId?: string | number;1018          approved?: boolean;1019        };1020        if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {1021          return { ok: false, error: 'requestId and approved required' };1022        }1023        await resolvePendingPasskeyRequest(Number(requestId), approved);1024        return { ok: true };1025      }10261027      case "GET_PASSKEY_FOR_DOMAIN": {1028        const { domain } = (msg.payload ?? {}) as { domain?: string };1029        if (!domain) return { ok: false, error: "domain_required" };1030        // Scope to the caller's own origin: a page may only ask about passkeys1031        // for a domain it actually owns, so an arbitrary page cannot enumerate1032        // which sites the user holds passkeys for or read the username (audit MEDIUM).1033        const origin = attestedSenderOrigin(sender);1034        if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };1035        if (!vault.isUnlocked()) return { ok: true, data: null };1036        const match = vault1037          .getEntries()1038          .find((entry) => entry.passkey?.rpId === domain);1039        if (!match || !match.passkey) return { ok: true, data: null };1040        // Slim shape — only the fields State 11 needs. Never expose private1041        // key material, even though this is locked behind isUnlocked.1042        return {1043          ok: true,1044          data: {1045            rpId: match.passkey.rpId,1046            userName: match.passkey.userName,1047            browser: match.passkey.browser,1048          },1049        };1050      }10511052      case "DESTROY_VAULT": {1053        // Irreversible by design. Lock zeroes the derived key + in-memory entries;1054        // storage.local.clear() wipes every Peach key (slots, settings, license,1055        // onboarded flag, history, sync state, declined cards, recall, etc.);1056        // session.clear() wipes pending passkey requests + pro-notice queues.1057        // Caller (Panic.tsx) handles UI navigation back to onboarding.1058        //1059        // The Pro license is the one thing that must survive: the user paid for1060        // it, and destroying a vault (panic wipe, fresh start) is not a refund.1061        // Capture it before the clear and restore it after. peach_pro_celebrated1062        // rides along so the Pro celebration doesn't replay on the next unlock.1063        const preserved = await chrome.storage.local.get([1064          "peach_license",1065          "peach_pro_celebrated",1066        ]);1067        vault.lock();1068        await storageClear();1069        await vault.clearVaultHistory();1070        const toRestore: Record<string, unknown> = {};1071        if (preserved.peach_license !== undefined) toRestore.peach_license = preserved.peach_license;1072        if (preserved.peach_pro_celebrated !== undefined) toRestore.peach_pro_celebrated = preserved.peach_pro_celebrated;1073        if (Object.keys(toRestore).length > 0) {1074          await chrome.storage.local.set(toRestore);1075        }1076        if (chrome.storage.session) {1077          await chrome.storage.session.clear();1078        }1079        return { ok: true };1080      }10811082      case "SUBMIT_FEEDBACK": {1083        const { category, message, version, browser } = msg.payload as {1084          category: string;1085          message: string;1086          version: string;1087          browser: string;1088        };1089        if (!message.trim()) return { ok: false, error: "message_empty" };1090        if (!FEEDBACK_ENDPOINT) return { ok: true };1091        if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {1092          return { ok: false, error: "invalid_feedback_endpoint" };1093        }1094        try {1095          const res = await fetch(FEEDBACK_ENDPOINT, {1096            method: "POST",1097            headers: { "Content-Type": "application/json" },1098            body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),1099          });1100          return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1101        } catch (err) {1102          return { ok: false, error: err instanceof Error ? err.message : "network_error" };1103        }1104      }1105      // Android-only: biometric unlock with pre-derived key.1106      case "UNLOCK_WITH_KEY": {1107        const { masterKeyHex } = msg.payload as { masterKeyHex: string };1108        if (!isNativePlatform()) {1109          return { ok: false, error: "desktop_only_uses_password" };1110        }1111        try {1112          const result = await vault.unlockWithKey(masterKeyHex);1113          if (result.ok) {1114            onVaultUnlocked();1115            await publishInMemoryVault(result.entries);1116          }1117          return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };1118        } catch {1119          return { ok: false, error: "invalid_key" };1120        }1121      }1122      // Android: returns current in-memory derived key as hex. Used by biometric enable flow.1123      case "GET_DERIVED_KEY": {1124        if (!isNativePlatform()) {1125          return { ok: false, error: "android_only" };1126        }1127        if (!vault.isUnlocked()) {1128          return { ok: false, error: "vault_locked" };1129        }1130        return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };1131      }1132      case "GET_IDENTITY_SUGGESTIONS": {1133        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1134        const entries = vault.getEntries();1135        const suggestions = buildIdentitySuggestions(entries);1136        return { ok: true, data: { suggestions } };1137      }1138      case "POPULATE_INLINE_VAULT": {1139        if (!isNativePlatform()) return { ok: false };1140        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1141        const entries = vault.getEntries();1142        await publishInMemoryVault(entries);1143        return { ok: true };1144      }1145      case "ML_INIT":1146        return { ok: true };11471148      case "ML_PREDICT_BATCH": {1149        // AUD-002: shape-validate before forwarding to the ML helper.1150        const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1151        if (!Array.isArray(featuresArray)) {1152          return { ok: false, error: "invalid_payload" };1153        }1154        return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };1155      }11561157      // ── Chirp Sync (v3) ───────────────────────────────────────────────1158      // Returns the public-facing local identity needed by the popup to1159      // drive Phase-1 audio pairing. The private long-term key never1160      // leaves the background.1161      case "CHIRP_GET_LOCAL_IDENTITY": {1162        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1163        const proCheck = await requirePro("sync");1164        if (!proCheck.allowed) return { ok: false, error: "pro_required" };1165        // Identity derives from the stable root key, not the password-derived1166        // key, so pairing survives a master-password change (SEV-2-B).1167        const masterKeyHex = vault.getRootKeyHex();1168        if (!masterKeyHex) return { ok: false, error: "vault_locked" };1169        const masterKey = unhexBytes(masterKeyHex);1170        try {1171          const identity = await buildLocalIdentity(masterKey);1172          return { ok: true, data: identity };1173        } finally {1174          masterKey.fill(0);1175        }1176      }11771178      case "CHIRP_COMPLETE_PAIRING": {1179        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1180        const proCheck = await requirePro("sync");1181        if (!proCheck.allowed) return { ok: false, error: "pro_required" };1182        const paired = (msg.payload as { paired?: unknown })?.paired;1183        if (typeof paired !== "object" || paired === null) {1184          return { ok: false, error: "invalid_payload" };1185        }1186        const candidate = paired as Record<string, unknown>;1187        if (1188          typeof candidate.deviceId !== "string" || !candidate.deviceId ||1189          typeof candidate.deviceName !== "string" || !candidate.deviceName ||1190          typeof candidate.deviceType !== "string" ||1191          typeof candidate.longTermPublicKeyHex !== "string" ||1192          !/^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) ||1193          (1194            candidate.signingPublicKeyHex !== undefined &&1195            (1196              typeof candidate.signingPublicKeyHex !== "string" ||1197              !/^[0-9a-f]{64}$/.test(candidate.signingPublicKeyHex)1198            )1199          ) ||1200          typeof candidate.pairedAt !== "number"1201        ) {1202          return { ok: false, error: "invalid_paired_device" };1203        }1204        await upsertPairedDeviceV3(candidate as unknown as import("../shared/sync-types").PairedDevice);1205        return { ok: true };1206      }12071208      case "CHIRP_LIST_PAIRED_DEVICES": {1209        const devices = await listPairedDevicesV3();1210        return { ok: true, data: { devices } };1211      }12121213      case "CHIRP_UNPAIR_DEVICE": {1214        const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };1215        if (typeof deviceId !== "string" || !deviceId) {1216          return { ok: false, error: "invalid_payload" };1217        }1218        await removePairedDeviceByIdV3(deviceId);1219        return { ok: true };1220      }12211222      case "CHIRP_RENAME_LOCAL_DEVICE": {1223        const { name } = (msg.payload ?? {}) as { name?: string };1224        if (typeof name !== "string") return { ok: false, error: "invalid_payload" };1225        await setDeviceNameV3(name);1226        return { ok: true };1227      }12281229      case "TRIGGER_SYNC": {1230        const payload = (msg.payload ?? {}) as {1231          reason?: "vault_change" | "foreground" | "manual" | "mdns";1232          force?: boolean;1233          deviceId?: string;1234          discoveredIp?: string;1235          discoveredPort?: number;1236        };1237        const reason = payload.reason ?? "foreground";12381239        // mDNS discovery delivers a fresh (ip, port) resolved by the native1240        // side. Update the cached address before the sync runner tries to1241        // connect — avoids a subnet probe on the very next round.1242        if (1243          reason === "mdns" &&1244          typeof payload.deviceId === "string" &&1245          typeof payload.discoveredIp === "string"1246        ) {1247          const { findPairedDeviceById, setLastKnownIp } = await import("./paired-devices");1248          const device = await findPairedDeviceById(payload.deviceId);1249          if (device?.longTermPublicKeyHex) {1250            await setLastKnownIp(device.longTermPublicKeyHex, payload.discoveredIp).catch(() => undefined);1251          }1252        }12531254        const { triggerSyncIfReachable } = await import("./sync-trigger");1255        void triggerSyncIfReachable({ reason, force: payload.force === true });1256        return { ok: true };1257      }12581259      case "GET_SYNC_STATE": {1260        const { getSyncState } = await import("./sync-trigger");1261        return { ok: true, data: getSyncState() };1262      }12631264      case "GET_SYNC_ALERTS": {1265        const { consume } = (msg.payload ?? {}) as { consume?: boolean };1266        const alerts = await getPendingSyncAlerts();1267        // The popup reads on open and acknowledges with consume:true after1268        // rendering, so a popup crash between read and render doesn't lose1269        // the alerts.1270        if (consume) await clearPendingSyncAlerts();1271        return { ok: true, data: alerts };1272      }12731274      default:1275        return { ok: false, error: "unknown_message_type" };1276    }1277  } catch (err) {1278    return {1279      ok: false,1280      error: err instanceof Error ? err.message : "operation_failed",1281    };1282  }1283}12841285// --- Mobile platform helpers ---12861287// Android: populates the in-memory vault used by PeachAutofillService.1288async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {1289  const plugin = nativePlugin<{1290    setEntries?: (args: {1291      entries: Array<{1292        id: string;1293        title: string;1294        username: string;1295        email: string;1296        password: string;1297        url: string;1298        totp: string;1299        additionalUrls: string[];1300      }>;1301    }) => Promise<void>;1302  }>("InMemoryVaultPlugin");1303  if (!plugin?.setEntries) return;1304  await plugin.setEntries({1305    entries: entries.map((e) => ({1306      id: e.id,1307      title: e.title ?? "",1308      username: e.username ?? "",1309      email: (e as VaultEntry & { email?: string }).email ?? "",1310      password: e.password ?? "",1311      url: e.url ?? "",1312      totp: (e as VaultEntry & { totp?: string }).totp ?? "",1313      additionalUrls: (e as VaultEntry & { additionalUrls?: string[] }).additionalUrls ?? [],1314    })),1315  });1316}13171318// Android: clear the autofill in-memory store on lock.1319async function clearInMemoryVault(): Promise<void> {1320  const plugin = nativePlugin<{ clearEntries?: () => Promise<void> }>("InMemoryVaultPlugin");1321  if (!plugin?.clearEntries) return;1322  await plugin.clearEntries();1323}13241325// Returns username use-counts from the vault, sorted by frequency. Each row1326// also carries the entry's email so the popup can suggest the email/username1327// pair the user has actually used together.1328function buildIdentitySuggestions(1329  entries: VaultEntry[],1330): Array<{ email: string; username: string; count: number }> {1331  const usernameCounts = new Map<string, number>();1332  const usernameEmail = new Map<string, string>();1333  for (const entry of entries) {1334    const username = entry.username ?? "";1335    const email = (entry as VaultEntry & { email?: string }).email ?? "";1336    if (!username && !email) continue;1337    const key = username || email;1338    usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1339    if (!usernameEmail.has(key)) usernameEmail.set(key, email);1340  }1341  return Array.from(usernameCounts.entries())1342    .sort((a, b) => b[1] - a[1])1343    .map(([username, count]) => ({1344      email: usernameEmail.get(username) ?? "",1345      username,1346      count,1347    }));1348}13491350/** Notify all content scripts that the vault just became available. */1351function broadcastToContentScripts(message: { type: string }): void {1352  if (!chrome.tabs?.query) return;1353  chrome.tabs.query({}, (tabs) => {1354    for (const tab of tabs) {1355      if (tab.id !== undefined) {1356        chrome.tabs.sendMessage(tab.id, message).catch(() => {1357          // Tab may not have a content script — silently ignore1358        });1359      }1360    }1361  });1362}13631364function shouldRefreshAutoLock(type: MessageType): boolean {1365  return (1366    type !== "HAS_VAULT" &&1367    type !== "IS_UNLOCKED" &&1368    type !== "GET_VAULT_STATUS" &&1369    type !== "LOCK" &&1370    type !== "UPDATE_SETTINGS" &&1371    type !== "GET_PENDING_PASSKEY" &&1372    type !== "GET_PASSKEY_PROVIDER_STATE" &&1373    type !== "DECLINE_CARD_SAVE" &&1374    type !== "IS_CARD_DECLINED" &&1375    type !== "SUBMIT_FEEDBACK"1376  );1377}13781379function isAllowedCanaryWebhookUrl(value: string): boolean {1380  try {1381    const url = new URL(value);1382    if (url.protocol !== "https:") return false;13831384    const hostname = url.hostname.toLowerCase();1385    if (1386      hostname === "localhost" ||1387      hostname.endsWith(".localhost") ||1388      isPrivateIpv4(hostname) ||1389      isPrivateIpv6(hostname)1390    ) {1391      return false;1392    }13931394    return true;1395  } catch {1396    return false;1397  }1398}13991400function isPrivateIpv4(hostname: string): boolean {1401  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1402    return true;1403  }14041405  const octets = hostname.split(".").map((part) => Number(part));1406  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1407    return false;1408  }14091410  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1411}14121413function isPrivateIpv6(hostname: string): boolean {1414  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1415  return (1416    normalized === "::1" ||1417    normalized.startsWith("fc") ||1418    normalized.startsWith("fd") ||1419    normalized.startsWith("fe80:")1420  );1421}