messages.ts

24.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  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_IDENTITIES": {409        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };410        return { ok: true, data: vault.getIdentities() };411      }412      case "GET_ADVANCED_HEALTH_REPORT": {413        const gate = await requirePro("advanced_health");414        if (!gate.allowed) return { ok: false, error: "pro_required" };415        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };416        const report = await buildAdvancedHealthReport(vault.getEntries());417        return { ok: true, data: report };418      }419      case "INITIATE_PASSWORD_CHANGE": {420        const gate = await requirePro("smart_password_change");421        if (!gate.allowed) return { ok: false, error: "pro_required" };422        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };423        const { id } = msg.payload as { id: string };424        const entry = vault.getEntryById(id);425        if (!entry) return { ok: false, error: "entry not found" };426        const url = resolveResetUrl(entry);427        if (!url) return { ok: false, error: "no reset url" };428        try {429          await chrome.tabs.create({ url });430          return { ok: true, data: { url } };431        } catch (err) {432          return {433            ok: false,434            error: err instanceof Error ? err.message : "failed to open tab",435          };436        }437      }438      case "IS_DURESS_ENABLED": {439        const enabled = await vault.isDuressEnabled();440        return { ok: true, data: enabled };441      }442      case "SETUP_DURESS": {443        const { duressPassword } = msg.payload as { duressPassword: string };444        await vault.setupDuress(duressPassword);445        return { ok: true };446      }447      case "DISABLE_DURESS": {448        await vault.disableDuress();449        return { ok: true };450      }451      case "VERIFY_MAIN_PASSWORD": {452        const { password } = msg.payload as { password: string };453        const ok = await vault.verifyMainPassword(password);454        return { ok: true, data: ok };455      }456      case "CHANGE_PASSWORD": {457        const { currentPassword, newPassword } = msg.payload as {458          currentPassword: string;459          newPassword: string;460        };461        const changed = await vault.changePassword(currentPassword, newPassword);462        return changed463          ? { ok: true }464          : { ok: false, error: "incorrect current password" };465      }466      case "GET_SITE_PASSWORD_POLICY": {467        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });468        if (!tab?.id) return { ok: true, data: null };469        try {470          const response = await chrome.tabs.sendMessage(tab.id, {471            type: "GET_SITE_PASSWORD_POLICY",472          });473          return (response as MessageResponse) ?? { ok: true, data: null };474        } catch {475          return { ok: true, data: null };476        }477      }478      case "GET_SYNC_STATE": {479        const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');480        const { isOnTailscale } = await import('./sync-network');481        const { isProUser } = await import('./license');482        const pairedDevices = await getPairedDevices();483        const trustedNetworks = await getTrustedNetworks();484        const tailscale = await isOnTailscale();485        const pro = await isProUser();486        487        let status: SyncStatus = "offline";488        if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {489          if (pairedDevices.length === 0) {490            status = "no_peers";491          } else {492            status = "synced";493          }494        } else {495          status = "not_trusted";496        }497        498        return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };499      }500      case "TRIGGER_SYNC": {501        const gate = await requirePro("sync");502        if (!gate.allowed) return { ok: false, error: "pro_required" };503        return { ok: true };504      }505      case "START_PAIRING": {506        const gate = await requirePro("sync");507        if (!gate.allowed) return { ok: false, error: "pro_required" };508        return { ok: true };509      }510      case "CANCEL_PAIRING": {511        return { ok: true };512      }513      case "ACCEPT_PAIRING": {514        const gate = await requirePro("sync");515        if (!gate.allowed) return { ok: false, error: "pro_required" };516        return { ok: true };517      }518      case "REMOVE_PAIRED_DEVICE": {519        const { deviceId } = msg.payload as { deviceId: string };520        const { removePairedDevice } = await import('./sync-storage');521        await removePairedDevice(deviceId);522        return { ok: true };523      }524      case "REMOVE_TRUSTED_NETWORK": {525        const { ssid, bssid, isTailscale } = msg.payload as any;526        const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');527        const networks = await getTrustedNetworks();528        const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 529          (isTailscale ? n.isTailscale : n.ssid === ssid) && 530          (!bssid || n.bssid === bssid)531        );532        if (network) {533          await removeTrustedNetwork(network.addedAt);534        }535        return { ok: true };536      }537      case "MARK_NETWORK_TRUSTED": {538        const { addTrustedNetwork } = await import('./sync-storage');539        const { getCurrentNetwork } = await import('./sync-network');540        const network = await getCurrentNetwork();541        await addTrustedNetwork({542          ssid: network.ssid,543          bssid: network.bssid,544          isTailscale: network.isTailscale,545          addedAt: Date.now(),546        });547        return { ok: true };548      }549      case "RECORD_MANUAL_SUMMON": {550        const { domain } = msg.payload as { domain: string };551        const settings = vault.getSettings();552        const existing = settings.manualSummonDomains?.[domain];553        const now = Date.now();554        await vault.updateSettings({555          manualSummonDomains: {556            ...settings.manualSummonDomains,557            [domain]: {558              firstSummonedAt: existing?.firstSummonedAt ?? now,559              summonCount: (existing?.summonCount ?? 0) + 1,560              lastSummonedAt: now,561            },562          },563        });564        return { ok: true };565      }566      case "DECLINE_CARD_SAVE": {567        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };568        await vault.declineCardSaveOffer(last4, expMonth, expYear);569        return { ok: true };570      }571      case "IS_CARD_DECLINED": {572        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };573        const declined = await vault.isCardDeclined(last4, expMonth, expYear);574        return { ok: true, data: declined };575      }576      case "SUBMIT_FEEDBACK": {577        const { category, message, version, browser } = msg.payload as {578          category: string;579          message: string;580          version: string;581          browser: string;582        };583        if (!message.trim()) return { ok: false, error: "message is empty" };584        if (!FEEDBACK_ENDPOINT) return { ok: true };585        if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {586          return { ok: false, error: "invalid feedback endpoint" };587        }588        try {589          const res = await fetch(FEEDBACK_ENDPOINT, {590            method: "POST",591            headers: { "Content-Type": "application/json" },592            body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),593          });594          return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };595        } catch (err) {596          return { ok: false, error: err instanceof Error ? err.message : "network error" };597        }598      }599      default:600        return { ok: false, error: "unknown message type" };601    }602  } catch (err) {603    return {604      ok: false,605      error: err instanceof Error ? err.message : "operation failed",606    };607  }608}609610/** Notify all content scripts that the vault just became available. */611function broadcastToContentScripts(message: { type: string }): void {612  chrome.tabs.query({}, (tabs) => {613    for (const tab of tabs) {614      if (tab.id !== undefined) {615        chrome.tabs.sendMessage(tab.id, message).catch(() => {616          // Tab may not have a content script — silently ignore617        });618      }619    }620  });621}622623function shouldRefreshAutoLock(type: MessageType): boolean {624  return (625    type !== "HAS_VAULT" &&626    type !== "IS_UNLOCKED" &&627    type !== "GET_VAULT_STATUS" &&628    type !== "LOCK" &&629    type !== "UPDATE_SETTINGS" &&630    type !== "GET_PENDING_PASSKEY" &&631    type !== "GET_PASSKEY_PROVIDER_STATE" &&632    type !== "DECLINE_CARD_SAVE" &&633    type !== "IS_CARD_DECLINED" &&634    type !== "SUBMIT_FEEDBACK"635  );636}637638function isAllowedCanaryWebhookUrl(value: string): boolean {639  try {640    const url = new URL(value);641    if (url.protocol !== "https:") return false;642643    const hostname = url.hostname.toLowerCase();644    if (645      hostname === "localhost" ||646      hostname.endsWith(".localhost") ||647      isPrivateIpv4(hostname) ||648      isPrivateIpv6(hostname)649    ) {650      return false;651    }652653    return true;654  } catch {655    return false;656  }657}658659function isPrivateIpv4(hostname: string): boolean {660  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {661    return true;662  }663664  const octets = hostname.split(".").map((part) => Number(part));665  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {666    return false;667  }668669  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;670}671672function isPrivateIpv6(hostname: string): boolean {673  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();674  return (675    normalized === "::1" ||676    normalized.startsWith("fc") ||677    normalized.startsWith("fd") ||678    normalized.startsWith("fe80:")679  );680}