messages.ts

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