messages.ts

42.5 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 { setupAutoLock, touchAutoLock } from "./alarms";14import * as vault from "./vault";15import {16  activateLicense,17  deactivateLicense,18  getLicenseInfo,19  getLicenseKey,20  isProUser,21  markCelebrationPlayed,22  shouldPlayCelebration,23} from "./license";24import {25  cancelFirefoxPasskeyCreate,26  completeFirefoxPasskeyCreate,27  getPasskeyProviderState,28  getPendingPasskeyRequest,29  handleFirefoxPasskeyCreate,30  handleFirefoxPasskeyGet,31  handlePasskeyHasCredentials,32  initiateFirefoxPasskeyCreate,33  isPasskeyOfferDeclined,34  recordPasskeyOfferDecline,35  resolvePendingPasskeyRequest,36  setPasskeyProviderEnabled,37} from "./passkey";38import { buildAdvancedHealthReport, resolveResetUrl } from "./health";39import { Capacitor } from "@capacitor/core";40import { storageClear } from "../shared/storage";41import type {42  Message,43  MessageResponse,44  MessageType,45  ProFeature,46} from "../shared/types";47import { isPasskeyOnly } from "../shared/types";48import type { SyncStatus } from "../shared/sync-types";4950/**51 * The single Pro gate. Every gated handler calls this first; the broadcast52 * lets the popup react (navigate to the License section, highlight the53 * relevant feature). Background never touches the popup view directly —54 * the popup owns navigation. See Projects.md "Pro gate rule".55 */56async function requirePro(57  feature: ProFeature,58  trigger: "user" | "background" = "user"59): Promise<{ allowed: boolean }> {60  if (await isProUser()) return { allowed: true };6162  if (trigger === "user") {63    // User deliberately triggered this feature — redirect the popup immediately.64    if (!Capacitor.isNativePlatform()) {65      chrome.runtime66        .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })67        .catch(() => {68          // Popup may be closed — the gate signal is best-effort.69        });70    }71  } else {72    // Background trigger (e.g. passive sync initiation) — no popup is open.73    // Queue a notice so the next popup open shows a soft banner instead of74    // silently failing.75    if (!Capacitor.isNativePlatform() && chrome.storage.session) {76      chrome.storage.session77        .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })78        .catch(() => {});79    }80  }8182  return { allowed: false };83}8485// ---------------------------------------------------------------------------86// Local device name. Peers see this string in PAIR_REQUEST / PAIR_ACCEPT and87// next to the device in their paired-devices list. Default to a platform-aware88// fallback when the user hasn't set one explicitly.89const DEVICE_NAME_KEY = "peach_device_name";9091async function getLocalDeviceName(): Promise<string> {92  try {93    const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);94    const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];95    if (typeof value === "string" && value.trim()) return value.trim();96  } catch {97    // fall through to default98  }99  if (Capacitor.getPlatform() === "android") return "Peach Android";100  if (Capacitor.getPlatform() === "ios") return "Peach iOS";101  return "Peach";102}103104// ---------------------------------------------------------------------------105// Peer discovery glue (Phase 7 popup pairing flow).106//107// The popup opens discovery via START_PEER_DISCOVERY. We connect to the108// native sync host, subscribe once, and re-broadcast every PEER_DISCOVERED /109// PEER_LOST event to the popup as a SYNC_PEER_DISCOVERED / SYNC_PEER_LOST110// runtime message. The popup is the only consumer; if it closes, STOP is111// sent on cleanup which tears down subscriptions and the native discovery.112// ---------------------------------------------------------------------------113114let peerDiscoverySubscriptions: Array<() => void> = [];115116async function startPeerDiscoveryForPopup(): Promise<void> {117  await stopPeerDiscoveryForPopup();118  const sync = await import('./sync');119  const { getPairedDevices } = await import('./sync-storage');120  sync.connectSyncHost();121122  const unsubDiscovered = sync.onPeerDiscovered((peer) => {123    void (async () => {124      const paired = await getPairedDevices();125      const alreadyPaired = paired.some((d) => d.deviceId === peer.deviceId);126      if (Capacitor.isNativePlatform()) return;127      chrome.runtime128        .sendMessage({129          type: "SYNC_PEER_DISCOVERED",130          peer: {131            deviceId: peer.deviceId,132            deviceName: peer.name,133            host: peer.host,134            port: peer.port,135            wsPort: peer.wsPort,136            x25519PublicKey: peer.x25519PublicKey,137            alreadyPaired,138          },139        })140        .catch(() => {});141    })();142  });143  const unsubLost = sync.onPeerLost((deviceId) => {144    if (Capacitor.isNativePlatform()) return;145    chrome.runtime146      .sendMessage({ type: "SYNC_PEER_LOST", deviceId })147      .catch(() => {});148  });149  peerDiscoverySubscriptions = [unsubDiscovered, unsubLost];150  sync.discoverPeers();151}152153async function stopPeerDiscoveryForPopup(): Promise<void> {154  for (const unsub of peerDiscoverySubscriptions) unsub();155  peerDiscoverySubscriptions = [];156  try {157    const sync = await import('./sync');158    if (sync.isHostConnected()) {159      await sync.stopDiscovery();160    }161  } catch {162    // best-effort163  }164}165166const PRIVATE_IPV4_RANGES = [167  { prefix: "10." },168  { prefix: "127." },169  { prefix: "169.254." },170  { prefix: "192.168." },171];172173// Empty disables network submission while the UI continues to function as a174// no-op (the handler returns ok:true without calling fetch). When set, the175// URL is validated by `isAllowedCanaryWebhookUrl` before any request, so the176// same private-IP/HTTPS rules that gate canary webhooks apply here.177const FEEDBACK_ENDPOINT = "";178179export function setupMessageHandler(): void {180  chrome.runtime.onMessage.addListener(181    (msg: unknown, sender, sendResponse) => {182      if (!isMessage(msg)) {183        sendResponse({ ok: false, error: "invalid message" });184        return true;185      }186      handleMessage(msg, sender)187        .then(sendResponse)188        .catch((err) =>189          sendResponse({190            ok: false,191            error: err instanceof Error ? err.message : String(err),192          })193        );194      return true;195    }196  );197}198199function isMessage(m: unknown): m is Message {200  return typeof m === "object" && m !== null && "type" in m;201}202203export async function handleMessage(204  msg: Message,205  sender: Partial<chrome.runtime.MessageSender> = {}206): Promise<MessageResponse> {207  try {208    // Validate sender — only allow messages from our extension pages/scripts.209    // `sender.id` is undefined for messages originating from extension UI210    // (popup/options), which is legitimate; reject only when it is defined211    // and does not match our extension id.212    if (213      !Capacitor.isNativePlatform() &&214      sender.id !== undefined &&215      sender.id !== chrome.runtime.id216    ) {217      return { ok: false, error: "invalid sender" };218    }219220    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {221      touchAutoLock();222    }223224    switch (msg.type) {225      case "CREATE_VAULT": {226        const { password } = msg.payload as { password: string };227        await vault.createVault(password);228        await setupAutoLock();229        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });230        return { ok: true };231      }232      case "UNLOCK": {233        const { password } = msg.payload as { password: string };234        const ok = await vault.unlock(password);235        if (ok) {236          await setupAutoLock();237          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });238        }239        return { ok };240      }241      case "LOCK":242        vault.lock();243        return { ok: true };244      case "IS_UNLOCKED":245        return { ok: true, data: vault.isUnlocked() };246      case "GET_VAULT_STATUS":247        return { ok: true, unlocked: vault.isUnlocked() };248      case "HAS_VAULT":249        return { ok: true, data: await vault.hasVault() };250      case "GET_ENTRIES":251        return { ok: true, data: vault.getEntries() };252      case "ADD_ENTRY": {253        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };254        const entry = await vault.addEntry(msg.payload as any);255        return { ok: true, data: entry };256      }257      case "BATCH_ADD_ENTRIES": {258        const { entries } = msg.payload as { entries: any[] };259        const added = await vault.batchAddEntries(entries);260        return { ok: true, data: added };261      }262      case "RESTORE_ENTRIES": {263        const { entries, mode, settings } = msg.payload as {264          entries: any[];265          mode: "replace" | "merge";266          settings?: vault.VaultSettings;267        };268        const summary = await vault.restoreEntries(entries, mode);269        if (settings && typeof settings === "object") {270          await vault.updateSettings(settings);271        }272        return { ok: true, data: summary };273      }274      case "CLEAR_VAULT_ENTRIES": {275        await vault.clearVaultEntries("REPLACE_ALL");276        return { ok: true };277      }278      case "GET_VAULT_HISTORY": {279        const snapshots = await vault.getVaultHistory();280        return { ok: true, data: snapshots };281      }282      case "RESTORE_VAULT_SNAPSHOT": {283        const { snapshotId } = msg.payload as { snapshotId: string };284        const result = await vault.restoreVaultSnapshot(snapshotId);285        if (!result.ok) return { ok: false, error: result.error };286        broadcastToContentScripts({ type: "VAULT_LOCKED" });287        return { ok: true };288      }289      case "COUNT_DUPLICATES": {290        return { ok: true, data: vault.countDuplicates() };291      }292      case "DEDUPLICATE_ENTRIES": {293        const removed = await vault.deduplicateEntries();294        return { ok: true, data: removed };295      }296      case "UPDATE_ENTRY": {297        const { id, patch } = msg.payload as any;298        await vault.updateEntry(id, patch);299        return { ok: true };300      }301      case "ADD_ADDITIONAL_URL": {302        const { id, url } = msg.payload as { id: string; url: string };303        const trimmed = typeof url === "string" ? url.trim() : "";304        if (!trimmed) return { ok: false, error: "url required" };305        const existing = vault.getEntryById(id);306        if (!existing) return { ok: false, error: "entry not found" };307        if (existing.type !== "login") return { ok: false, error: "login entries only" };308        const merged = [...(existing.additionalUrls ?? []), trimmed];309        await vault.updateEntry(id, { additionalUrls: merged });310        return { ok: true };311      }312      case "DELETE_ENTRY": {313        const { id } = msg.payload as { id: string };314        await vault.deleteEntry(id);315        return { ok: true };316      }317      case "GET_DELETED_ENTRIES":318        return { ok: true, data: vault.getDeletedEntries() };319      case "RESTORE_ENTRY": {320        const { id } = msg.payload as { id: string };321        await vault.restoreEntry(id);322        return { ok: true };323      }324      case "PURGE_ENTRY": {325        const { id } = msg.payload as { id: string };326        await vault.purgeEntry(id);327        return { ok: true };328      }329      case "PURGE_EXPIRED_TRASH": {330        const { retentionMs } = msg.payload as { retentionMs: number };331        const purged = await vault.purgeExpiredTrash(retentionMs);332        return { ok: true, data: purged };333      }334      case "AUTOFILL_QUERY": {335        if (!vault.isUnlocked()) {336          return { ok: false, error: "VAULT_LOCKED" };337        }338        const { domain, includeAll, kind } = msg.payload as {339          domain: string;340          includeAll?: boolean;341          kind?: "login" | "card";342        };343        // kind="card": return saved cards as compact summaries for the344        // inline card silver platter. Cards are not domain-scoped — every345        // saved card is a candidate on every checkout page — so domain is346        // ignored when kind === "card".347        if (kind === "card") {348          const cards = vault.getEntries().filter((e) => e.type === "card");349          return {350            ok: true,351            data: cards.map((e) => ({352              id: e.id,353              title: e.cardNickname || e.title,354              cardholderName: e.cardholderName,355              last4: e.last4,356              expMonth: e.expMonth,357              expYear: e.expYear,358              network: e.network,359            })),360          };361        }362        // The inline selector fills username+password into a form. Passkey-363        // only entries have no password to fill; sign-in for those goes364        // through the WebAuthn path instead. Filter them out everywhere so365        // the user doesn't see four "GitHub" rows that mostly do nothing.366        // Notes and cards never belonged here either.367        const matches = (368          includeAll369            ? vault370                .getEntries()371                .filter((entry) => entry.type !== "note" && entry.type !== "card")372            : vault.queryDomain(domain)373        ).filter((entry) => !isPasskeyOnly(entry));374        return {375          ok: true,376          data: matches.map((e) =>377            includeAll378              ? {379                  id: e.id,380                  title: e.title,381                  hasPasskey: e.passkey !== undefined,382                }383              : {384                  id: e.id,385                  title: e.title,386                  username: e.username,387                  url: e.url,388                  hasTotp: Boolean(e.totp),389                  hasPasskey: e.passkey !== undefined,390                }391          ),392        };393      }394      case "GET_ENTRY_FOR_FILL": {395        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };396        const { id } = msg.payload as { id: string };397        const entry = vault.getEntryById(id);398        if (!entry) return { ok: false, error: "entry not found" };399        // Passkey-only entries (passkey credential, no password) cannot be400        // filled into a form — sign-in goes through the WebAuthn path. A401        // login that ALSO has a passkey is still fillable for its password.402        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };403        // Strip the passkey credential from the response — content scripts404        // never need the private key, and there's no reason to expose the405        // public credential metadata over the boundary either.406        const { passkey: _strippedPasskey, ...safeEntry } = entry;407        return { ok: true, data: safeEntry };408      }409      case "COPY": {410        const { text, label, alreadyWritten } = msg.payload as {411          text: string;412          label: string;413          alreadyWritten?: boolean;414        };415        await copyToClipboard(text, label, Boolean(alreadyWritten));416        return { ok: true };417      }418      case "CHECK_BREACH": {419        const { password } = msg.payload as { password: string };420        const count = await vault.checkBreach(password);421        return { ok: true, data: count };422      }423      case "CHECK_ALL_BREACHES": {424        const allEntries = vault.getEntries();425        const results: Record<string, number> = {};426        for (const entry of allEntries) {427          if (entry.type === "note") continue;428          if (!entry.password) continue;429          results[entry.id] = await vault.checkBreach(entry.password);430          // Small delay — be a good citizen to the HIBP API431          await new Promise((r) => setTimeout(r, 200));432        }433        // Persist any non-zero counts back to entries434        for (const [id, count] of Object.entries(results)) {435          const entry = vault.getEntryById(id);436          if (entry && (entry.breach ?? 0) !== count) {437            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });438          }439        }440        return { ok: true, data: results };441      }442      case "GET_SETTINGS":443        return { ok: true, data: await vault.ensureSettingsLoaded() };444      case "UPDATE_SETTINGS": {445        const patch = msg.payload as Record<string, unknown>;446        await vault.updateSettings(patch as any);447        if (typeof patch.autoLockMinutes === "number") {448          await setupAutoLock();449          if (vault.isUnlocked()) touchAutoLock();450        }451        return { ok: true };452      }453      case "GET_PENDING_PASSKEY":454        return { ok: true, data: await getPendingPasskeyRequest() };455      case "APPROVE_PASSKEY": {456        const { requestId } = msg.payload as { requestId: number };457        await resolvePendingPasskeyRequest(requestId, true);458        return { ok: true };459      }460      case "DENY_PASSKEY": {461        const { requestId } = msg.payload as { requestId: number };462        await resolvePendingPasskeyRequest(requestId, false);463        return { ok: true };464      }465      case "GET_PASSKEYS":466        return {467          ok: true,468          data: vault.getEntries().filter((entry) => entry.passkey !== undefined),469        };470      case "DELETE_PASSKEY": {471        const { id } = msg.payload as { id: string };472        await vault.deleteEntry(id);473        return { ok: true };474      }475      case "GET_PASSKEY_PROVIDER_STATE":476        return { ok: true, data: await getPasskeyProviderState() };477      case "SET_PASSKEY_PROVIDER_ENABLED": {478        const { enabled } = msg.payload as { enabled: boolean };479        // Disabling is always free; gating fires only when a free user is480        // trying to turn passkey interception on.481        if (enabled) {482          const gate = await requirePro("passkeys");483          if (!gate.allowed) return { ok: false, error: "pro_required" };484        }485        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };486      }487      case "FIRE_CANARY_PING": {488        const { webhookUrl, entryTitle, trigger } = msg.payload as {489          webhookUrl: string;490          entryTitle: string;491          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";492        };493        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {494          return { ok: false, error: "invalid webhook url" };495        }496        const body = JSON.stringify({497          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",498          entry: entryTitle,499          trigger,500          timestamp: new Date().toISOString(),501          source: "peach-extension",502        });503        if (trigger === "test") {504          // Permitted outbound fetch: user-configured canary alert endpoint505          // (validated by isAllowedCanaryWebhookUrl above). This is the506          // only non-HIBP outbound call in the extension — see507          // Projects.md §3.7. Test pings are awaited so the UI can show508          // success/failure.509          try {510            const res = await fetch(webhookUrl, {511              method: "POST",512              headers: { "Content-Type": "application/json" },513              body,514            });515            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };516          } catch (err) {517            return { ok: false, error: err instanceof Error ? err.message : "network error" };518          }519        }520        // Non-test triggers are fire-and-forget521        fetch(webhookUrl, {522          method: "POST",523          headers: { "Content-Type": "application/json" },524          body,525        }).catch(() => {});526        return { ok: true };527      }528      case "ACTIVATE_LICENSE": {529        const { key } = msg.payload as { key: string };530        const licenseInfo = await activateLicense(key);531        return { ok: true, data: licenseInfo };532      }533      case "GET_LICENSE": {534        const licenseInfo = await getLicenseInfo();535        return { ok: true, data: licenseInfo };536      }537      case "GET_LICENSE_KEY": {538        const key = await getLicenseKey();539        return { ok: true, data: key };540      }541      case "DEACTIVATE_LICENSE": {542        await deactivateLicense();543        return { ok: true };544      }545      case "SHOULD_PLAY_CELEBRATION": {546        const should = await shouldPlayCelebration();547        return { ok: true, data: should };548      }549      case "MARK_CELEBRATION_PLAYED": {550        await markCelebrationPlayed();551        return { ok: true };552      }553      case "OPEN_PRO_GATE": {554        // Content script requests: open popup and navigate to License section.555        const { feature } = (msg.payload ?? {}) as { feature?: string };556        if (chrome.storage.session) {557          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });558        }559        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }560        return { ok: true };561      }562      case "GET_IDENTITIES": {563        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };564        return { ok: true, data: vault.getIdentities() };565      }566      case "GET_ADVANCED_HEALTH_REPORT": {567        const gate = await requirePro("advanced_health");568        if (!gate.allowed) return { ok: false, error: "pro_required" };569        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };570        const report = await buildAdvancedHealthReport(vault.getEntries());571        return { ok: true, data: report };572      }573      case "INITIATE_PASSWORD_CHANGE": {574        const gate = await requirePro("smart_password_change");575        if (!gate.allowed) return { ok: false, error: "pro_required" };576        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };577        const { id } = msg.payload as { id: string };578        const entry = vault.getEntryById(id);579        if (!entry) return { ok: false, error: "entry not found" };580        const url = resolveResetUrl(entry);581        if (!url) return { ok: false, error: "no reset url" };582        try {583          await chrome.tabs.create({ url });584          return { ok: true, data: { url } };585        } catch (err) {586          return {587            ok: false,588            error: err instanceof Error ? err.message : "failed to open tab",589          };590        }591      }592      case "IS_DURESS_ENABLED": {593        const enabled = await vault.isDuressEnabled();594        return { ok: true, data: enabled };595      }596      case "SETUP_DURESS": {597        const { duressPassword } = msg.payload as { duressPassword: string };598        await vault.setupDuress(duressPassword);599        return { ok: true };600      }601      case "DISABLE_DURESS": {602        await vault.disableDuress();603        return { ok: true };604      }605      case "VERIFY_MAIN_PASSWORD": {606        const { password } = msg.payload as { password: string };607        const ok = await vault.verifyMainPassword(password);608        return { ok: true, data: ok };609      }610      case "CHANGE_PASSWORD": {611        const { currentPassword, newPassword } = msg.payload as {612          currentPassword: string;613          newPassword: string;614        };615        const changed = await vault.changePassword(currentPassword, newPassword);616        return changed617          ? { ok: true }618          : { ok: false, error: "incorrect current password" };619      }620      case "GET_SITE_PASSWORD_POLICY": {621        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });622        if (!tab?.id) return { ok: true, data: null };623        try {624          const response = await chrome.tabs.sendMessage(tab.id, {625            type: "GET_SITE_PASSWORD_POLICY",626          });627          return (response as MessageResponse) ?? { ok: true, data: null };628        } catch {629          return { ok: true, data: null };630        }631      }632      case "GET_SYNC_STATE": {633        const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');634        const { isOnTailscale } = await import('./sync-network');635        const { isProUser } = await import('./license');636        const pairedDevices = await getPairedDevices();637        const trustedNetworks = await getTrustedNetworks();638        const tailscale = await isOnTailscale();639        const pro = await isProUser();640        641        let status: SyncStatus = "offline";642        if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {643          if (pairedDevices.length === 0) {644            status = "no_peers";645          } else {646            status = "synced";647          }648        } else {649          status = "not_trusted";650        }651        652        return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };653      }654      case "TRIGGER_SYNC": {655        const gate = await requirePro("sync");656        if (!gate.allowed) return { ok: false, error: "pro_required" };657        return { ok: true };658      }659      case "START_PAIRING": {660        const gate = await requirePro("sync");661        if (!gate.allowed) return { ok: false, error: "pro_required" };662        return { ok: true };663      }664      case "CANCEL_PAIRING": {665        return { ok: true };666      }667      case "ACCEPT_PAIRING": {668        const gate = await requirePro("sync");669        if (!gate.allowed) return { ok: false, error: "pro_required" };670        return { ok: true };671      }672      case "REMOVE_PAIRED_DEVICE": {673        const { deviceId } = msg.payload as { deviceId: string };674        const { removePairedDevice } = await import('./sync-storage');675        await removePairedDevice(deviceId);676        return { ok: true };677      }678      case "REMOVE_TRUSTED_NETWORK": {679        const { ssid, bssid, isTailscale } = msg.payload as any;680        const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');681        const networks = await getTrustedNetworks();682        const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 683          (isTailscale ? n.isTailscale : n.ssid === ssid) && 684          (!bssid || n.bssid === bssid)685        );686        if (network) {687          await removeTrustedNetwork(network.addedAt);688        }689        return { ok: true };690      }691      case "MARK_NETWORK_TRUSTED": {692        const { addTrustedNetwork } = await import('./sync-storage');693        const { getCurrentNetwork } = await import('./sync-network');694        const network = await getCurrentNetwork();695        await addTrustedNetwork({696          ssid: network.ssid,697          bssid: network.bssid,698          isTailscale: network.isTailscale,699          addedAt: Date.now(),700        });701        return { ok: true };702      }703      case "GET_INCOMING_PAIRING_REQUEST": {704        const { getIncomingPairingRequest } = await import('./sync-storage');705        const request = await getIncomingPairingRequest();706        return { ok: true, data: request };707      }708      case "RESPOND_INCOMING_PAIRING": {709        const { accept } = msg.payload as { accept: boolean };710        const { getIncomingPairingRequest, clearIncomingPairingRequest, addPairedDevice } =711          await import('./sync-storage');712        const request = await getIncomingPairingRequest();713        if (!request) return { ok: false, error: "no_pending_request" };714        if (Date.now() > request.expiresAt) {715          await clearIncomingPairingRequest();716          return { ok: false, error: "expired" };717        }718719        const sync = await import('./sync');720        const { encodeSyncMessage } = await import('../shared/sync-wire');721722        // Always clear the pending request — UI must not show the banner twice723        // even if the wire-level send below fails.724        await clearIncomingPairingRequest();725726        if (request.connectionId) {727          try {728            if (accept) {729              const ourKey = await sync.getIdentityPublicKey();730              const ourName = await getLocalDeviceName();731              sync.sendSyncData(732                request.connectionId,733                encodeSyncMessage({734                  type: "PAIR_ACCEPT",735                  public_key_hex: ourKey,736                  device_name: ourName,737                }),738              );739              await sync.trustDevice(request.publicKeyHex, request.deviceName);740              await addPairedDevice({741                deviceId: request.publicKeyHex,742                displayName: request.deviceName,743                x25519PublicKey: request.publicKeyHex,744                pairedAt: Date.now(),745                lastSeenAt: Date.now(),746              });747            } else {748              sync.sendSyncData(749                request.connectionId,750                encodeSyncMessage({ type: "PAIR_REJECT", reason: "user_declined" }),751              );752            }753          } catch (err) {754            // Wire failure is non-fatal for the local trust update — the755            // initiator will time out cleanly on its end.756            void err;757          }758          try { sync.disconnectFromPeer(request.connectionId); } catch { /* best-effort */ }759        } else if (accept) {760          // Test-injected request without a real connection: still record local trust.761          try {762            await sync.trustDevice(request.publicKeyHex, request.deviceName);763          } catch {764            // Native host may not be installed in test contexts.765          }766        }767768        return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };769      }770      case "START_PEER_DISCOVERY": {771        const gate = await requirePro("sync");772        if (!gate.allowed) return { ok: false, error: "pro_required" };773        try {774          await startPeerDiscoveryForPopup();775          return { ok: true };776        } catch (err) {777          return { ok: false, error: err instanceof Error ? err.message : "discovery_failed" };778        }779      }780      case "STOP_PEER_DISCOVERY": {781        try {782          await stopPeerDiscoveryForPopup();783        } catch {784          // best-effort cleanup785        }786        return { ok: true };787      }788      case "INITIATE_PAIR_WITH_PEER": {789        const gate = await requirePro("sync");790        if (!gate.allowed) return { ok: false, error: "pro_required" };791        const { peer } = msg.payload as {792          peer: { deviceId: string; deviceName: string; host: string; port: number; x25519PublicKey: string };793        };794795        const sync = await import('./sync');796        const { encodeSyncMessage } = await import('../shared/sync-wire');797        const { waitForPairingResponse } = await import('./sync-coordinator');798        const { addPairedDevice } = await import('./sync-storage');799800        let connectionId: string | undefined;801        try {802          // The responder accepts this connection only because pairing mode is803          // active on its side — Noise NK still authenticates the *transport*,804          // and the responder verifies our auth proof, but our public key is805          // not yet in their trust store. The PAIR_ACCEPT we receive is the806          // user's confirmation on the peer device that we should be added.807          connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);808809          const ourPublicKeyHex = await sync.getIdentityPublicKey();810          const deviceName = await getLocalDeviceName();811812          sync.sendSyncData(813            connectionId,814            encodeSyncMessage({815              type: "PAIR_REQUEST",816              public_key_hex: ourPublicKeyHex,817              device_name: deviceName,818            }),819          );820821          const response = await waitForPairingResponse(connectionId, 30_000);822823          if (response.type === "PAIR_ACCEPT") {824            await sync.trustDevice(response.public_key_hex, response.device_name);825            await addPairedDevice({826              deviceId: peer.deviceId,827              displayName: response.device_name || peer.deviceName,828              x25519PublicKey: response.public_key_hex,829              pairedAt: Date.now(),830              lastSeenAt: Date.now(),831            });832            sync.disconnectFromPeer(connectionId);833            return { ok: true, data: { connectionId, deviceName: response.device_name } };834          }835836          if (response.type === "PAIR_REJECT") {837            sync.disconnectFromPeer(connectionId);838            return { ok: false, error: "pair_rejected", data: { reason: response.reason } };839          }840841          sync.disconnectFromPeer(connectionId);842          return { ok: false, error: "unexpected_response" };843        } catch (err) {844          if (connectionId) {845            try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }846          }847          return { ok: false, error: err instanceof Error ? err.message : "pair_failed" };848        }849      }850      case "DEBUG_INJECT_INCOMING_PAIRING": {851        // Manual E2E rehearsal hook. Inserts a synthetic incoming pairing852        // request into session storage so Phase 7 banner UX can be exercised853        // without a live native host. No Pro gate — this is a no-op in854        // production unless explicitly invoked from a Settings debug action.855        const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {856          deviceName?: string;857          publicKeyHex?: string;858        };859        if (!deviceName || !publicKeyHex) {860          return { ok: false, error: "missing_payload" };861        }862        const { setIncomingPairingRequest } = await import('./sync-storage');863        const now = Date.now();864        await setIncomingPairingRequest({865          deviceName,866          publicKeyHex,867          receivedAt: now,868          expiresAt: now + 60_000,869        });870        return { ok: true };871      }872      case "RESET_SYNC_IDENTITY": {873        const gate = await requirePro("sync");874        if (!gate.allowed) return { ok: false, error: "pro_required" };875        const { getPairedDevices, removePairedDevice } = await import('./sync-storage');876        const devices = await getPairedDevices();877        for (const device of devices) {878          await removePairedDevice(device.deviceId);879        }880        // Native rotation is best-effort: when the native messaging host is881        // not yet installed (Phase 9), connectNative throws and we still882        // honour the local clear so the UI reflects the reset. The native883        // identity rotates the next time the host starts cleanly anyway.884        let nativePublicKey: string | null = null;885        try {886          const { rotateNativeIdentity, disconnectSyncHost } = await import('./sync');887          nativePublicKey = await rotateNativeIdentity();888          disconnectSyncHost();889        } catch {890          nativePublicKey = null;891        }892        return { ok: true, data: { removedDevices: devices.length, nativePublicKey } };893      }894      case "RECORD_MANUAL_SUMMON": {895        const { domain } = msg.payload as { domain: string };896        const settings = vault.getSettings();897        const existing = settings.manualSummonDomains?.[domain];898        const now = Date.now();899        await vault.updateSettings({900          manualSummonDomains: {901            ...settings.manualSummonDomains,902            [domain]: {903              firstSummonedAt: existing?.firstSummonedAt ?? now,904              summonCount: (existing?.summonCount ?? 0) + 1,905              lastSummonedAt: now,906            },907          },908        });909        return { ok: true };910      }911      case "DECLINE_CARD_SAVE": {912        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };913        await vault.declineCardSaveOffer(last4, expMonth, expYear);914        return { ok: true };915      }916      case "IS_CARD_DECLINED": {917        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };918        const declined = await vault.isCardDeclined(last4, expMonth, expYear);919        return { ok: true, data: declined };920      }921      case "INITIATE_FIREFOX_PASSKEY_CREATE": {922        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {923          rpId?: string;924          rpName?: string;925          userName?: string;926          userDisplayName?: string;927        };928        if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };929        return await initiateFirefoxPasskeyCreate({930          rpId,931          rpName: rpName ?? rpId,932          userName,933          userDisplayName,934        });935      }936937      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {938        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;939        if (!payload) return { ok: false, error: "missing payload" };940        return await completeFirefoxPasskeyCreate(payload);941      }942943      case "PASSKEY_OFFER_DECLINE": {944        const { domain } = (msg.payload ?? {}) as { domain?: string };945        if (!domain) return { ok: false, error: "domain required" };946        await recordPasskeyOfferDecline(domain);947        return { ok: true };948      }949950      case "GET_PASSKEY_OFFER_DECLINED": {951        const { domain } = (msg.payload ?? {}) as { domain?: string };952        if (!domain) return { ok: false, error: "domain required" };953        return { ok: true, data: await isPasskeyOfferDeclined(domain) };954      }955956      case "PASSKEY_HAS_CREDENTIALS": {957        const payload = msg.payload as {958          rpId?: string;959          allowCredentials?: Array<{ id: string; type?: string }>;960        } | undefined;961        if (!payload?.rpId) return { ok: false, error: "rpId required" };962        return await handlePasskeyHasCredentials({963          rpId: payload.rpId,964          allowCredentials: payload.allowCredentials ?? [],965        });966      }967968      case "PASSKEY_CREATE": {969        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;970        if (!payload?.rpId || !payload?.userName) {971          return { ok: false, error: "rpId and userName required" };972        }973        return await handleFirefoxPasskeyCreate(payload);974      }975976      case "PASSKEY_GET": {977        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;978        if (!payload?.rpId) return { ok: false, error: "rpId required" };979        return await handleFirefoxPasskeyGet(payload);980      }981982      case "GET_PASSKEY_FOR_DOMAIN": {983        const { domain } = (msg.payload ?? {}) as { domain?: string };984        if (!domain) return { ok: false, error: "domain required" };985        if (!vault.isUnlocked()) return { ok: true, data: null };986        const match = vault987          .getEntries()988          .find((entry) => entry.passkey?.rpId === domain);989        if (!match || !match.passkey) return { ok: true, data: null };990        // Slim shape — only the fields State 11 needs. Never expose private991        // key material, even though this is locked behind isUnlocked.992        return {993          ok: true,994          data: {995            rpId: match.passkey.rpId,996            userName: match.passkey.userName,997            browser: match.passkey.browser,998          },999        };1000      }10011002      case "DESTROY_VAULT": {1003        // Irreversible by design. Lock zeroes the derived key + in-memory entries;1004        // storage.local.clear() wipes every Peach key (slots, settings, license,1005        // onboarded flag, history, sync state, declined cards, recall, etc.);1006        // session.clear() wipes pending passkey requests + pro-notice queues.1007        // Caller (Panic.tsx) handles UI navigation back to onboarding.1008        vault.lock();1009        await storageClear();1010        // Belt-and-suspenders: storage.local.clear() already wipes1011        // peach_vault_history, but if storage.ts ever routes history to a1012        // different backend (e.g. Capacitor secure storage on Android) the1013        // explicit clear keeps the invariant that Panic leaves no recoverable1014        // vault state behind.1015        await vault.clearVaultHistory();1016        if (!Capacitor.isNativePlatform() && chrome.storage.session) {1017          await chrome.storage.session.clear();1018        }1019        return { ok: true };1020      }10211022      case "SUBMIT_FEEDBACK": {1023        const { category, message, version, browser } = msg.payload as {1024          category: string;1025          message: string;1026          version: string;1027          browser: string;1028        };1029        if (!message.trim()) return { ok: false, error: "message is empty" };1030        if (!FEEDBACK_ENDPOINT) return { ok: true };1031        if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {1032          return { ok: false, error: "invalid feedback endpoint" };1033        }1034        try {1035          const res = await fetch(FEEDBACK_ENDPOINT, {1036            method: "POST",1037            headers: { "Content-Type": "application/json" },1038            body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),1039          });1040          return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1041        } catch (err) {1042          return { ok: false, error: err instanceof Error ? err.message : "network error" };1043        }1044      }1045      default:1046        return { ok: false, error: "unknown message type" };1047    }1048  } catch (err) {1049    return {1050      ok: false,1051      error: err instanceof Error ? err.message : "operation failed",1052    };1053  }1054}10551056/** Notify all content scripts that the vault just became available. */1057function broadcastToContentScripts(message: { type: string }): void {1058  if (Capacitor.isNativePlatform() || !chrome.tabs?.query) return;1059  chrome.tabs.query({}, (tabs) => {1060    for (const tab of tabs) {1061      if (tab.id !== undefined) {1062        chrome.tabs.sendMessage(tab.id, message).catch(() => {1063          // Tab may not have a content script — silently ignore1064        });1065      }1066    }1067  });1068}10691070function shouldRefreshAutoLock(type: MessageType): boolean {1071  return (1072    type !== "HAS_VAULT" &&1073    type !== "IS_UNLOCKED" &&1074    type !== "GET_VAULT_STATUS" &&1075    type !== "LOCK" &&1076    type !== "UPDATE_SETTINGS" &&1077    type !== "GET_PENDING_PASSKEY" &&1078    type !== "GET_PASSKEY_PROVIDER_STATE" &&1079    type !== "DECLINE_CARD_SAVE" &&1080    type !== "IS_CARD_DECLINED" &&1081    type !== "SUBMIT_FEEDBACK"1082  );1083}10841085function isAllowedCanaryWebhookUrl(value: string): boolean {1086  try {1087    const url = new URL(value);1088    if (url.protocol !== "https:") return false;10891090    const hostname = url.hostname.toLowerCase();1091    if (1092      hostname === "localhost" ||1093      hostname.endsWith(".localhost") ||1094      isPrivateIpv4(hostname) ||1095      isPrivateIpv6(hostname)1096    ) {1097      return false;1098    }10991100    return true;1101  } catch {1102    return false;1103  }1104}11051106function isPrivateIpv4(hostname: string): boolean {1107  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1108    return true;1109  }11101111  const octets = hostname.split(".").map((part) => Number(part));1112  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1113    return false;1114  }11151116  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1117}11181119function isPrivateIpv6(hostname: string): boolean {1120  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1121  return (1122    normalized === "::1" ||1123    normalized.startsWith("fc") ||1124    normalized.startsWith("fd") ||1125    normalized.startsWith("fe80:")1126  );1127}