messages.ts

24.5 KB · raw

1/**2 * messages.ts — the only background message handler.3 *4 * All `chrome.runtime.onMessage` handling for the service worker lives here.5 * Do not handle messages inline in entry.ts, alarms.ts, or any other6 * background file. Every background-bound MessageType in shared/types.ts7 * must have a case below. Content-script-bound message types (PING,8 * SUMMON_AUTOFILL, VAULT_LOCKED) are documented in shared/types.ts and9 * intentionally have no case here — they are sent *to* content scripts.10 */1112import { copyToClipboard } from "./clipboard";13import { setupAutoLock, touchAutoLock } from "./alarms";14import * as vault from "./vault";15import {16  activateLicense,17  deactivateLicense,18  getLicenseInfo,19  isProUser,20} from "./license";21import {22  getPasskeyProviderState,23  getPendingPasskeyRequest,24  resolvePendingPasskeyRequest,25  setPasskeyProviderEnabled,26} from "./passkey";27import { buildAdvancedHealthReport, resolveResetUrl } from "./health";28import type {29  Message,30  MessageResponse,31  MessageType,32  ProFeature,33} from "../shared/types";34import type { SyncStatus } from "../shared/sync-types";3536/**37 * The single Pro gate. Every gated handler calls this first; the broadcast38 * lets the popup react (navigate to the License section, highlight the39 * relevant feature). Background never touches the popup view directly —40 * the popup owns navigation. See Projects.md "Pro gate rule".41 */42async function requirePro(feature: ProFeature): Promise<{ allowed: boolean }> {43  if (await isProUser()) return { allowed: true };44  chrome.runtime45    .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })46    .catch(() => {47      // Popup may be closed — the gate signal is best-effort.48    });49  return { allowed: false };50}5152const PRIVATE_IPV4_RANGES = [53  { prefix: "10." },54  { prefix: "127." },55  { prefix: "169.254." },56  { prefix: "192.168." },57];5859// Empty disables network submission while the UI continues to function as a60// no-op (the handler returns ok:true without calling fetch). When set, the61// URL is validated by `isAllowedCanaryWebhookUrl` before any request, so the62// same private-IP/HTTPS rules that gate canary webhooks apply here.63const FEEDBACK_ENDPOINT = "";6465export function setupMessageHandler(): void {66  chrome.runtime.onMessage.addListener(67    (msg: unknown, sender, sendResponse) => {68      if (!isMessage(msg)) {69        sendResponse({ ok: false, error: "invalid message" });70        return true;71      }72      handleMessage(msg, sender)73        .then(sendResponse)74        .catch((err) =>75          sendResponse({76            ok: false,77            error: err instanceof Error ? err.message : String(err),78          })79        );80      return true;81    }82  );83}8485function isMessage(m: unknown): m is Message {86  return typeof m === "object" && m !== null && "type" in m;87}8889async function handleMessage(90  msg: Message,91  sender: chrome.runtime.MessageSender92): Promise<MessageResponse> {93  try {94    // Validate sender — only allow messages from our extension pages/scripts.95    // `sender.id` is undefined for messages originating from extension UI96    // (popup/options), which is legitimate; reject only when it is defined97    // and does not match our extension id.98    if (sender.id !== undefined && sender.id !== chrome.runtime.id) {99      return { ok: false, error: "invalid sender" };100    }101102    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {103      touchAutoLock();104    }105106    switch (msg.type) {107      case "CREATE_VAULT": {108        const { password } = msg.payload as { password: string };109        await vault.createVault(password);110        await setupAutoLock();111        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });112        return { ok: true };113      }114      case "UNLOCK": {115        const { password } = msg.payload as { password: string };116        const ok = await vault.unlock(password);117        if (ok) {118          await setupAutoLock();119          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });120        }121        return { ok };122      }123      case "LOCK":124        vault.lock();125        return { ok: true };126      case "IS_UNLOCKED":127        return { ok: true, data: vault.isUnlocked() };128      case "GET_VAULT_STATUS":129        return { ok: true, unlocked: vault.isUnlocked() };130      case "HAS_VAULT":131        return { ok: true, data: await vault.hasVault() };132      case "GET_ENTRIES":133        return { ok: true, data: vault.getEntries() };134      case "ADD_ENTRY": {135        const entry = await vault.addEntry(msg.payload as any);136        return { ok: true, data: entry };137      }138      case "BATCH_ADD_ENTRIES": {139        const { entries } = msg.payload as { entries: any[] };140        const added = await vault.batchAddEntries(entries);141        return { ok: true, data: added };142      }143      case "RESTORE_ENTRIES": {144        const { entries, mode, settings } = msg.payload as {145          entries: any[];146          mode: "replace" | "merge";147          settings?: vault.VaultSettings;148        };149        const summary = await vault.restoreEntries(entries, mode);150        if (settings && typeof settings === "object") {151          await vault.updateSettings(settings);152        }153        return { ok: true, data: summary };154      }155      case "CLEAR_VAULT_ENTRIES": {156        await vault.clearVaultEntries("REPLACE_ALL");157        return { ok: true };158      }159      case "LIST_VAULT_SNAPSHOTS": {160        const snapshots = await vault.listSnapshots();161        return { ok: true, data: snapshots };162      }163      case "RESTORE_VAULT_SNAPSHOT": {164        const { index, password } = msg.payload as { index: number; password: string };165        const ok = await vault.restoreFromSnapshot(index, password);166        if (ok) broadcastToContentScripts({ type: "VAULT_UNLOCKED" });167        return { ok };168      }169      case "COUNT_DUPLICATES": {170        return { ok: true, data: vault.countDuplicates() };171      }172      case "DEDUPLICATE_ENTRIES": {173        const removed = await vault.deduplicateEntries();174        return { ok: true, data: removed };175      }176      case "UPDATE_ENTRY": {177        const { id, patch } = msg.payload as any;178        await vault.updateEntry(id, patch);179        return { ok: true };180      }181      case "ADD_ADDITIONAL_URL": {182        const { id, url } = msg.payload as { id: string; url: string };183        const trimmed = typeof url === "string" ? url.trim() : "";184        if (!trimmed) return { ok: false, error: "url required" };185        const existing = vault.getEntryById(id);186        if (!existing) return { ok: false, error: "entry not found" };187        if (existing.type !== "login") return { ok: false, error: "login entries only" };188        const merged = [...(existing.additionalUrls ?? []), trimmed];189        await vault.updateEntry(id, { additionalUrls: merged });190        return { ok: true };191      }192      case "DELETE_ENTRY": {193        const { id } = msg.payload as { id: string };194        await vault.deleteEntry(id);195        return { ok: true };196      }197      case "GET_DELETED_ENTRIES":198        return { ok: true, data: vault.getDeletedEntries() };199      case "RESTORE_ENTRY": {200        const { id } = msg.payload as { id: string };201        await vault.restoreEntry(id);202        return { ok: true };203      }204      case "PURGE_ENTRY": {205        const { id } = msg.payload as { id: string };206        await vault.purgeEntry(id);207        return { ok: true };208      }209      case "PURGE_EXPIRED_TRASH": {210        const { retentionMs } = msg.payload as { retentionMs: number };211        const purged = await vault.purgeExpiredTrash(retentionMs);212        return { ok: true, data: purged };213      }214      case "AUTOFILL_QUERY": {215        if (!vault.isUnlocked()) {216          return { ok: false, error: "VAULT_LOCKED" };217        }218        const { domain, includeAll, kind } = msg.payload as {219          domain: string;220          includeAll?: boolean;221          kind?: "login" | "card";222        };223        // kind="card": return saved cards as compact summaries for the224        // inline card silver platter. Cards are not domain-scoped — every225        // saved card is a candidate on every checkout page — so domain is226        // ignored when kind === "card".227        if (kind === "card") {228          const cards = vault.getEntries().filter((e) => e.type === "card");229          return {230            ok: true,231            data: cards.map((e) => ({232              id: e.id,233              title: e.cardNickname || e.title,234              cardholderName: e.cardholderName,235              last4: e.last4,236              expMonth: e.expMonth,237              expYear: e.expYear,238              network: e.network,239            })),240          };241        }242        const matches = includeAll243          ? vault244              .getEntries()245              .filter((entry) => entry.type !== "note" && entry.type !== "passkey" && entry.type !== "card")246          : vault.queryDomain(domain);247        return {248          ok: true,249          data: matches.map((e) =>250            includeAll251              ? {252                  id: e.id,253                  title: e.title,254                }255              : {256                  id: e.id,257                  title: e.title,258                  username: e.username,259                  url: e.url,260                  hasTotp: Boolean(e.totp),261                }262          ),263        };264      }265      case "GET_ENTRY_FOR_FILL": {266        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };267        const { id } = msg.payload as { id: string };268        const entry = vault.getEntryById(id);269        if (!entry) return { ok: false, error: "entry not found" };270        if (entry.type === "passkey") return { ok: false, error: "passkey not fillable" };271        // Content scripts never need the passkey private key; strip it272        // defensively even though login entries do not carry one.273        const { privateKeyJwk: _stripped, ...safeEntry } = entry;274        return { ok: true, data: safeEntry };275      }276      case "COPY": {277        const { text, label, alreadyWritten } = msg.payload as {278          text: string;279          label: string;280          alreadyWritten?: boolean;281        };282        await copyToClipboard(text, label, Boolean(alreadyWritten));283        return { ok: true };284      }285      case "CHECK_BREACH": {286        const { password } = msg.payload as { password: string };287        const count = await vault.checkBreach(password);288        return { ok: true, data: count };289      }290      case "CHECK_ALL_BREACHES": {291        const allEntries = vault.getEntries();292        const results: Record<string, number> = {};293        for (const entry of allEntries) {294          if (entry.type === "note") continue;295          if (!entry.password) continue;296          results[entry.id] = await vault.checkBreach(entry.password);297          // Small delay — be a good citizen to the HIBP API298          await new Promise((r) => setTimeout(r, 200));299        }300        // Persist any non-zero counts back to entries301        for (const [id, count] of Object.entries(results)) {302          const entry = vault.getEntryById(id);303          if (entry && (entry.breach ?? 0) !== count) {304            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });305          }306        }307        return { ok: true, data: results };308      }309      case "GET_SETTINGS":310        return { ok: true, data: await vault.ensureSettingsLoaded() };311      case "UPDATE_SETTINGS": {312        const patch = msg.payload as Record<string, unknown>;313        await vault.updateSettings(patch as any);314        if (typeof patch.autoLockMinutes === "number") {315          await setupAutoLock();316          if (vault.isUnlocked()) touchAutoLock();317        }318        return { ok: true };319      }320      case "GET_PENDING_PASSKEY":321        return { ok: true, data: await getPendingPasskeyRequest() };322      case "APPROVE_PASSKEY": {323        const { requestId } = msg.payload as { requestId: number };324        await resolvePendingPasskeyRequest(requestId, true);325        return { ok: true };326      }327      case "DENY_PASSKEY": {328        const { requestId } = msg.payload as { requestId: number };329        await resolvePendingPasskeyRequest(requestId, false);330        return { ok: true };331      }332      case "GET_PASSKEYS":333        return {334          ok: true,335          data: vault.getEntries().filter((entry) => entry.type === "passkey"),336        };337      case "DELETE_PASSKEY": {338        const { id } = msg.payload as { id: string };339        await vault.deleteEntry(id);340        return { ok: true };341      }342      case "GET_PASSKEY_PROVIDER_STATE":343        return { ok: true, data: await getPasskeyProviderState() };344      case "SET_PASSKEY_PROVIDER_ENABLED": {345        const { enabled } = msg.payload as { enabled: boolean };346        // Disabling is always free; gating fires only when a free user is347        // trying to turn passkey interception on.348        if (enabled) {349          const gate = await requirePro("passkeys");350          if (!gate.allowed) return { ok: false, error: "pro_required" };351        }352        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };353      }354      case "FIRE_CANARY_PING": {355        const { webhookUrl, entryTitle, trigger } = msg.payload as {356          webhookUrl: string;357          entryTitle: string;358          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";359        };360        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {361          return { ok: false, error: "invalid webhook url" };362        }363        const body = JSON.stringify({364          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",365          entry: entryTitle,366          trigger,367          timestamp: new Date().toISOString(),368          source: "peach-extension",369        });370        if (trigger === "test") {371          // Permitted outbound fetch: user-configured canary alert endpoint372          // (validated by isAllowedCanaryWebhookUrl above). This is the373          // only non-HIBP outbound call in the extension — see374          // Projects.md §3.7. Test pings are awaited so the UI can show375          // success/failure.376          try {377            const res = await fetch(webhookUrl, {378              method: "POST",379              headers: { "Content-Type": "application/json" },380              body,381            });382            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };383          } catch (err) {384            return { ok: false, error: err instanceof Error ? err.message : "network error" };385          }386        }387        // Non-test triggers are fire-and-forget388        fetch(webhookUrl, {389          method: "POST",390          headers: { "Content-Type": "application/json" },391          body,392        }).catch(() => {});393        return { ok: true };394      }395      case "ACTIVATE_LICENSE": {396        const { key } = msg.payload as { key: string };397        const licenseInfo = await activateLicense(key);398        return { ok: true, data: licenseInfo };399      }400      case "GET_LICENSE": {401        const licenseInfo = await getLicenseInfo();402        return { ok: true, data: licenseInfo };403      }404      case "DEACTIVATE_LICENSE": {405        await deactivateLicense();406        return { ok: true };407      }408      case "GET_ADVANCED_HEALTH_REPORT": {409        const gate = await requirePro("advanced_health");410        if (!gate.allowed) return { ok: false, error: "pro_required" };411        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };412        const report = await buildAdvancedHealthReport(vault.getEntries());413        return { ok: true, data: report };414      }415      case "INITIATE_PASSWORD_CHANGE": {416        const gate = await requirePro("smart_password_change");417        if (!gate.allowed) return { ok: false, error: "pro_required" };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        const url = resolveResetUrl(entry);423        if (!url) return { ok: false, error: "no reset url" };424        try {425          await chrome.tabs.create({ url });426          return { ok: true, data: { url } };427        } catch (err) {428          return {429            ok: false,430            error: err instanceof Error ? err.message : "failed to open tab",431          };432        }433      }434      case "IS_DURESS_ENABLED": {435        const enabled = await vault.isDuressEnabled();436        return { ok: true, data: enabled };437      }438      case "SETUP_DURESS": {439        const { duressPassword } = msg.payload as { duressPassword: string };440        await vault.setupDuress(duressPassword);441        return { ok: true };442      }443      case "DISABLE_DURESS": {444        await vault.disableDuress();445        return { ok: true };446      }447      case "VERIFY_MAIN_PASSWORD": {448        const { password } = msg.payload as { password: string };449        const ok = await vault.verifyMainPassword(password);450        return { ok: true, data: ok };451      }452      case "CHANGE_PASSWORD": {453        const { currentPassword, newPassword } = msg.payload as {454          currentPassword: string;455          newPassword: string;456        };457        const changed = await vault.changePassword(currentPassword, newPassword);458        return changed459          ? { ok: true }460          : { ok: false, error: "incorrect current password" };461      }462      case "GET_SITE_PASSWORD_POLICY": {463        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });464        if (!tab?.id) return { ok: true, data: null };465        try {466          const response = await chrome.tabs.sendMessage(tab.id, {467            type: "GET_SITE_PASSWORD_POLICY",468          });469          return (response as MessageResponse) ?? { ok: true, data: null };470        } catch {471          return { ok: true, data: null };472        }473      }474      case "GET_SYNC_STATE": {475        const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');476        const { isOnTailscale } = await import('./sync-network');477        const { isProUser } = await import('./license');478        const pairedDevices = await getPairedDevices();479        const trustedNetworks = await getTrustedNetworks();480        const tailscale = await isOnTailscale();481        const pro = await isProUser();482        483        let status: SyncStatus = "offline";484        if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {485          if (pairedDevices.length === 0) {486            status = "no_peers";487          } else {488            status = "synced";489          }490        } else {491          status = "not_trusted";492        }493        494        return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };495      }496      case "TRIGGER_SYNC": {497        const gate = await requirePro("sync");498        if (!gate.allowed) return { ok: false, error: "pro_required" };499        return { ok: true };500      }501      case "START_PAIRING": {502        const gate = await requirePro("sync");503        if (!gate.allowed) return { ok: false, error: "pro_required" };504        return { ok: true };505      }506      case "CANCEL_PAIRING": {507        return { ok: true };508      }509      case "ACCEPT_PAIRING": {510        const gate = await requirePro("sync");511        if (!gate.allowed) return { ok: false, error: "pro_required" };512        return { ok: true };513      }514      case "REMOVE_PAIRED_DEVICE": {515        const { deviceId } = msg.payload as { deviceId: string };516        const { removePairedDevice } = await import('./sync-storage');517        await removePairedDevice(deviceId);518        return { ok: true };519      }520      case "REMOVE_TRUSTED_NETWORK": {521        const { ssid, bssid, isTailscale } = msg.payload as any;522        const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');523        const networks = await getTrustedNetworks();524        const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 525          (isTailscale ? n.isTailscale : n.ssid === ssid) && 526          (!bssid || n.bssid === bssid)527        );528        if (network) {529          await removeTrustedNetwork(network.addedAt);530        }531        return { ok: true };532      }533      case "MARK_NETWORK_TRUSTED": {534        const { addTrustedNetwork } = await import('./sync-storage');535        const { getCurrentNetwork } = await import('./sync-network');536        const network = await getCurrentNetwork();537        await addTrustedNetwork({538          ssid: network.ssid,539          bssid: network.bssid,540          isTailscale: network.isTailscale,541          addedAt: Date.now(),542        });543        return { ok: true };544      }545      case "RECORD_MANUAL_SUMMON": {546        const { domain } = msg.payload as { domain: string };547        const settings = vault.getSettings();548        const existing = settings.manualSummonDomains?.[domain];549        const now = Date.now();550        await vault.updateSettings({551          manualSummonDomains: {552            ...settings.manualSummonDomains,553            [domain]: {554              firstSummonedAt: existing?.firstSummonedAt ?? now,555              summonCount: (existing?.summonCount ?? 0) + 1,556              lastSummonedAt: now,557            },558          },559        });560        return { ok: true };561      }562      case "DECLINE_CARD_SAVE": {563        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };564        await vault.declineCardSaveOffer(last4, expMonth, expYear);565        return { ok: true };566      }567      case "IS_CARD_DECLINED": {568        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };569        const declined = await vault.isCardDeclined(last4, expMonth, expYear);570        return { ok: true, data: declined };571      }572      case "SUBMIT_FEEDBACK": {573        const { category, message, version, browser } = msg.payload as {574          category: string;575          message: string;576          version: string;577          browser: string;578        };579        if (!message.trim()) return { ok: false, error: "message is empty" };580        if (!FEEDBACK_ENDPOINT) return { ok: true };581        if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {582          return { ok: false, error: "invalid feedback endpoint" };583        }584        try {585          const res = await fetch(FEEDBACK_ENDPOINT, {586            method: "POST",587            headers: { "Content-Type": "application/json" },588            body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),589          });590          return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };591        } catch (err) {592          return { ok: false, error: err instanceof Error ? err.message : "network error" };593        }594      }595      default:596        return { ok: false, error: "unknown message type" };597    }598  } catch (err) {599    return {600      ok: false,601      error: err instanceof Error ? err.message : "operation failed",602    };603  }604}605606/** Notify all content scripts that the vault just became available. */607function broadcastToContentScripts(message: { type: string }): void {608  chrome.tabs.query({}, (tabs) => {609    for (const tab of tabs) {610      if (tab.id !== undefined) {611        chrome.tabs.sendMessage(tab.id, message).catch(() => {612          // Tab may not have a content script — silently ignore613        });614      }615    }616  });617}618619function shouldRefreshAutoLock(type: MessageType): boolean {620  return (621    type !== "HAS_VAULT" &&622    type !== "IS_UNLOCKED" &&623    type !== "GET_VAULT_STATUS" &&624    type !== "LOCK" &&625    type !== "UPDATE_SETTINGS" &&626    type !== "GET_PENDING_PASSKEY" &&627    type !== "GET_PASSKEY_PROVIDER_STATE" &&628    type !== "DECLINE_CARD_SAVE" &&629    type !== "IS_CARD_DECLINED" &&630    type !== "SUBMIT_FEEDBACK"631  );632}633634function isAllowedCanaryWebhookUrl(value: string): boolean {635  try {636    const url = new URL(value);637    if (url.protocol !== "https:") return false;638639    const hostname = url.hostname.toLowerCase();640    if (641      hostname === "localhost" ||642      hostname.endsWith(".localhost") ||643      isPrivateIpv4(hostname) ||644      isPrivateIpv6(hostname)645    ) {646      return false;647    }648649    return true;650  } catch {651    return false;652  }653}654655function isPrivateIpv4(hostname: string): boolean {656  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {657    return true;658  }659660  const octets = hostname.split(".").map((part) => Number(part));661  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {662    return false;663  }664665  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;666}667668function isPrivateIpv6(hostname: string): boolean {669  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();670  return (671    normalized === "::1" ||672    normalized.startsWith("fc") ||673    normalized.startsWith("fd") ||674    normalized.startsWith("fe80:")675  );676}