messages.ts

43.7 KB · raw

1/**2 * messages.ts — the only background message handler.3 *4 * All `chrome.runtime.onMessage` handling for the service worker lives here.5 * Do not handle messages inline in entry.ts, alarms.ts, or any other6 * background file. Every background-bound MessageType in shared/types.ts7 * must have a case below. Content-script-bound message types (PING,8 * SUMMON_AUTOFILL, VAULT_LOCKED) are documented in shared/types.ts and9 * intentionally have no case here — they are sent *to* content scripts.10 */1112import { copyToClipboard } from "./clipboard";13import { 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 { storageClear, storageGet } from "../shared/storage";40import { findInvalidWords } from "../shared/recovery-words";41import {42  addPairedDevice,43  addTrustedNetwork,44  clearIncomingPairingRequest,45  getIncomingPairingRequest,46  getPairedDevices,47  getTrustedNetworks,48  removePairedDevice,49  removeTrustedNetwork,50  setIncomingPairingRequest,51} from "./sync-storage";52import { getCurrentNetwork, isOnTailscale } from "./sync-network";53import {54  consumeRecentPairing,55  setActivePairingToken,56} from "./pairing-server-state";57import type {58  Message,59  MessageResponse,60  MessageType,61  ProFeature,62  VaultEntry,63} from "../shared/types";64import { isPasskeyOnly } from "../shared/types";65import type { SyncStatus } from "../shared/sync-types";6667/**68 * The single Pro gate. Every gated handler calls this first; the broadcast69 * lets the popup react (navigate to the License section, highlight the70 * relevant feature). Background never touches the popup view directly —71 * the popup owns navigation. See Projects.md "Pro gate rule".72 */73async function requirePro(74  feature: ProFeature,75  trigger: "user" | "background" = "user"76): Promise<{ allowed: boolean }> {77  if (await isProUser()) return { allowed: true };7879  if (trigger === "user") {80    // User deliberately triggered this feature — redirect the popup immediately.81    chrome.runtime82      .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })83      .catch(() => {84        // Popup may be closed — the gate signal is best-effort.85      });86  } else {87    // Background trigger (e.g. passive sync initiation) — no popup is open.88    // Queue a notice so the next popup open shows a soft banner instead of89    // silently failing.90    if (chrome.storage.session) {91      chrome.storage.session92        .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })93        .catch(() => {});94    }95  }9697  return { allowed: false };98}99100// ---------------------------------------------------------------------------101// Local device name. Peers see this string in PAIR_REQUEST / PAIR_ACCEPT and102// next to the device in their paired-devices list. Default to a platform-aware103// fallback when the user hasn't set one explicitly.104const DEVICE_NAME_KEY = "peach_device_name";105106async function getLocalDeviceName(): Promise<string> {107  try {108    const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);109    const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];110    if (typeof value === "string" && value.trim()) return value.trim();111  } catch {112    // fall through to default113  }114  return "Peach";115}116117// ---------------------------------------------------------------------------118// 6-digit code pairing.119120const PAIRING_TOKEN_TTL_MS = 5 * 60 * 1000;121122123function generateSixDigitToken(): string {124  // crypto.getRandomValues, not Math.random — the token is the only secret125  // protecting the pairing window from a same-LAN attacker. 6 decimal digits126  // is 1 in 1,000,000; combined with a 5-minute TTL and pairing-mode gating127  // on the responder this is the right tradeoff between UX and security.128  const buf = new Uint32Array(1);129  crypto.getRandomValues(buf);130  const value = buf[0] % 1_000_000;131  return value.toString().padStart(6, "0");132}133134// ---------------------------------------------------------------------------135// Peer discovery glue (Phase 7 popup pairing flow).136137const PRIVATE_IPV4_RANGES = [138  { prefix: "10." },139  { prefix: "127." },140  { prefix: "169.254." },141  { prefix: "192.168." },142];143144// Empty disables network submission while the UI continues to function as a145// no-op (the handler returns ok:true without calling fetch). When set, the146// URL is validated by `isAllowedCanaryWebhookUrl` before any request, so the147// same private-IP/HTTPS rules that gate canary webhooks apply here.148const FEEDBACK_ENDPOINT = "";149150export function setupMessageHandler(): void {151  chrome.runtime.onMessage.addListener(152    (msg: unknown, sender, sendResponse) => {153      if (!isMessage(msg)) {154        sendResponse({ ok: false, error: "invalid message" });155        return true;156      }157      handleMessage(msg, sender)158        .then(sendResponse)159        .catch((err) =>160          sendResponse({161            ok: false,162            error: err instanceof Error ? err.message : String(err),163          })164        );165      return true;166    }167  );168}169170function isMessage(m: unknown): m is Message {171  return typeof m === "object" && m !== null && "type" in m;172}173174export async function handleMessage(175  msg: Message,176  sender: Partial<chrome.runtime.MessageSender> = {}177): Promise<MessageResponse> {178  try {179    // Validate sender — only allow messages from our extension pages/scripts.180    // `sender.id` is undefined for messages originating from extension UI181    // (popup/options), which is legitimate; reject only when it is defined182    // and does not match our extension id.183    if (184      sender.id !== undefined &&185      sender.id !== chrome.runtime.id186    ) {187      return { ok: false, error: "invalid sender" };188    }189190    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {191      touchAutoLock();192    }193194    switch (msg.type) {195      case "CREATE_VAULT": {196        const { password } = msg.payload as { password: string };197        await vault.createVault(password);198        await setupAutoLock();199        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });200        return { ok: true };201      }202      case "UNLOCK": {203        const { password } = msg.payload as { password: string };204        const ok = await vault.unlock(password);205        if (ok) {206          await setupAutoLock();207          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });208        }209        return { ok };210      }211      case "VALIDATE_RECOVERY_WORDS": {212        const { words } = msg.payload as { words: string[] };213        const invalid = findInvalidWords(words);214        return { ok: true, data: { valid: invalid.length === 0, invalid } };215      }216      case "GET_RECOVERY_WORDS": {217        // Must be unlocked and have recovery set up (vault must be v2.12+).218        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };219        try {220          const words = await vault.getRecoveryWords();221          return { ok: true, data: words };222        } catch (e) {223          // pre-v2.12 vault that hasn't migrated224          return { ok: false, error: "recovery_not_setup" };225        }226      }227      case "RECOVER_WITH_WORDS": {228        // User has lost their password but has their 12 recovery words.229        // We: (1) validate the words, (2) re-encrypt vault with new password.230        const { words, newPassword } = msg.payload as {231          words: string[];232          newPassword: string;233        };234        const stored = await vault.loadSlotA();235        if (!stored) return { ok: false, error: "no vault" };236        if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {237          return { ok: false, error: "recovery_not_setup" };238        }239        // Unwrap the root key from the recovery escrow.240        try {241          await vault.unwrapRootKeyForRecovery(242            stored.recovery_wrapped_root_key,243            stored.recovery_nonce ?? "",244            stored.recovery_tag ?? "",245            stored.recovery_salt,246            words247          );248        } catch {249          return { ok: false, error: "invalid_recovery_words" };250        }251        // Re-key: derive fresh salt + key from new password.252        const newSalt = vault.generateSalt();253        const newKey = await vault.deriveKey(newPassword, newSalt);254        // Re-wrap root key under the new password's derived key so the same255        // recovery words continue to work after the password change.256        await vault.rewrapRootKeyForPassword(newKey, newSalt);257        // Re-encrypt vault entries with the new key.258        await vault.changePasswordWithNewKey(newSalt, newKey);259        await setupAutoLock();260        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });261        return { ok: true };262      }263      case "LOCK":264        vault.lock();265        return { ok: true };266      case "IS_UNLOCKED":267        return { ok: true, data: vault.isUnlocked() };268      case "GET_VAULT_STATUS":269        return { ok: true, unlocked: vault.isUnlocked() };270      case "HAS_VAULT":271        return { ok: true, data: await vault.hasVault() };272      case "GET_ENTRIES":273        return { ok: true, data: vault.getEntries() };274      case "ADD_ENTRY": {275        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };276        const entry = await vault.addEntry(msg.payload as any);277        return { ok: true, data: entry };278      }279      case "BATCH_ADD_ENTRIES": {280        const { entries } = msg.payload as { entries: any[] };281        const added = await vault.batchAddEntries(entries);282        return { ok: true, data: added };283      }284      case "RESTORE_ENTRIES": {285        const { entries, mode, settings } = msg.payload as {286          entries: any[];287          mode: "replace" | "merge";288          settings?: vault.VaultSettings;289        };290        const summary = await vault.restoreEntries(entries, mode);291        if (settings && typeof settings === "object") {292          await vault.updateSettings(settings);293        }294        return { ok: true, data: summary };295      }296      case "CLEAR_VAULT_ENTRIES": {297        await vault.clearVaultEntries("REPLACE_ALL");298        return { ok: true };299      }300      case "GET_VAULT_HISTORY": {301        const snapshots = await vault.getVaultHistory();302        return { ok: true, data: snapshots };303      }304      case "RESTORE_VAULT_SNAPSHOT": {305        const { snapshotId } = msg.payload as { snapshotId: string };306        const result = await vault.restoreVaultSnapshot(snapshotId);307        if (!result.ok) return { ok: false, error: result.error };308        broadcastToContentScripts({ type: "VAULT_LOCKED" });309        return { ok: true };310      }311      case "COUNT_DUPLICATES": {312        return { ok: true, data: vault.countDuplicates() };313      }314      case "DEDUPLICATE_ENTRIES": {315        const removed = await vault.deduplicateEntries();316        return { ok: true, data: removed };317      }318      case "UPDATE_ENTRY": {319        const { id, patch } = msg.payload as any;320        await vault.updateEntry(id, patch);321        return { ok: true };322      }323      case "ADD_ADDITIONAL_URL": {324        const { id, url } = msg.payload as { id: string; url: string };325        const trimmed = typeof url === "string" ? url.trim() : "";326        if (!trimmed) return { ok: false, error: "url required" };327        const existing = vault.getEntryById(id);328        if (!existing) return { ok: false, error: "entry not found" };329        if (existing.type !== "login") return { ok: false, error: "login entries only" };330        const merged = [...(existing.additionalUrls ?? []), trimmed];331        await vault.updateEntry(id, { additionalUrls: merged });332        return { ok: true };333      }334      case "DELETE_ENTRY": {335        const { id } = msg.payload as { id: string };336        await vault.deleteEntry(id);337        return { ok: true };338      }339      case "GET_DELETED_ENTRIES":340        return { ok: true, data: vault.getDeletedEntries() };341      case "RESTORE_ENTRY": {342        const { id } = msg.payload as { id: string };343        await vault.restoreEntry(id);344        return { ok: true };345      }346      case "PURGE_ENTRY": {347        const { id } = msg.payload as { id: string };348        await vault.purgeEntry(id);349        return { ok: true };350      }351      case "PURGE_EXPIRED_TRASH": {352        const { retentionMs } = msg.payload as { retentionMs: number };353        const purged = await vault.purgeExpiredTrash(retentionMs);354        return { ok: true, data: purged };355      }356      case "AUTOFILL_QUERY": {357        if (!vault.isUnlocked()) {358          return { ok: false, error: "VAULT_LOCKED" };359        }360        const { domain, includeAll, kind } = msg.payload as {361          domain: string;362          includeAll?: boolean;363          kind?: "login" | "card";364        };365        // kind="card": return saved cards as compact summaries for the366        // inline card silver platter. Cards are not domain-scoped — every367        // saved card is a candidate on every checkout page — so domain is368        // ignored when kind === "card".369        if (kind === "card") {370          const cards = vault.getEntries().filter((e) => e.type === "card");371          return {372            ok: true,373            data: cards.map((e) => ({374              id: e.id,375              title: e.cardNickname || e.title,376              cardholderName: e.cardholderName,377              last4: e.last4,378              expMonth: e.expMonth,379              expYear: e.expYear,380              network: e.network,381            })),382          };383        }384        // The inline selector fills username+password into a form. Passkey-385        // only entries have no password to fill; sign-in for those goes386        // through the WebAuthn path instead. Filter them out everywhere so387        // the user doesn't see four "GitHub" rows that mostly do nothing.388        // Notes and cards never belonged here either.389        const matches = (390          includeAll391            ? vault392                .getEntries()393                .filter((entry) => entry.type !== "note" && entry.type !== "card")394            : vault.queryDomain(domain)395        ).filter((entry) => !isPasskeyOnly(entry));396        return {397          ok: true,398          data: matches.map((e) =>399            includeAll400              ? {401                  id: e.id,402                  title: e.title,403                  hasPasskey: e.passkey !== undefined,404                }405              : {406                  id: e.id,407                  title: e.title,408                  username: e.username,409                  email: e.email,410                  url: e.url,411                  hasTotp: Boolean(e.totp),412                  hasPasskey: e.passkey !== undefined,413                }414          ),415        };416      }417      case "GET_ENTRY_FOR_FILL": {418        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };419        const { id } = msg.payload as { id: string };420        const entry = vault.getEntryById(id);421        if (!entry) return { ok: false, error: "entry not found" };422        // Passkey-only entries (passkey credential, no password) cannot be423        // filled into a form — sign-in goes through the WebAuthn path. A424        // login that ALSO has a passkey is still fillable for its password.425        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };426        // Strip the passkey credential from the response — content scripts427        // never need the private key, and there's no reason to expose the428        // public credential metadata over the boundary either.429        const { passkey: _strippedPasskey, ...safeEntry } = entry;430        return { ok: true, data: safeEntry };431      }432      case "COPY": {433        const { text, label, alreadyWritten } = msg.payload as {434          text: string;435          label: string;436          alreadyWritten?: boolean;437        };438        await copyToClipboard(text, label, Boolean(alreadyWritten));439        return { ok: true };440      }441      case "CHECK_BREACH": {442        const { password } = msg.payload as { password: string };443        const count = await vault.checkBreach(password);444        return { ok: true, data: count };445      }446      case "CHECK_ALL_BREACHES": {447        const allEntries = vault.getEntries();448        const results: Record<string, number> = {};449        for (const entry of allEntries) {450          if (entry.type === "note") continue;451          if (!entry.password) continue;452          results[entry.id] = await vault.checkBreach(entry.password);453          // Small delay — be a good citizen to the HIBP API454          await new Promise((r) => setTimeout(r, 200));455        }456        // Persist any non-zero counts back to entries457        for (const [id, count] of Object.entries(results)) {458          const entry = vault.getEntryById(id);459          if (entry && (entry.breach ?? 0) !== count) {460            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });461          }462        }463        return { ok: true, data: results };464      }465      case "GET_SETTINGS":466        return { ok: true, data: await vault.ensureSettingsLoaded() };467      case "UPDATE_SETTINGS": {468        const patch = msg.payload as Record<string, unknown>;469        await vault.updateSettings(patch as any);470        if (typeof patch.autoLockMinutes === "number") {471          await setupAutoLock();472          if (vault.isUnlocked()) touchAutoLock();473        }474        return { ok: true };475      }476      case "GET_PENDING_PASSKEY":477        return { ok: true, data: await getPendingPasskeyRequest() };478      case "APPROVE_PASSKEY": {479        const { requestId } = msg.payload as { requestId: number };480        await resolvePendingPasskeyRequest(requestId, true);481        return { ok: true };482      }483      case "DENY_PASSKEY": {484        const { requestId } = msg.payload as { requestId: number };485        await resolvePendingPasskeyRequest(requestId, false);486        return { ok: true };487      }488      case "GET_PASSKEYS":489        return {490          ok: true,491          data: vault.getEntries().filter((entry) => entry.passkey !== undefined),492        };493      case "DELETE_PASSKEY": {494        const { id } = msg.payload as { id: string };495        await vault.deleteEntry(id);496        return { ok: true };497      }498      case "GET_PASSKEY_PROVIDER_STATE":499        return { ok: true, data: await getPasskeyProviderState() };500      case "SET_PASSKEY_PROVIDER_ENABLED": {501        const { enabled } = msg.payload as { enabled: boolean };502        // Disabling is always free; gating fires only when a free user is503        // trying to turn passkey interception on.504        if (enabled) {505          const gate = await requirePro("passkeys");506          if (!gate.allowed) return { ok: false, error: "pro_required" };507        }508        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };509      }510      case "FIRE_CANARY_PING": {511        const { webhookUrl, entryTitle, trigger } = msg.payload as {512          webhookUrl: string;513          entryTitle: string;514          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";515        };516        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {517          return { ok: false, error: "invalid webhook url" };518        }519        const body = JSON.stringify({520          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",521          entry: entryTitle,522          trigger,523          timestamp: new Date().toISOString(),524          source: "peach-extension",525        });526        if (trigger === "test") {527          // Permitted outbound fetch: user-configured canary alert endpoint528          // (validated by isAllowedCanaryWebhookUrl above). This is the529          // only non-HIBP outbound call in the extension — see530          // Projects.md §3.7. Test pings are awaited so the UI can show531          // success/failure.532          try {533            const res = await fetch(webhookUrl, {534              method: "POST",535              headers: { "Content-Type": "application/json" },536              body,537            });538            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };539          } catch (err) {540            return { ok: false, error: err instanceof Error ? err.message : "network error" };541          }542        }543        // Non-test triggers are fire-and-forget544        fetch(webhookUrl, {545          method: "POST",546          headers: { "Content-Type": "application/json" },547          body,548        }).catch(() => {});549        return { ok: true };550      }551      case "ACTIVATE_LICENSE": {552        const { key } = msg.payload as { key: string };553        const licenseInfo = await activateLicense(key);554        return { ok: true, data: licenseInfo };555      }556      case "GET_LICENSE": {557        const licenseInfo = await getLicenseInfo();558        return { ok: true, data: licenseInfo };559      }560      case "GET_LICENSE_KEY": {561        const key = await getLicenseKey();562        return { ok: true, data: key };563      }564      case "DEACTIVATE_LICENSE": {565        await deactivateLicense();566        return { ok: true };567      }568      case "SHOULD_PLAY_CELEBRATION": {569        const should = await shouldPlayCelebration();570        return { ok: true, data: should };571      }572      case "MARK_CELEBRATION_PLAYED": {573        await markCelebrationPlayed();574        return { ok: true };575      }576      case "OPEN_PRO_GATE": {577        // Content script requests: open popup and navigate to License section.578        const { feature } = (msg.payload ?? {}) as { feature?: string };579        if (chrome.storage.session) {580          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });581        }582        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }583        return { ok: true };584      }585      case "GET_IDENTITIES": {586        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };587        return { ok: true, data: vault.getIdentities() };588      }589      case "GET_ADVANCED_HEALTH_REPORT": {590        const gate = await requirePro("advanced_health");591        if (!gate.allowed) return { ok: false, error: "pro_required" };592        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };593        const report = await buildAdvancedHealthReport(vault.getEntries());594        return { ok: true, data: report };595      }596      case "INITIATE_PASSWORD_CHANGE": {597        const gate = await requirePro("smart_password_change");598        if (!gate.allowed) return { ok: false, error: "pro_required" };599        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };600        const { id } = msg.payload as { id: string };601        const entry = vault.getEntryById(id);602        if (!entry) return { ok: false, error: "entry not found" };603        const url = resolveResetUrl(entry);604        if (!url) return { ok: false, error: "no reset url" };605        try {606          await chrome.tabs.create({ url });607          return { ok: true, data: { url } };608        } catch (err) {609          return {610            ok: false,611            error: err instanceof Error ? err.message : "failed to open tab",612          };613        }614      }615      case "IS_DURESS_ENABLED": {616        const enabled = await vault.isDuressEnabled();617        return { ok: true, data: enabled };618      }619      case "SETUP_DURESS": {620        const { duressPassword } = msg.payload as { duressPassword: string };621        await vault.setupDuress(duressPassword);622        return { ok: true };623      }624      case "DISABLE_DURESS": {625        await vault.disableDuress();626        return { ok: true };627      }628      case "VERIFY_MAIN_PASSWORD": {629        const { password } = msg.payload as { password: string };630        const ok = await vault.verifyMainPassword(password);631        return { ok: true, data: ok };632      }633      case "CHANGE_PASSWORD": {634        const { currentPassword, newPassword } = msg.payload as {635          currentPassword: string;636          newPassword: string;637        };638        const changed = await vault.changePassword(currentPassword, newPassword);639        return changed640          ? { ok: true }641          : { ok: false, error: "incorrect current password" };642      }643      case "GET_SITE_PASSWORD_POLICY": {644        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });645        if (!tab?.id) return { ok: true, data: null };646        try {647          const response = await chrome.tabs.sendMessage(tab.id, {648            type: "GET_SITE_PASSWORD_POLICY",649          });650          return (response as MessageResponse) ?? { ok: true, data: null };651        } catch {652          return { ok: true, data: null };653        }654      }655      case "GET_SYNC_STATE": {656        const pairedDevices = await getPairedDevices();657        const trustedNetworks = await getTrustedNetworks();658        const tailscale = await isOnTailscale();659        const pro = await isProUser();660661        let status: SyncStatus = "offline";662        if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {663          if (pairedDevices.length === 0) {664            status = "no_peers";665          } else {666            status = "synced";667          }668        } else {669          status = "not_trusted";670        }671672        const lastSyncRaw = await storageGet("peach_last_sync_at");673        const lastSyncParsed = lastSyncRaw ? Number.parseInt(lastSyncRaw, 10) : NaN;674        const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : undefined;675676        return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };677      }678      case "TRIGGER_SYNC": {679        const gate = await requirePro("sync");680        if (!gate.allowed) return { ok: false, error: "pro_required" };681        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };682        return { ok: false, error: "no_native_host" };683      }684      case "START_PAIRING": {685        const gate = await requirePro("sync");686        if (!gate.allowed) return { ok: false, error: "pro_required" };687        return { ok: true };688      }689      case "CANCEL_PAIRING": {690        return { ok: true };691      }692      case "ACCEPT_PAIRING": {693        // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.694        return { ok: true };695      }696      case "REMOVE_PAIRED_DEVICE": {697        const { deviceId } = msg.payload as { deviceId: string };698        await removePairedDevice(deviceId);699        return { ok: true };700      }701      case "REMOVE_TRUSTED_NETWORK": {702        const { ssid, bssid, isTailscale } = msg.payload as any;703        const networks = await getTrustedNetworks();704        const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 705          (isTailscale ? n.isTailscale : n.ssid === ssid) && 706          (!bssid || n.bssid === bssid)707        );708        if (network) {709          await removeTrustedNetwork(network.addedAt);710        }711        return { ok: true };712      }713      case "MARK_NETWORK_TRUSTED": {714        const network = await getCurrentNetwork();715        await addTrustedNetwork({716          ssid: network.ssid,717          bssid: network.bssid,718          isTailscale: network.isTailscale,719          addedAt: Date.now(),720        });721        return { ok: true };722      }723      case "GET_INCOMING_PAIRING_REQUEST": {724        const request = await getIncomingPairingRequest();725        return { ok: true, data: request };726      }727      case "RESPOND_INCOMING_PAIRING": {728        const { accept } = msg.payload as { accept: boolean };729        const request = await getIncomingPairingRequest();730        if (!request) return { ok: false, error: "no_pending_request" };731        if (Date.now() > request.expiresAt) {732          await clearIncomingPairingRequest();733          return { ok: false, error: "expired" };734        }735        await clearIncomingPairingRequest();736        if (accept) {737          await addPairedDevice({738            deviceId: request.publicKeyHex,739            displayName: request.deviceName,740            x25519PublicKey: request.publicKeyHex,741            pairedAt: Date.now(),742            lastSeenAt: Date.now(),743          });744        }745        return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };746      }747      case "START_PEER_DISCOVERY": {748        const gate = await requirePro("sync");749        if (!gate.allowed) return { ok: false, error: "pro_required" };750        return { ok: false, error: "no_native_host" };751      }752      case "STOP_PEER_DISCOVERY": {753        return { ok: true };754      }755      case "START_PAIRING_SERVER": {756        // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.757        const token = generateSixDigitToken();758        const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;759        await setActivePairingToken({ token, expiresAt });760        return { ok: true, data: { token, expiresAt } };761      }762      case "STOP_PAIRING_SERVER": {763        try {764          await setActivePairingToken(null);765        } catch { /* best-effort */ }766        return { ok: true };767      }768      case "CONNECT_WITH_CODE": {769        const gate = await requirePro("sync");770        if (!gate.allowed) return { ok: false, error: "pro_required" };771        return { ok: false, error: "no_native_host" };772      }773      case "GET_RECENT_PAIRING": {774        const recent = await consumeRecentPairing();775        return { ok: true, data: recent };776      }777      case "INITIATE_PAIR_WITH_PEER": {778        const gate = await requirePro("sync");779        if (!gate.allowed) return { ok: false, error: "pro_required" };780        return { ok: false, error: "no_native_host" };781      }782      case "DEBUG_INJECT_INCOMING_PAIRING": {783        // Manual E2E rehearsal hook. Inserts a synthetic incoming pairing784        // request into session storage so Phase 7 banner UX can be exercised785        // without a live native host. No Pro gate — this is a no-op in786        // production unless explicitly invoked from a Settings debug action.787        const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {788          deviceName?: string;789          publicKeyHex?: string;790        };791        if (!deviceName || !publicKeyHex) {792          return { ok: false, error: "missing_payload" };793        }794        const now = Date.now();795        await setIncomingPairingRequest({796          deviceName,797          publicKeyHex,798          receivedAt: now,799          expiresAt: now + 60_000,800        });801        return { ok: true };802      }803      case "RESET_SYNC_IDENTITY": {804        const gate = await requirePro("sync");805        if (!gate.allowed) return { ok: false, error: "pro_required" };806        const devices = await getPairedDevices();807        for (const device of devices) {808          await removePairedDevice(device.deviceId);809        }810        return { ok: true, data: { removedDevices: devices.length, nativePublicKey: null } };811      }812      case "RECORD_MANUAL_SUMMON": {813        const { domain } = msg.payload as { domain: string };814        const settings = vault.getSettings();815        const existing = settings.manualSummonDomains?.[domain];816        const now = Date.now();817        await vault.updateSettings({818          manualSummonDomains: {819            ...settings.manualSummonDomains,820            [domain]: {821              firstSummonedAt: existing?.firstSummonedAt ?? now,822              summonCount: (existing?.summonCount ?? 0) + 1,823              lastSummonedAt: now,824            },825          },826        });827        return { ok: true };828      }829      case "DECLINE_CARD_SAVE": {830        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };831        await vault.declineCardSaveOffer(last4, expMonth, expYear);832        return { ok: true };833      }834      case "IS_CARD_DECLINED": {835        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };836        const declined = await vault.isCardDeclined(last4, expMonth, expYear);837        return { ok: true, data: declined };838      }839      case "INITIATE_FIREFOX_PASSKEY_CREATE": {840        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {841          rpId?: string;842          rpName?: string;843          userName?: string;844          userDisplayName?: string;845        };846        if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };847        return await initiateFirefoxPasskeyCreate({848          rpId,849          rpName: rpName ?? rpId,850          userName,851          userDisplayName,852        });853      }854855      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {856        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;857        if (!payload) return { ok: false, error: "missing payload" };858        return await completeFirefoxPasskeyCreate(payload);859      }860861      case "PASSKEY_OFFER_DECLINE": {862        const { domain } = (msg.payload ?? {}) as { domain?: string };863        if (!domain) return { ok: false, error: "domain required" };864        await recordPasskeyOfferDecline(domain);865        return { ok: true };866      }867868      case "GET_PASSKEY_OFFER_DECLINED": {869        const { domain } = (msg.payload ?? {}) as { domain?: string };870        if (!domain) return { ok: false, error: "domain required" };871        return { ok: true, data: await isPasskeyOfferDeclined(domain) };872      }873874      case "PASSKEY_HAS_CREDENTIALS": {875        const payload = msg.payload as {876          rpId?: string;877          allowCredentials?: Array<{ id: string; type?: string }>;878        } | undefined;879        if (!payload?.rpId) return { ok: false, error: "rpId required" };880        return await handlePasskeyHasCredentials({881          rpId: payload.rpId,882          allowCredentials: payload.allowCredentials ?? [],883        });884      }885886      case "PASSKEY_CREATE": {887        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;888        if (!payload?.rpId || !payload?.userName) {889          return { ok: false, error: "rpId and userName required" };890        }891        return await handleFirefoxPasskeyCreate(payload);892      }893894      case "PASSKEY_GET": {895        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;896        if (!payload?.rpId) return { ok: false, error: "rpId required" };897        return await handleFirefoxPasskeyGet(payload);898      }899900      case "GET_PASSKEY_FOR_DOMAIN": {901        const { domain } = (msg.payload ?? {}) as { domain?: string };902        if (!domain) return { ok: false, error: "domain required" };903        if (!vault.isUnlocked()) return { ok: true, data: null };904        const match = vault905          .getEntries()906          .find((entry) => entry.passkey?.rpId === domain);907        if (!match || !match.passkey) return { ok: true, data: null };908        // Slim shape — only the fields State 11 needs. Never expose private909        // key material, even though this is locked behind isUnlocked.910        return {911          ok: true,912          data: {913            rpId: match.passkey.rpId,914            userName: match.passkey.userName,915            browser: match.passkey.browser,916          },917        };918      }919920      case "DESTROY_VAULT": {921        // Irreversible by design. Lock zeroes the derived key + in-memory entries;922        // storage.local.clear() wipes every Peach key (slots, settings, license,923        // onboarded flag, history, sync state, declined cards, recall, etc.);924        // session.clear() wipes pending passkey requests + pro-notice queues.925        // Caller (Panic.tsx) handles UI navigation back to onboarding.926        vault.lock();927        await storageClear();928        await vault.clearVaultHistory();929        if (chrome.storage.session) {930          await chrome.storage.session.clear();931        }932        return { ok: true };933      }934935      case "SUBMIT_FEEDBACK": {936        const { category, message, version, browser } = msg.payload as {937          category: string;938          message: string;939          version: string;940          browser: string;941        };942        if (!message.trim()) return { ok: false, error: "message is empty" };943        if (!FEEDBACK_ENDPOINT) return { ok: true };944        if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {945          return { ok: false, error: "invalid feedback endpoint" };946        }947        try {948          const res = await fetch(FEEDBACK_ENDPOINT, {949            method: "POST",950            headers: { "Content-Type": "application/json" },951            body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),952          });953          return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };954        } catch (err) {955          return { ok: false, error: err instanceof Error ? err.message : "network error" };956        }957      }958      case "TRIGGER_SYNC": {959        if (isNativePlatform()) {960          bridge?.ensureServerRunning?.();961          return { ok: true, data: { syncedCount: 0, mode: "server" as const } };962        }963        return await desktopTriggerSync({ getLastKnownIp: () => null });964      }965      // Android-only: biometric unlock with pre-derived key.966      case "UNLOCK_WITH_KEY": {967        const { masterKeyHex } = msg.payload as { masterKeyHex: string };968        if (!isNativePlatform()) {969          return { ok: false, error: "desktop_only_uses_password" };970        }971        try {972          const result = await vault.unlockWithKey(masterKeyHex);973          if (result.ok) await publishInMemoryVault(result.entries);974          return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };975        } catch {976          return { ok: false, error: "invalid_key" };977        }978      }979      // Android: returns current in-memory derived key as hex. Used by biometric enable flow.980      case "GET_DERIVED_KEY": {981        if (!isNativePlatform()) {982          return { ok: false, error: "android_only" };983        }984        if (!vault.isUnlocked()) {985          return { ok: false, error: "vault_locked" };986        }987        return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };988      }989      // Android: scanned a QR containing a desktop pairing token.990      case "SCAN_PAIRING_QR": {991        if (!isNativePlatform()) {992          return { ok: false, error: "android_only" };993        }994        const { token } = msg.payload as { token: string };995        bridge?.startPairingSession?.({ token });996        return { ok: true };997      }998      // Android: returns the phone's WiFi IP address.999      case "GET_LOCAL_IP": {1000        if (!isNativePlatform()) {1001          return { ok: false, error: "android_only" };1002        }1003        const { ip } = (await bridge?.getLocalIp?.()) ?? { ip: "" };1004        return { ok: true, data: { ip } };1005      }1006      // Desktop: generates a pairing token and starts the subnet scan for QR pairing.1007      case "START_PAIRING_SERVER": {1008        if (isNativePlatform()) {1009          return { ok: false, error: "desktop_only" };1010        }1011        const arr = new Uint32Array(1);1012        crypto.getRandomValues(arr);1013        const token = String(100000 + (arr[0] % 900000));1014        activeDesktopPairingToken = token;1015        activeDesktopPairingExpiry = Date.now() + 5 * 60 * 1000;1016        startSubnetScan(token, { getLocalAddress: () => null });1017        return { ok: true, data: { token, expiresIn: 300 } };1018      }1019      case "GET_IDENTITY_SUGGESTIONS": {1020        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1021        const entries = vault.getEntries();1022        const suggestions = buildIdentitySuggestions(entries);1023        return { ok: true, data: { suggestions } };1024      }1025      case "POPULATE_INLINE_VAULT": {1026        if (!isNativePlatform()) return { ok: false };1027        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1028        const entries = vault.getEntries();1029        await publishInMemoryVault(entries);1030        return { ok: true };1031      }1032      default:1033        return { ok: false, error: "unknown message type" };1034    }1035  } catch (err) {1036    return {1037      ok: false,1038      error: err instanceof Error ? err.message : "operation failed",1039    };1040  }1041}10421043// --- Mobile platform helpers ---10441045// Desktop: triggers sync for all paired devices.1046async function desktopTriggerSync(deps: {1047  getLastKnownIp: () => string | null;1048}): Promise<{ ok: true; syncedCount: number } | { ok: false; error: string }> {1049  const ip = deps.getLastKnownIp();1050  if (!ip) return { ok: false, error: "no_paired_devices" };1051  return { ok: true, syncedCount: 0 };1052}10531054// Desktop: starts background subnet scan for QR pairing.1055function startSubnetScan(1056  token: string,1057  _deps: {1058    getLocalAddress: () => string | null;1059  }1060): void {1061  // TODO: implement subnet scan and WebSocket pairing server1062}10631064// Android: populates the in-memory vault used by PeachAutofillService.1065async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {1066  // TODO: wire up to autofill in-memory store (exists in Android, not here)1067}10681069// Returns all emails and usernames from the vault, deduped and sorted by frequency.1070function buildIdentitySuggestions(1071  entries: VaultEntry[],1072): Array<{ email: string; username: string; count: number }> {1073  const map = new Map<string, number>();1074  for (const entry of entries) {1075    if (entry.username) map.set(entry.username, (map.get(entry.username) ?? 0) + 1);1076  }1077  return Array.from(map.entries())1078    .sort((a, b) => b[1] - a[1])1079    .map(([username, count]) => ({ email: username, username, count }));1080}10811082// Mobile bridge — runtime check to avoid importing Capacitor at the top level.1083// eslint-disable-next-line @typescript-eslint/no-explicit-any1084const bridge = (typeof (globalThis as any).Capacitor !== "undefined")1085  ? (globalThis as any).Capacitor.Plugins.SyncPlugin1086  : null;10871088// eslint-disable-next-line @typescript-eslint/no-explicit-any1089const isNativePlatform = (): boolean => typeof (globalThis as any).Capacitor !== "undefined" && (globalThis as any).isNativePlatform();10901091let activeDesktopPairingToken: string | null = null;1092let activeDesktopPairingExpiry = 0;10931094/** Notify all content scripts that the vault just became available. */1095function broadcastToContentScripts(message: { type: string }): void {1096  if (!chrome.tabs?.query) return;1097  chrome.tabs.query({}, (tabs) => {1098    for (const tab of tabs) {1099      if (tab.id !== undefined) {1100        chrome.tabs.sendMessage(tab.id, message).catch(() => {1101          // Tab may not have a content script — silently ignore1102        });1103      }1104    }1105  });1106}11071108function shouldRefreshAutoLock(type: MessageType): boolean {1109  return (1110    type !== "HAS_VAULT" &&1111    type !== "IS_UNLOCKED" &&1112    type !== "GET_VAULT_STATUS" &&1113    type !== "LOCK" &&1114    type !== "UPDATE_SETTINGS" &&1115    type !== "GET_PENDING_PASSKEY" &&1116    type !== "GET_PASSKEY_PROVIDER_STATE" &&1117    type !== "DECLINE_CARD_SAVE" &&1118    type !== "IS_CARD_DECLINED" &&1119    type !== "SUBMIT_FEEDBACK"1120  );1121}11221123function isAllowedCanaryWebhookUrl(value: string): boolean {1124  try {1125    const url = new URL(value);1126    if (url.protocol !== "https:") return false;11271128    const hostname = url.hostname.toLowerCase();1129    if (1130      hostname === "localhost" ||1131      hostname.endsWith(".localhost") ||1132      isPrivateIpv4(hostname) ||1133      isPrivateIpv6(hostname)1134    ) {1135      return false;1136    }11371138    return true;1139  } catch {1140    return false;1141  }1142}11431144function isPrivateIpv4(hostname: string): boolean {1145  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1146    return true;1147  }11481149  const octets = hostname.split(".").map((part) => Number(part));1150  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1151    return false;1152  }11531154  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1155}11561157function isPrivateIpv6(hostname: string): boolean {1158  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1159  return (1160    normalized === "::1" ||1161    normalized.startsWith("fc") ||1162    normalized.startsWith("fd") ||1163    normalized.startsWith("fe80:")1164  );1165}