messages.ts

54.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 * as syncLifecycle from "./sync-lifecycle";16import {17  activateLicense,18  deactivateLicense,19  getLicenseInfo,20  getLicenseKey,21  isProUser,22  markCelebrationPlayed,23  shouldPlayCelebration,24} from "./license";25import {26  cancelFirefoxPasskeyCreate,27  completeFirefoxPasskeyCreate,28  getPasskeyProviderState,29  getPendingPasskeyRequest,30  handleFirefoxPasskeyCreate,31  handleFirefoxPasskeyGet,32  handlePasskeyHasCredentials,33  initiateFirefoxPasskeyCreate,34  isPasskeyOfferDeclined,35  recordPasskeyOfferDecline,36  resolvePendingPasskeyRequest,37  setPasskeyProviderEnabled,38} from "./passkey";39import { buildAdvancedHealthReport, resolveResetUrl } from "./health";40import { Capacitor } from "@capacitor/core";41import { storageClear } from "../shared/storage";42import type {43  Message,44  MessageResponse,45  MessageType,46  ProFeature,47} from "../shared/types";48import { isPasskeyOnly } from "../shared/types";49import type { SyncStatus } from "../shared/sync-types";5051/**52 * The single Pro gate. Every gated handler calls this first; the broadcast53 * lets the popup react (navigate to the License section, highlight the54 * relevant feature). Background never touches the popup view directly —55 * the popup owns navigation. See Projects.md "Pro gate rule".56 */57async function requirePro(58  feature: ProFeature,59  trigger: "user" | "background" = "user"60): Promise<{ allowed: boolean }> {61  if (await isProUser()) return { allowed: true };6263  if (trigger === "user") {64    // User deliberately triggered this feature — redirect the popup immediately.65    if (!Capacitor.isNativePlatform()) {66      chrome.runtime67        .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })68        .catch(() => {69          // Popup may be closed — the gate signal is best-effort.70        });71    }72  } else {73    // Background trigger (e.g. passive sync initiation) — no popup is open.74    // Queue a notice so the next popup open shows a soft banner instead of75    // silently failing.76    if (!Capacitor.isNativePlatform() && chrome.storage.session) {77      chrome.storage.session78        .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })79        .catch(() => {});80    }81  }8283  return { allowed: false };84}8586// ---------------------------------------------------------------------------87// Local device name. Peers see this string in PAIR_REQUEST / PAIR_ACCEPT and88// next to the device in their paired-devices list. Default to a platform-aware89// fallback when the user hasn't set one explicitly.90const DEVICE_NAME_KEY = "peach_device_name";9192async function getLocalDeviceName(): Promise<string> {93  try {94    const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);95    const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];96    if (typeof value === "string" && value.trim()) return value.trim();97  } catch {98    // fall through to default99  }100  if (Capacitor.getPlatform() === "android") return "Peach Android";101  if (Capacitor.getPlatform() === "ios") return "Peach iOS";102  return "Peach";103}104105// ---------------------------------------------------------------------------106// 6-digit code pairing.107108const PAIRING_TOKEN_TTL_MS = 5 * 60 * 1000;109110function generateSixDigitToken(): string {111  // crypto.getRandomValues, not Math.random — the token is the only secret112  // protecting the pairing window from a same-LAN attacker. 6 decimal digits113  // is 1 in 1,000,000; combined with a 5-minute TTL and pairing-mode gating114  // on the responder this is the right tradeoff between UX and security.115  const buf = new Uint32Array(1);116  crypto.getRandomValues(buf);117  const value = buf[0] % 1_000_000;118  return value.toString().padStart(6, "0");119}120121// ---------------------------------------------------------------------------122// Peer discovery glue (Phase 7 popup pairing flow).123//124// The popup opens discovery via START_PEER_DISCOVERY. We connect to the125// native sync host, subscribe once, and re-broadcast every PEER_DISCOVERED /126// PEER_LOST event to the popup as a SYNC_PEER_DISCOVERED / SYNC_PEER_LOST127// runtime message. The popup is the only consumer; if it closes, STOP is128// sent on cleanup which tears down subscriptions and the native discovery.129// ---------------------------------------------------------------------------130131let peerDiscoverySubscriptions: Array<() => void> = [];132133async function startPeerDiscoveryForPopup(): Promise<void> {134  await stopPeerDiscoveryForPopup();135  const sync = await import('./sync');136  const { getPairedDevices } = await import('./sync-storage');137  sync.connectSyncHost();138139  const unsubDiscovered = sync.onPeerDiscovered((peer) => {140    void (async () => {141      const paired = await getPairedDevices();142      const alreadyPaired = paired.some((d) => d.deviceId === peer.deviceId);143      if (Capacitor.isNativePlatform()) return;144      chrome.runtime145        .sendMessage({146          type: "SYNC_PEER_DISCOVERED",147          peer: {148            deviceId: peer.deviceId,149            deviceName: peer.name,150            host: peer.host,151            port: peer.port,152            wsPort: peer.wsPort,153            x25519PublicKey: peer.x25519PublicKey,154            alreadyPaired,155          },156        })157        .catch(() => {});158    })();159  });160  const unsubLost = sync.onPeerLost((deviceId) => {161    if (Capacitor.isNativePlatform()) return;162    chrome.runtime163      .sendMessage({ type: "SYNC_PEER_LOST", deviceId })164      .catch(() => {});165  });166  peerDiscoverySubscriptions = [unsubDiscovered, unsubLost];167  sync.discoverPeers();168}169170async function stopPeerDiscoveryForPopup(): Promise<void> {171  for (const unsub of peerDiscoverySubscriptions) unsub();172  peerDiscoverySubscriptions = [];173  try {174    const sync = await import('./sync');175    if (sync.isHostConnected()) {176      await sync.stopDiscovery();177    }178  } catch {179    // best-effort180  }181}182183const PRIVATE_IPV4_RANGES = [184  { prefix: "10." },185  { prefix: "127." },186  { prefix: "169.254." },187  { prefix: "192.168." },188];189190// Empty disables network submission while the UI continues to function as a191// no-op (the handler returns ok:true without calling fetch). When set, the192// URL is validated by `isAllowedCanaryWebhookUrl` before any request, so the193// same private-IP/HTTPS rules that gate canary webhooks apply here.194const FEEDBACK_ENDPOINT = "";195196export function setupMessageHandler(): void {197  chrome.runtime.onMessage.addListener(198    (msg: unknown, sender, sendResponse) => {199      if (!isMessage(msg)) {200        sendResponse({ ok: false, error: "invalid message" });201        return true;202      }203      handleMessage(msg, sender)204        .then(sendResponse)205        .catch((err) =>206          sendResponse({207            ok: false,208            error: err instanceof Error ? err.message : String(err),209          })210        );211      return true;212    }213  );214}215216function isMessage(m: unknown): m is Message {217  return typeof m === "object" && m !== null && "type" in m;218}219220export async function handleMessage(221  msg: Message,222  sender: Partial<chrome.runtime.MessageSender> = {}223): Promise<MessageResponse> {224  try {225    // Validate sender — only allow messages from our extension pages/scripts.226    // `sender.id` is undefined for messages originating from extension UI227    // (popup/options), which is legitimate; reject only when it is defined228    // and does not match our extension id.229    if (230      !Capacitor.isNativePlatform() &&231      sender.id !== undefined &&232      sender.id !== chrome.runtime.id233    ) {234      return { ok: false, error: "invalid sender" };235    }236237    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {238      touchAutoLock();239    }240241    switch (msg.type) {242      case "CREATE_VAULT": {243        const { password } = msg.payload as { password: string };244        await vault.createVault(password);245        await setupAutoLock();246        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });247        void syncLifecycle.onVaultUnlocked();248        return { ok: true };249      }250      case "UNLOCK": {251        const { password } = msg.payload as { password: string };252        const ok = await vault.unlock(password);253        if (ok) {254          await setupAutoLock();255          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });256          void syncLifecycle.onVaultUnlocked();257        }258        return { ok };259      }260      case "LOCK":261        vault.lock();262        void syncLifecycle.onVaultLocked();263        return { ok: true };264      case "IS_UNLOCKED":265        return { ok: true, data: vault.isUnlocked() };266      case "GET_VAULT_STATUS":267        return { ok: true, unlocked: vault.isUnlocked() };268      case "HAS_VAULT":269        return { ok: true, data: await vault.hasVault() };270      case "GET_ENTRIES":271        return { ok: true, data: vault.getEntries() };272      case "ADD_ENTRY": {273        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };274        const entry = await vault.addEntry(msg.payload as any);275        return { ok: true, data: entry };276      }277      case "BATCH_ADD_ENTRIES": {278        const { entries } = msg.payload as { entries: any[] };279        const added = await vault.batchAddEntries(entries);280        return { ok: true, data: added };281      }282      case "RESTORE_ENTRIES": {283        const { entries, mode, settings } = msg.payload as {284          entries: any[];285          mode: "replace" | "merge";286          settings?: vault.VaultSettings;287        };288        const summary = await vault.restoreEntries(entries, mode);289        if (settings && typeof settings === "object") {290          await vault.updateSettings(settings);291        }292        return { ok: true, data: summary };293      }294      case "CLEAR_VAULT_ENTRIES": {295        await vault.clearVaultEntries("REPLACE_ALL");296        return { ok: true };297      }298      case "GET_VAULT_HISTORY": {299        const snapshots = await vault.getVaultHistory();300        return { ok: true, data: snapshots };301      }302      case "RESTORE_VAULT_SNAPSHOT": {303        const { snapshotId } = msg.payload as { snapshotId: string };304        const result = await vault.restoreVaultSnapshot(snapshotId);305        if (!result.ok) return { ok: false, error: result.error };306        broadcastToContentScripts({ type: "VAULT_LOCKED" });307        return { ok: true };308      }309      case "COUNT_DUPLICATES": {310        return { ok: true, data: vault.countDuplicates() };311      }312      case "DEDUPLICATE_ENTRIES": {313        const removed = await vault.deduplicateEntries();314        return { ok: true, data: removed };315      }316      case "UPDATE_ENTRY": {317        const { id, patch } = msg.payload as any;318        await vault.updateEntry(id, patch);319        return { ok: true };320      }321      case "ADD_ADDITIONAL_URL": {322        const { id, url } = msg.payload as { id: string; url: string };323        const trimmed = typeof url === "string" ? url.trim() : "";324        if (!trimmed) return { ok: false, error: "url required" };325        const existing = vault.getEntryById(id);326        if (!existing) return { ok: false, error: "entry not found" };327        if (existing.type !== "login") return { ok: false, error: "login entries only" };328        const merged = [...(existing.additionalUrls ?? []), trimmed];329        await vault.updateEntry(id, { additionalUrls: merged });330        return { ok: true };331      }332      case "DELETE_ENTRY": {333        const { id } = msg.payload as { id: string };334        await vault.deleteEntry(id);335        return { ok: true };336      }337      case "GET_DELETED_ENTRIES":338        return { ok: true, data: vault.getDeletedEntries() };339      case "RESTORE_ENTRY": {340        const { id } = msg.payload as { id: string };341        await vault.restoreEntry(id);342        return { ok: true };343      }344      case "PURGE_ENTRY": {345        const { id } = msg.payload as { id: string };346        await vault.purgeEntry(id);347        return { ok: true };348      }349      case "PURGE_EXPIRED_TRASH": {350        const { retentionMs } = msg.payload as { retentionMs: number };351        const purged = await vault.purgeExpiredTrash(retentionMs);352        return { ok: true, data: purged };353      }354      case "AUTOFILL_QUERY": {355        if (!vault.isUnlocked()) {356          return { ok: false, error: "VAULT_LOCKED" };357        }358        const { domain, includeAll, kind } = msg.payload as {359          domain: string;360          includeAll?: boolean;361          kind?: "login" | "card";362        };363        // kind="card": return saved cards as compact summaries for the364        // inline card silver platter. Cards are not domain-scoped — every365        // saved card is a candidate on every checkout page — so domain is366        // ignored when kind === "card".367        if (kind === "card") {368          const cards = vault.getEntries().filter((e) => e.type === "card");369          return {370            ok: true,371            data: cards.map((e) => ({372              id: e.id,373              title: e.cardNickname || e.title,374              cardholderName: e.cardholderName,375              last4: e.last4,376              expMonth: e.expMonth,377              expYear: e.expYear,378              network: e.network,379            })),380          };381        }382        // The inline selector fills username+password into a form. Passkey-383        // only entries have no password to fill; sign-in for those goes384        // through the WebAuthn path instead. Filter them out everywhere so385        // the user doesn't see four "GitHub" rows that mostly do nothing.386        // Notes and cards never belonged here either.387        const matches = (388          includeAll389            ? vault390                .getEntries()391                .filter((entry) => entry.type !== "note" && entry.type !== "card")392            : vault.queryDomain(domain)393        ).filter((entry) => !isPasskeyOnly(entry));394        return {395          ok: true,396          data: matches.map((e) =>397            includeAll398              ? {399                  id: e.id,400                  title: e.title,401                  hasPasskey: e.passkey !== undefined,402                }403              : {404                  id: e.id,405                  title: e.title,406                  username: e.username,407                  url: e.url,408                  hasTotp: Boolean(e.totp),409                  hasPasskey: e.passkey !== undefined,410                }411          ),412        };413      }414      case "GET_ENTRY_FOR_FILL": {415        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };416        const { id } = msg.payload as { id: string };417        const entry = vault.getEntryById(id);418        if (!entry) return { ok: false, error: "entry not found" };419        // Passkey-only entries (passkey credential, no password) cannot be420        // filled into a form — sign-in goes through the WebAuthn path. A421        // login that ALSO has a passkey is still fillable for its password.422        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };423        // Strip the passkey credential from the response — content scripts424        // never need the private key, and there's no reason to expose the425        // public credential metadata over the boundary either.426        const { passkey: _strippedPasskey, ...safeEntry } = entry;427        return { ok: true, data: safeEntry };428      }429      case "COPY": {430        const { text, label, alreadyWritten } = msg.payload as {431          text: string;432          label: string;433          alreadyWritten?: boolean;434        };435        await copyToClipboard(text, label, Boolean(alreadyWritten));436        return { ok: true };437      }438      case "CHECK_BREACH": {439        const { password } = msg.payload as { password: string };440        const count = await vault.checkBreach(password);441        return { ok: true, data: count };442      }443      case "CHECK_ALL_BREACHES": {444        const allEntries = vault.getEntries();445        const results: Record<string, number> = {};446        for (const entry of allEntries) {447          if (entry.type === "note") continue;448          if (!entry.password) continue;449          results[entry.id] = await vault.checkBreach(entry.password);450          // Small delay — be a good citizen to the HIBP API451          await new Promise((r) => setTimeout(r, 200));452        }453        // Persist any non-zero counts back to entries454        for (const [id, count] of Object.entries(results)) {455          const entry = vault.getEntryById(id);456          if (entry && (entry.breach ?? 0) !== count) {457            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });458          }459        }460        return { ok: true, data: results };461      }462      case "GET_SETTINGS":463        return { ok: true, data: await vault.ensureSettingsLoaded() };464      case "UPDATE_SETTINGS": {465        const patch = msg.payload as Record<string, unknown>;466        await vault.updateSettings(patch as any);467        if (typeof patch.autoLockMinutes === "number") {468          await setupAutoLock();469          if (vault.isUnlocked()) touchAutoLock();470        }471        return { ok: true };472      }473      case "GET_PENDING_PASSKEY":474        return { ok: true, data: await getPendingPasskeyRequest() };475      case "APPROVE_PASSKEY": {476        const { requestId } = msg.payload as { requestId: number };477        await resolvePendingPasskeyRequest(requestId, true);478        return { ok: true };479      }480      case "DENY_PASSKEY": {481        const { requestId } = msg.payload as { requestId: number };482        await resolvePendingPasskeyRequest(requestId, false);483        return { ok: true };484      }485      case "GET_PASSKEYS":486        return {487          ok: true,488          data: vault.getEntries().filter((entry) => entry.passkey !== undefined),489        };490      case "DELETE_PASSKEY": {491        const { id } = msg.payload as { id: string };492        await vault.deleteEntry(id);493        return { ok: true };494      }495      case "GET_PASSKEY_PROVIDER_STATE":496        return { ok: true, data: await getPasskeyProviderState() };497      case "SET_PASSKEY_PROVIDER_ENABLED": {498        const { enabled } = msg.payload as { enabled: boolean };499        // Disabling is always free; gating fires only when a free user is500        // trying to turn passkey interception on.501        if (enabled) {502          const gate = await requirePro("passkeys");503          if (!gate.allowed) return { ok: false, error: "pro_required" };504        }505        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };506      }507      case "FIRE_CANARY_PING": {508        const { webhookUrl, entryTitle, trigger } = msg.payload as {509          webhookUrl: string;510          entryTitle: string;511          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";512        };513        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {514          return { ok: false, error: "invalid webhook url" };515        }516        const body = JSON.stringify({517          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",518          entry: entryTitle,519          trigger,520          timestamp: new Date().toISOString(),521          source: "peach-extension",522        });523        if (trigger === "test") {524          // Permitted outbound fetch: user-configured canary alert endpoint525          // (validated by isAllowedCanaryWebhookUrl above). This is the526          // only non-HIBP outbound call in the extension — see527          // Projects.md §3.7. Test pings are awaited so the UI can show528          // success/failure.529          try {530            const res = await fetch(webhookUrl, {531              method: "POST",532              headers: { "Content-Type": "application/json" },533              body,534            });535            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };536          } catch (err) {537            return { ok: false, error: err instanceof Error ? err.message : "network error" };538          }539        }540        // Non-test triggers are fire-and-forget541        fetch(webhookUrl, {542          method: "POST",543          headers: { "Content-Type": "application/json" },544          body,545        }).catch(() => {});546        return { ok: true };547      }548      case "ACTIVATE_LICENSE": {549        const { key } = msg.payload as { key: string };550        const licenseInfo = await activateLicense(key);551        return { ok: true, data: licenseInfo };552      }553      case "GET_LICENSE": {554        const licenseInfo = await getLicenseInfo();555        return { ok: true, data: licenseInfo };556      }557      case "GET_LICENSE_KEY": {558        const key = await getLicenseKey();559        return { ok: true, data: key };560      }561      case "DEACTIVATE_LICENSE": {562        await deactivateLicense();563        return { ok: true };564      }565      case "SHOULD_PLAY_CELEBRATION": {566        const should = await shouldPlayCelebration();567        return { ok: true, data: should };568      }569      case "MARK_CELEBRATION_PLAYED": {570        await markCelebrationPlayed();571        return { ok: true };572      }573      case "OPEN_PRO_GATE": {574        // Content script requests: open popup and navigate to License section.575        const { feature } = (msg.payload ?? {}) as { feature?: string };576        if (chrome.storage.session) {577          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });578        }579        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }580        return { ok: true };581      }582      case "GET_IDENTITIES": {583        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };584        return { ok: true, data: vault.getIdentities() };585      }586      case "GET_ADVANCED_HEALTH_REPORT": {587        const gate = await requirePro("advanced_health");588        if (!gate.allowed) return { ok: false, error: "pro_required" };589        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };590        const report = await buildAdvancedHealthReport(vault.getEntries());591        return { ok: true, data: report };592      }593      case "INITIATE_PASSWORD_CHANGE": {594        const gate = await requirePro("smart_password_change");595        if (!gate.allowed) return { ok: false, error: "pro_required" };596        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };597        const { id } = msg.payload as { id: string };598        const entry = vault.getEntryById(id);599        if (!entry) return { ok: false, error: "entry not found" };600        const url = resolveResetUrl(entry);601        if (!url) return { ok: false, error: "no reset url" };602        try {603          await chrome.tabs.create({ url });604          return { ok: true, data: { url } };605        } catch (err) {606          return {607            ok: false,608            error: err instanceof Error ? err.message : "failed to open tab",609          };610        }611      }612      case "IS_DURESS_ENABLED": {613        const enabled = await vault.isDuressEnabled();614        return { ok: true, data: enabled };615      }616      case "SETUP_DURESS": {617        const { duressPassword } = msg.payload as { duressPassword: string };618        await vault.setupDuress(duressPassword);619        return { ok: true };620      }621      case "DISABLE_DURESS": {622        await vault.disableDuress();623        return { ok: true };624      }625      case "VERIFY_MAIN_PASSWORD": {626        const { password } = msg.payload as { password: string };627        const ok = await vault.verifyMainPassword(password);628        return { ok: true, data: ok };629      }630      case "CHANGE_PASSWORD": {631        const { currentPassword, newPassword } = msg.payload as {632          currentPassword: string;633          newPassword: string;634        };635        const changed = await vault.changePassword(currentPassword, newPassword);636        return changed637          ? { ok: true }638          : { ok: false, error: "incorrect current password" };639      }640      case "GET_SITE_PASSWORD_POLICY": {641        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });642        if (!tab?.id) return { ok: true, data: null };643        try {644          const response = await chrome.tabs.sendMessage(tab.id, {645            type: "GET_SITE_PASSWORD_POLICY",646          });647          return (response as MessageResponse) ?? { ok: true, data: null };648        } catch {649          return { ok: true, data: null };650        }651      }652      case "GET_SYNC_STATE": {653        const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');654        const { isOnTailscale } = await import('./sync-network');655        const { isProUser } = await import('./license');656        const { storageGet } = await import('../shared/storage');657        const pairedDevices = await getPairedDevices();658        const trustedNetworks = await getTrustedNetworks();659        const tailscale = await isOnTailscale();660        const pro = await isProUser();661662        let status: SyncStatus = "offline";663        if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {664          if (pairedDevices.length === 0) {665            status = "no_peers";666          } else {667            status = "synced";668          }669        } else {670          status = "not_trusted";671        }672673        const lastSyncRaw = await storageGet("peach_last_sync_at");674        const lastSyncParsed = lastSyncRaw ? Number.parseInt(lastSyncRaw, 10) : NaN;675        const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : undefined;676677        return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };678      }679      case "TRIGGER_SYNC": {680        const gate = await requirePro("sync");681        if (!gate.allowed) return { ok: false, error: "pro_required" };682        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };683684        const { getPairedDevices, updatePairedDevice } = await import('./sync-storage');685        const paired = await getPairedDevices();686        if (paired.length === 0) return { ok: false, error: "no_paired_devices" };687688        const lifecycle = await import('./sync-lifecycle');689        const sync = await import('./sync');690        const { storageGet, storageSet } = await import('../shared/storage');691        const { createNativeSyncTransport, runBlobSyncSession } = await import('./sync-coordinator');692693        const discovered = lifecycle.getDiscoveredPeerCache();694        const reachable = paired.filter((device) =>695          discovered.some((peer) => peer.x25519PublicKey === device.x25519PublicKey),696        );697        if (reachable.length === 0) return { ok: false, error: "no_devices_reachable" };698699        // Sequential — each session may update peach_vault_a, and the next700        // peer should receive the freshest blob (which may already include701        // changes from the previous device in this same pass).702        const results: Array<{703          deviceName: string;704          status: "completed" | "errored" | "closed";705          error?: string;706        }> = [];707        for (const device of reachable) {708          const peer = discovered.find((p) => p.x25519PublicKey === device.x25519PublicKey)!;709          try {710            const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);711            const transport = await createNativeSyncTransport(connectionId);712            const result = await runBlobSyncSession("initiator", transport, {713              isPro: async () => true,714              isUnlocked: () => vault.isUnlocked(),715              getVaultBlobs: async () => ({716                vault_a: (await storageGet("peach_vault_a")) ?? "",717                vault_b: (await storageGet("peach_vault_b")) ?? "",718              }),719            });720            results.push({721              deviceName: device.displayName,722              status: result.status,723              error: result.error,724            });725            if (result.status === "completed") {726              await updatePairedDevice(device.deviceId, {727                lastSeenAt: Date.now(),728                lastSyncAt: Date.now(),729              });730            }731          } catch (err) {732            results.push({733              deviceName: device.displayName,734              status: "errored",735              error: err instanceof Error ? err.message : String(err),736            });737          }738        }739740        await storageSet("peach_last_sync_at", String(Date.now()));741        return {742          ok: true,743          data: {744            results,745            syncedCount: results.filter((r) => r.status === "completed").length,746            totalReachable: reachable.length,747          },748        };749      }750      case "START_PAIRING": {751        const gate = await requirePro("sync");752        if (!gate.allowed) return { ok: false, error: "pro_required" };753        return { ok: true };754      }755      case "CANCEL_PAIRING": {756        return { ok: true };757      }758      case "ACCEPT_PAIRING": {759        const gate = await requirePro("sync");760        if (!gate.allowed) return { ok: false, error: "pro_required" };761        return { ok: true };762      }763      case "REMOVE_PAIRED_DEVICE": {764        const { deviceId } = msg.payload as { deviceId: string };765        const { removePairedDevice } = await import('./sync-storage');766        await removePairedDevice(deviceId);767        return { ok: true };768      }769      case "REMOVE_TRUSTED_NETWORK": {770        const { ssid, bssid, isTailscale } = msg.payload as any;771        const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');772        const networks = await getTrustedNetworks();773        const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 774          (isTailscale ? n.isTailscale : n.ssid === ssid) && 775          (!bssid || n.bssid === bssid)776        );777        if (network) {778          await removeTrustedNetwork(network.addedAt);779        }780        return { ok: true };781      }782      case "MARK_NETWORK_TRUSTED": {783        const { addTrustedNetwork } = await import('./sync-storage');784        const { getCurrentNetwork } = await import('./sync-network');785        const network = await getCurrentNetwork();786        await addTrustedNetwork({787          ssid: network.ssid,788          bssid: network.bssid,789          isTailscale: network.isTailscale,790          addedAt: Date.now(),791        });792        return { ok: true };793      }794      case "GET_INCOMING_PAIRING_REQUEST": {795        const { getIncomingPairingRequest } = await import('./sync-storage');796        const request = await getIncomingPairingRequest();797        return { ok: true, data: request };798      }799      case "RESPOND_INCOMING_PAIRING": {800        const { accept } = msg.payload as { accept: boolean };801        const { getIncomingPairingRequest, clearIncomingPairingRequest, addPairedDevice } =802          await import('./sync-storage');803        const request = await getIncomingPairingRequest();804        if (!request) return { ok: false, error: "no_pending_request" };805        if (Date.now() > request.expiresAt) {806          await clearIncomingPairingRequest();807          return { ok: false, error: "expired" };808        }809810        const sync = await import('./sync');811        const { encodeSyncMessage } = await import('../shared/sync-wire');812813        // Always clear the pending request — UI must not show the banner twice814        // even if the wire-level send below fails.815        await clearIncomingPairingRequest();816817        if (request.connectionId) {818          try {819            if (accept) {820              const ourKey = await sync.getIdentityPublicKey();821              const ourName = await getLocalDeviceName();822              sync.sendSyncData(823                request.connectionId,824                encodeSyncMessage({825                  type: "PAIR_ACCEPT",826                  public_key_hex: ourKey,827                  device_name: ourName,828                }),829              );830              await sync.trustDevice(request.publicKeyHex, request.deviceName);831              await addPairedDevice({832                deviceId: request.publicKeyHex,833                displayName: request.deviceName,834                x25519PublicKey: request.publicKeyHex,835                pairedAt: Date.now(),836                lastSeenAt: Date.now(),837              });838            } else {839              sync.sendSyncData(840                request.connectionId,841                encodeSyncMessage({ type: "PAIR_REJECT", reason: "user_declined" }),842              );843            }844          } catch (err) {845            // Wire failure is non-fatal for the local trust update — the846            // initiator will time out cleanly on its end.847            void err;848          }849          try { sync.disconnectFromPeer(request.connectionId); } catch { /* best-effort */ }850        } else if (accept) {851          // Test-injected request without a real connection: still record local trust.852          try {853            await sync.trustDevice(request.publicKeyHex, request.deviceName);854          } catch {855            // Native host may not be installed in test contexts.856          }857        }858859        return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };860      }861      case "START_PEER_DISCOVERY": {862        const gate = await requirePro("sync");863        if (!gate.allowed) return { ok: false, error: "pro_required" };864        try {865          await startPeerDiscoveryForPopup();866          return { ok: true };867        } catch (err) {868          return { ok: false, error: err instanceof Error ? err.message : "discovery_failed" };869        }870      }871      case "STOP_PEER_DISCOVERY": {872        try {873          await stopPeerDiscoveryForPopup();874        } catch {875          // best-effort cleanup876        }877        return { ok: true };878      }879      case "START_PAIRING_SERVER": {880        if (Capacitor.isNativePlatform()) {881          // Show-mode on Android needs a Kotlin TCP/WebSocket server plus a882          // Noise NK responder. Until that lands, Android pairs by typing883          // the code shown on the desktop (the entering-side path is wired).884          return { ok: false, error: "android_pairing_show_not_yet_supported" };885        }886        const gate = await requirePro("sync");887        if (!gate.allowed) return { ok: false, error: "pro_required" };888        try {889          const sync = await import('./sync');890          const { setActivePairingToken } = await import('./pairing-server-state');891          const { getOrCreateLocalDeviceId } = await import('./sync-identity-glue');892          sync.connectSyncHost();893          const token = generateSixDigitToken();894          const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;895          const deviceId = await getOrCreateLocalDeviceId();896          const name = await getLocalDeviceName();897          await sync.startPairingServer({ token, deviceId, name });898          await setActivePairingToken({ token, expiresAt });899          return { ok: true, data: { token, expiresAt } };900        } catch (err) {901          return { ok: false, error: err instanceof Error ? err.message : "pairing_server_failed" };902        }903      }904      case "STOP_PAIRING_SERVER": {905        if (Capacitor.isNativePlatform()) return { ok: true };906        try {907          const sync = await import('./sync');908          const { setActivePairingToken } = await import('./pairing-server-state');909          await setActivePairingToken(null);910          if (sync.isHostConnected()) {911            await sync.stopPairingServer();912          }913        } catch {914          // best-effort cleanup915        }916        return { ok: true };917      }918      case "CONNECT_WITH_CODE": {919        if (Capacitor.isNativePlatform()) {920          const gate = await requirePro("sync");921          if (!gate.allowed) return { ok: false, error: "pro_required" };922          const { token: rawToken } = msg.payload as { token: string };923          const token = String(rawToken).replace(/\D/g, "");924          if (token.length !== 6) return { ok: false, error: "invalid_code" };925          try {926            const { registerPlugin } = await import("@capacitor/core");927            const Sync = registerPlugin<{928              resolvePeerByCode(opts: { token: string; timeoutMs?: number }): Promise<{929                host: string;930                tcpPort: number;931                wsPort?: number;932                x25519PublicKey: string;933                deviceId: string;934                name: string;935              }>;936            }>("Sync");937            const resolved = await Sync.resolvePeerByCode({ token, timeoutMs: 8_000 });938            if (resolved.wsPort === undefined) {939              return { ok: false, error: "peer_missing_ws_port" };940            }941            const peer = {942              deviceId: resolved.deviceId,943              deviceName: resolved.name,944              host: resolved.host,945              port: resolved.tcpPort,946              wsPort: resolved.wsPort,947              x25519PublicKey: resolved.x25519PublicKey,948              alreadyPaired: false,949            };950            const { getAndroidSyncIdentity } = await import("../shared/android-sync-identity");951            const identity = await getAndroidSyncIdentity();952            const ourPublicKeyHex = Array.from(identity.publicKey, (b) =>953              b.toString(16).padStart(2, "0"),954            ).join("");955            const ourDeviceName = await getLocalDeviceName();956            const { runCapacitorPairingHandshake } = await import(957              "../shared/capacitor-pairing-handshake"958            );959            const { addPairedDevice } = await import("./sync-storage");960            const { setRecentPairing } = await import("./pairing-server-state");961            const result = await runCapacitorPairingHandshake(962              peer,963              identity.privateKey,964              identity.publicKey,965              ourPublicKeyHex,966              ourDeviceName,967            );968            await addPairedDevice({969              deviceId: peer.deviceId,970              displayName: result.deviceName || peer.deviceName,971              x25519PublicKey: result.publicKeyHex,972              pairedAt: Date.now(),973              lastSeenAt: Date.now(),974            });975            await setRecentPairing({976              deviceName: result.deviceName || peer.deviceName,977              pairedAt: Date.now(),978              side: "entering",979            });980            return { ok: true, data: { deviceName: result.deviceName || peer.deviceName } };981          } catch (err) {982            const msg = err instanceof Error ? err.message : String(err);983            if (msg.startsWith("pair_rejected:")) {984              return { ok: false, error: "pair_rejected", data: { reason: msg.slice("pair_rejected:".length) } };985            }986            if (msg.includes("no peer responded") || msg.includes("resolve failed")) {987              return { ok: false, error: msg };988            }989            return { ok: false, error: msg || "connect_failed" };990          }991        }992        const gate = await requirePro("sync");993        if (!gate.allowed) return { ok: false, error: "pro_required" };994        const { token: rawToken } = msg.payload as { token: string };995        const token = String(rawToken).replace(/\D/g, "");996        if (token.length !== 6) return { ok: false, error: "invalid_code" };997998        const sync = await import('./sync');999        const { encodeSyncMessage } = await import('../shared/sync-wire');1000        const { waitForPairingResponse } = await import('./sync-coordinator');1001        const { addPairedDevice } = await import('./sync-storage');1002        const { setRecentPairing } = await import('./pairing-server-state');10031004        sync.connectSyncHost();1005        let connectionId: string | undefined;1006        try {1007          const peer = await sync.resolvePeerByCode(token, 8_000);1008          connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);10091010          const ourPublicKeyHex = await sync.getIdentityPublicKey();1011          const deviceName = await getLocalDeviceName();10121013          sync.sendSyncData(1014            connectionId,1015            encodeSyncMessage({1016              type: "PAIR_REQUEST",1017              public_key_hex: ourPublicKeyHex,1018              device_name: deviceName,1019            }),1020          );10211022          const response = await waitForPairingResponse(connectionId, 30_000);10231024          if (response.type === "PAIR_ACCEPT") {1025            await sync.trustDevice(response.public_key_hex, response.device_name);1026            await addPairedDevice({1027              deviceId: peer.deviceId,1028              displayName: response.device_name || peer.name,1029              x25519PublicKey: response.public_key_hex,1030              pairedAt: Date.now(),1031              lastSeenAt: Date.now(),1032            });1033            sync.disconnectFromPeer(connectionId);1034            // Stash for the popup so a popup-close mid-handshake still surfaces1035            // a confirmation on next open.1036            await setRecentPairing({1037              deviceName: response.device_name || peer.name,1038              pairedAt: Date.now(),1039              side: "entering",1040            });1041            return { ok: true, data: { deviceName: response.device_name || peer.name } };1042          }10431044          if (response.type === "PAIR_REJECT") {1045            sync.disconnectFromPeer(connectionId);1046            return { ok: false, error: "pair_rejected", data: { reason: response.reason } };1047          }10481049          sync.disconnectFromPeer(connectionId);1050          return { ok: false, error: "unexpected_response" };1051        } catch (err) {1052          if (connectionId) {1053            try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }1054          }1055          return { ok: false, error: err instanceof Error ? err.message : "connect_failed" };1056        }1057      }1058      case "GET_RECENT_PAIRING": {1059        const { consumeRecentPairing } = await import('./pairing-server-state');1060        const recent = await consumeRecentPairing();1061        return { ok: true, data: recent };1062      }1063      case "INITIATE_PAIR_WITH_PEER": {1064        const gate = await requirePro("sync");1065        if (!gate.allowed) return { ok: false, error: "pro_required" };1066        const { peer } = msg.payload as {1067          peer: { deviceId: string; deviceName: string; host: string; port: number; x25519PublicKey: string };1068        };10691070        const sync = await import('./sync');1071        const { encodeSyncMessage } = await import('../shared/sync-wire');1072        const { waitForPairingResponse } = await import('./sync-coordinator');1073        const { addPairedDevice } = await import('./sync-storage');10741075        let connectionId: string | undefined;1076        try {1077          // The responder accepts this connection only because pairing mode is1078          // active on its side — Noise NK still authenticates the *transport*,1079          // and the responder verifies our auth proof, but our public key is1080          // not yet in their trust store. The PAIR_ACCEPT we receive is the1081          // user's confirmation on the peer device that we should be added.1082          connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);10831084          const ourPublicKeyHex = await sync.getIdentityPublicKey();1085          const deviceName = await getLocalDeviceName();10861087          sync.sendSyncData(1088            connectionId,1089            encodeSyncMessage({1090              type: "PAIR_REQUEST",1091              public_key_hex: ourPublicKeyHex,1092              device_name: deviceName,1093            }),1094          );10951096          const response = await waitForPairingResponse(connectionId, 30_000);10971098          if (response.type === "PAIR_ACCEPT") {1099            await sync.trustDevice(response.public_key_hex, response.device_name);1100            await addPairedDevice({1101              deviceId: peer.deviceId,1102              displayName: response.device_name || peer.deviceName,1103              x25519PublicKey: response.public_key_hex,1104              pairedAt: Date.now(),1105              lastSeenAt: Date.now(),1106            });1107            sync.disconnectFromPeer(connectionId);1108            return { ok: true, data: { connectionId, deviceName: response.device_name } };1109          }11101111          if (response.type === "PAIR_REJECT") {1112            sync.disconnectFromPeer(connectionId);1113            return { ok: false, error: "pair_rejected", data: { reason: response.reason } };1114          }11151116          sync.disconnectFromPeer(connectionId);1117          return { ok: false, error: "unexpected_response" };1118        } catch (err) {1119          if (connectionId) {1120            try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }1121          }1122          return { ok: false, error: err instanceof Error ? err.message : "pair_failed" };1123        }1124      }1125      case "DEBUG_INJECT_INCOMING_PAIRING": {1126        // Manual E2E rehearsal hook. Inserts a synthetic incoming pairing1127        // request into session storage so Phase 7 banner UX can be exercised1128        // without a live native host. No Pro gate — this is a no-op in1129        // production unless explicitly invoked from a Settings debug action.1130        const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {1131          deviceName?: string;1132          publicKeyHex?: string;1133        };1134        if (!deviceName || !publicKeyHex) {1135          return { ok: false, error: "missing_payload" };1136        }1137        const { setIncomingPairingRequest } = await import('./sync-storage');1138        const now = Date.now();1139        await setIncomingPairingRequest({1140          deviceName,1141          publicKeyHex,1142          receivedAt: now,1143          expiresAt: now + 60_000,1144        });1145        return { ok: true };1146      }1147      case "RESET_SYNC_IDENTITY": {1148        const gate = await requirePro("sync");1149        if (!gate.allowed) return { ok: false, error: "pro_required" };1150        const { getPairedDevices, removePairedDevice } = await import('./sync-storage');1151        const devices = await getPairedDevices();1152        for (const device of devices) {1153          await removePairedDevice(device.deviceId);1154        }1155        // Native rotation is best-effort: when the native messaging host is1156        // not yet installed (Phase 9), connectNative throws and we still1157        // honour the local clear so the UI reflects the reset. The native1158        // identity rotates the next time the host starts cleanly anyway.1159        let nativePublicKey: string | null = null;1160        try {1161          const { rotateNativeIdentity, disconnectSyncHost } = await import('./sync');1162          nativePublicKey = await rotateNativeIdentity();1163          disconnectSyncHost();1164        } catch {1165          nativePublicKey = null;1166        }1167        return { ok: true, data: { removedDevices: devices.length, nativePublicKey } };1168      }1169      case "RECORD_MANUAL_SUMMON": {1170        const { domain } = msg.payload as { domain: string };1171        const settings = vault.getSettings();1172        const existing = settings.manualSummonDomains?.[domain];1173        const now = Date.now();1174        await vault.updateSettings({1175          manualSummonDomains: {1176            ...settings.manualSummonDomains,1177            [domain]: {1178              firstSummonedAt: existing?.firstSummonedAt ?? now,1179              summonCount: (existing?.summonCount ?? 0) + 1,1180              lastSummonedAt: now,1181            },1182          },1183        });1184        return { ok: true };1185      }1186      case "DECLINE_CARD_SAVE": {1187        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1188        await vault.declineCardSaveOffer(last4, expMonth, expYear);1189        return { ok: true };1190      }1191      case "IS_CARD_DECLINED": {1192        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };1193        const declined = await vault.isCardDeclined(last4, expMonth, expYear);1194        return { ok: true, data: declined };1195      }1196      case "INITIATE_FIREFOX_PASSKEY_CREATE": {1197        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {1198          rpId?: string;1199          rpName?: string;1200          userName?: string;1201          userDisplayName?: string;1202        };1203        if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };1204        return await initiateFirefoxPasskeyCreate({1205          rpId,1206          rpName: rpName ?? rpId,1207          userName,1208          userDisplayName,1209        });1210      }12111212      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {1213        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1214        if (!payload) return { ok: false, error: "missing payload" };1215        return await completeFirefoxPasskeyCreate(payload);1216      }12171218      case "PASSKEY_OFFER_DECLINE": {1219        const { domain } = (msg.payload ?? {}) as { domain?: string };1220        if (!domain) return { ok: false, error: "domain required" };1221        await recordPasskeyOfferDecline(domain);1222        return { ok: true };1223      }12241225      case "GET_PASSKEY_OFFER_DECLINED": {1226        const { domain } = (msg.payload ?? {}) as { domain?: string };1227        if (!domain) return { ok: false, error: "domain required" };1228        return { ok: true, data: await isPasskeyOfferDeclined(domain) };1229      }12301231      case "PASSKEY_HAS_CREDENTIALS": {1232        const payload = msg.payload as {1233          rpId?: string;1234          allowCredentials?: Array<{ id: string; type?: string }>;1235        } | undefined;1236        if (!payload?.rpId) return { ok: false, error: "rpId required" };1237        return await handlePasskeyHasCredentials({1238          rpId: payload.rpId,1239          allowCredentials: payload.allowCredentials ?? [],1240        });1241      }12421243      case "PASSKEY_CREATE": {1244        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;1245        if (!payload?.rpId || !payload?.userName) {1246          return { ok: false, error: "rpId and userName required" };1247        }1248        return await handleFirefoxPasskeyCreate(payload);1249      }12501251      case "PASSKEY_GET": {1252        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1253        if (!payload?.rpId) return { ok: false, error: "rpId required" };1254        return await handleFirefoxPasskeyGet(payload);1255      }12561257      case "GET_PASSKEY_FOR_DOMAIN": {1258        const { domain } = (msg.payload ?? {}) as { domain?: string };1259        if (!domain) return { ok: false, error: "domain required" };1260        if (!vault.isUnlocked()) return { ok: true, data: null };1261        const match = vault1262          .getEntries()1263          .find((entry) => entry.passkey?.rpId === domain);1264        if (!match || !match.passkey) return { ok: true, data: null };1265        // Slim shape — only the fields State 11 needs. Never expose private1266        // key material, even though this is locked behind isUnlocked.1267        return {1268          ok: true,1269          data: {1270            rpId: match.passkey.rpId,1271            userName: match.passkey.userName,1272            browser: match.passkey.browser,1273          },1274        };1275      }12761277      case "DESTROY_VAULT": {1278        // Irreversible by design. Lock zeroes the derived key + in-memory entries;1279        // storage.local.clear() wipes every Peach key (slots, settings, license,1280        // onboarded flag, history, sync state, declined cards, recall, etc.);1281        // session.clear() wipes pending passkey requests + pro-notice queues.1282        // Caller (Panic.tsx) handles UI navigation back to onboarding.1283        vault.lock();1284        await storageClear();1285        // Belt-and-suspenders: storage.local.clear() already wipes1286        // peach_vault_history, but if storage.ts ever routes history to a1287        // different backend (e.g. Capacitor secure storage on Android) the1288        // explicit clear keeps the invariant that Panic leaves no recoverable1289        // vault state behind.1290        await vault.clearVaultHistory();1291        if (!Capacitor.isNativePlatform() && chrome.storage.session) {1292          await chrome.storage.session.clear();1293        }1294        return { ok: true };1295      }12961297      case "SUBMIT_FEEDBACK": {1298        const { category, message, version, browser } = msg.payload as {1299          category: string;1300          message: string;1301          version: string;1302          browser: string;1303        };1304        if (!message.trim()) return { ok: false, error: "message is empty" };1305        if (!FEEDBACK_ENDPOINT) return { ok: true };1306        if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {1307          return { ok: false, error: "invalid feedback endpoint" };1308        }1309        try {1310          const res = await fetch(FEEDBACK_ENDPOINT, {1311            method: "POST",1312            headers: { "Content-Type": "application/json" },1313            body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),1314          });1315          return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };1316        } catch (err) {1317          return { ok: false, error: err instanceof Error ? err.message : "network error" };1318        }1319      }1320      default:1321        return { ok: false, error: "unknown message type" };1322    }1323  } catch (err) {1324    return {1325      ok: false,1326      error: err instanceof Error ? err.message : "operation failed",1327    };1328  }1329}13301331/** Notify all content scripts that the vault just became available. */1332function broadcastToContentScripts(message: { type: string }): void {1333  if (Capacitor.isNativePlatform() || !chrome.tabs?.query) return;1334  chrome.tabs.query({}, (tabs) => {1335    for (const tab of tabs) {1336      if (tab.id !== undefined) {1337        chrome.tabs.sendMessage(tab.id, message).catch(() => {1338          // Tab may not have a content script — silently ignore1339        });1340      }1341    }1342  });1343}13441345function shouldRefreshAutoLock(type: MessageType): boolean {1346  return (1347    type !== "HAS_VAULT" &&1348    type !== "IS_UNLOCKED" &&1349    type !== "GET_VAULT_STATUS" &&1350    type !== "LOCK" &&1351    type !== "UPDATE_SETTINGS" &&1352    type !== "GET_PENDING_PASSKEY" &&1353    type !== "GET_PASSKEY_PROVIDER_STATE" &&1354    type !== "DECLINE_CARD_SAVE" &&1355    type !== "IS_CARD_DECLINED" &&1356    type !== "SUBMIT_FEEDBACK"1357  );1358}13591360function isAllowedCanaryWebhookUrl(value: string): boolean {1361  try {1362    const url = new URL(value);1363    if (url.protocol !== "https:") return false;13641365    const hostname = url.hostname.toLowerCase();1366    if (1367      hostname === "localhost" ||1368      hostname.endsWith(".localhost") ||1369      isPrivateIpv4(hostname) ||1370      isPrivateIpv6(hostname)1371    ) {1372      return false;1373    }13741375    return true;1376  } catch {1377    return false;1378  }1379}13801381function isPrivateIpv4(hostname: string): boolean {1382  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1383    return true;1384  }13851386  const octets = hostname.split(".").map((part) => Number(part));1387  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1388    return false;1389  }13901391  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1392}13931394function isPrivateIpv6(hostname: string): boolean {1395  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1396  return (1397    normalized === "::1" ||1398    normalized.startsWith("fc") ||1399    normalized.startsWith("fd") ||1400    normalized.startsWith("fe80:")1401  );1402}