messages.ts

18.9 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 { activateLicense, getLicenseInfo } from "./license";16import {17  getPasskeyProviderState,18  getPendingPasskeyRequest,19  resolvePendingPasskeyRequest,20  setPasskeyProviderEnabled,21} from "./passkey";22import type { Message, MessageResponse, MessageType } from "../shared/types";23import type { SyncStatus } from "../shared/sync-types";2425const PRIVATE_IPV4_RANGES = [26  { prefix: "10." },27  { prefix: "127." },28  { prefix: "169.254." },29  { prefix: "192.168." },30];3132export function setupMessageHandler(): void {33  chrome.runtime.onMessage.addListener(34    (msg: unknown, sender, sendResponse) => {35      if (!isMessage(msg)) {36        sendResponse({ ok: false, error: "invalid message" });37        return true;38      }39      handleMessage(msg, sender)40        .then(sendResponse)41        .catch((err) =>42          sendResponse({43            ok: false,44            error: err instanceof Error ? err.message : String(err),45          })46        );47      return true;48    }49  );50}5152function isMessage(m: unknown): m is Message {53  return typeof m === "object" && m !== null && "type" in m;54}5556async function handleMessage(57  msg: Message,58  sender: chrome.runtime.MessageSender59): Promise<MessageResponse> {60  try {61    // Validate sender — only allow messages from our extension pages/scripts.62    // `sender.id` is undefined for messages originating from extension UI63    // (popup/options), which is legitimate; reject only when it is defined64    // and does not match our extension id.65    if (sender.id !== undefined && sender.id !== chrome.runtime.id) {66      return { ok: false, error: "invalid sender" };67    }6869    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {70      touchAutoLock();71    }7273    switch (msg.type) {74      case "CREATE_VAULT": {75        const { password } = msg.payload as { password: string };76        await vault.createVault(password);77        await setupAutoLock();78        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });79        return { ok: true };80      }81      case "UNLOCK": {82        const { password } = msg.payload as { password: string };83        const ok = await vault.unlock(password);84        if (ok) {85          await setupAutoLock();86          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });87        }88        return { ok };89      }90      case "LOCK":91        vault.lock();92        return { ok: true };93      case "IS_UNLOCKED":94        return { ok: true, data: vault.isUnlocked() };95      case "HAS_VAULT":96        return { ok: true, data: await vault.hasVault() };97      case "GET_ENTRIES":98        return { ok: true, data: vault.getEntries() };99      case "ADD_ENTRY": {100        const entry = await vault.addEntry(msg.payload as any);101        return { ok: true, data: entry };102      }103      case "BATCH_ADD_ENTRIES": {104        const { entries } = msg.payload as { entries: any[] };105        const added = await vault.batchAddEntries(entries);106        return { ok: true, data: added };107      }108      case "RESTORE_ENTRIES": {109        const { entries, mode, settings } = msg.payload as {110          entries: any[];111          mode: "replace" | "merge";112          settings?: vault.VaultSettings;113        };114        const summary = await vault.restoreEntries(entries, mode);115        if (settings && typeof settings === "object") {116          await vault.updateSettings(settings);117        }118        return { ok: true, data: summary };119      }120      case "CLEAR_VAULT_ENTRIES": {121        await vault.clearVaultEntries("REPLACE_ALL");122        return { ok: true };123      }124      case "LIST_VAULT_SNAPSHOTS": {125        const snapshots = await vault.listSnapshots();126        return { ok: true, data: snapshots };127      }128      case "RESTORE_VAULT_SNAPSHOT": {129        const { index, password } = msg.payload as { index: number; password: string };130        const ok = await vault.restoreFromSnapshot(index, password);131        if (ok) broadcastToContentScripts({ type: "VAULT_UNLOCKED" });132        return { ok };133      }134      case "COUNT_DUPLICATES": {135        return { ok: true, data: vault.countDuplicates() };136      }137      case "DEDUPLICATE_ENTRIES": {138        const removed = await vault.deduplicateEntries();139        return { ok: true, data: removed };140      }141      case "UPDATE_ENTRY": {142        const { id, patch } = msg.payload as any;143        await vault.updateEntry(id, patch);144        return { ok: true };145      }146      case "DELETE_ENTRY": {147        const { id } = msg.payload as { id: string };148        await vault.deleteEntry(id);149        return { ok: true };150      }151      case "GET_DELETED_ENTRIES":152        return { ok: true, data: vault.getDeletedEntries() };153      case "RESTORE_ENTRY": {154        const { id } = msg.payload as { id: string };155        await vault.restoreEntry(id);156        return { ok: true };157      }158      case "PURGE_ENTRY": {159        const { id } = msg.payload as { id: string };160        await vault.purgeEntry(id);161        return { ok: true };162      }163      case "PURGE_EXPIRED_TRASH": {164        const { retentionMs } = msg.payload as { retentionMs: number };165        const purged = await vault.purgeExpiredTrash(retentionMs);166        return { ok: true, data: purged };167      }168      case "AUTOFILL_QUERY": {169        if (!vault.isUnlocked()) {170          return { ok: false, error: "VAULT_LOCKED" };171        }172        const { domain, includeAll } = msg.payload as {173          domain: string;174          includeAll?: boolean;175        };176        const matches = includeAll177          ? vault178              .getEntries()179              .filter((entry) => entry.type !== "note" && entry.type !== "passkey" && entry.type !== "card")180          : vault.queryDomain(domain);181        return {182          ok: true,183          data: matches.map((e) =>184            includeAll185              ? {186                  id: e.id,187                  title: e.title,188                }189              : {190                  id: e.id,191                  title: e.title,192                  username: e.username,193                  url: e.url,194                  hasTotp: Boolean(e.totp),195                }196          ),197        };198      }199      case "GET_ENTRY_FOR_FILL": {200        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };201        const { id } = msg.payload as { id: string };202        const entry = vault.getEntryById(id);203        if (!entry) return { ok: false, error: "entry not found" };204        if (entry.type === "passkey") return { ok: false, error: "passkey not fillable" };205        // Content scripts never need the passkey private key; strip it206        // defensively even though login entries do not carry one.207        const { privateKeyJwk: _stripped, ...safeEntry } = entry;208        return { ok: true, data: safeEntry };209      }210      case "COPY": {211        const { text, label } = msg.payload as { text: string; label: string };212        await copyToClipboard(text, label);213        return { ok: true };214      }215      case "CHECK_BREACH": {216        const { password } = msg.payload as { password: string };217        const count = await vault.checkBreach(password);218        return { ok: true, data: count };219      }220      case "CHECK_ALL_BREACHES": {221        const allEntries = vault.getEntries();222        const results: Record<string, number> = {};223        for (const entry of allEntries) {224          if (entry.type === "note") continue;225          if (!entry.password) continue;226          results[entry.id] = await vault.checkBreach(entry.password);227          // Small delay — be a good citizen to the HIBP API228          await new Promise((r) => setTimeout(r, 200));229        }230        // Persist any non-zero counts back to entries231        for (const [id, count] of Object.entries(results)) {232          const entry = vault.getEntryById(id);233          if (entry && (entry.breach ?? 0) !== count) {234            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });235          }236        }237        return { ok: true, data: results };238      }239      case "GET_SETTINGS":240        return { ok: true, data: await vault.ensureSettingsLoaded() };241      case "UPDATE_SETTINGS": {242        const patch = msg.payload as Record<string, unknown>;243        await vault.updateSettings(patch as any);244        if (typeof patch.autoLockMinutes === "number") {245          await setupAutoLock();246          if (vault.isUnlocked()) touchAutoLock();247        }248        return { ok: true };249      }250      case "GET_PENDING_PASSKEY":251        return { ok: true, data: await getPendingPasskeyRequest() };252      case "APPROVE_PASSKEY": {253        const { requestId } = msg.payload as { requestId: number };254        await resolvePendingPasskeyRequest(requestId, true);255        return { ok: true };256      }257      case "DENY_PASSKEY": {258        const { requestId } = msg.payload as { requestId: number };259        await resolvePendingPasskeyRequest(requestId, false);260        return { ok: true };261      }262      case "GET_PASSKEYS":263        return {264          ok: true,265          data: vault.getEntries().filter((entry) => entry.type === "passkey"),266        };267      case "DELETE_PASSKEY": {268        const { id } = msg.payload as { id: string };269        await vault.deleteEntry(id);270        return { ok: true };271      }272      case "GET_PASSKEY_PROVIDER_STATE":273        return { ok: true, data: await getPasskeyProviderState() };274      case "SET_PASSKEY_PROVIDER_ENABLED": {275        const { enabled } = msg.payload as { enabled: boolean };276        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };277      }278      case "FIRE_CANARY_PING": {279        const { webhookUrl, entryTitle, trigger } = msg.payload as {280          webhookUrl: string;281          entryTitle: string;282          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";283        };284        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {285          return { ok: false, error: "invalid webhook url" };286        }287        const body = JSON.stringify({288          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",289          entry: entryTitle,290          trigger,291          timestamp: new Date().toISOString(),292          source: "peach-extension",293        });294        if (trigger === "test") {295          // Permitted outbound fetch: user-configured canary alert endpoint296          // (validated by isAllowedCanaryWebhookUrl above). This is the297          // only non-HIBP outbound call in the extension — see298          // Projects.md §3.7. Test pings are awaited so the UI can show299          // success/failure.300          try {301            const res = await fetch(webhookUrl, {302              method: "POST",303              headers: { "Content-Type": "application/json" },304              body,305            });306            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };307          } catch (err) {308            return { ok: false, error: err instanceof Error ? err.message : "network error" };309          }310        }311        // Non-test triggers are fire-and-forget312        fetch(webhookUrl, {313          method: "POST",314          headers: { "Content-Type": "application/json" },315          body,316        }).catch(() => {});317        return { ok: true };318      }319      case "ACTIVATE_LICENSE": {320        const { key } = msg.payload as { key: string };321        const licenseInfo = await activateLicense(key);322        return { ok: true, data: licenseInfo };323      }324      case "GET_LICENSE": {325        const licenseInfo = await getLicenseInfo();326        return { ok: true, data: licenseInfo };327      }328      case "IS_DURESS_ENABLED": {329        const enabled = await vault.isDuressEnabled();330        return { ok: true, data: enabled };331      }332      case "SETUP_DURESS": {333        const { duressPassword } = msg.payload as { duressPassword: string };334        await vault.setupDuress(duressPassword);335        return { ok: true };336      }337      case "DISABLE_DURESS": {338        await vault.disableDuress();339        return { ok: true };340      }341      case "VERIFY_MAIN_PASSWORD": {342        const { password } = msg.payload as { password: string };343        const ok = await vault.verifyMainPassword(password);344        return { ok: true, data: ok };345      }346      case "CHANGE_PASSWORD": {347        const { currentPassword, newPassword } = msg.payload as {348          currentPassword: string;349          newPassword: string;350        };351        const changed = await vault.changePassword(currentPassword, newPassword);352        return changed353          ? { ok: true }354          : { ok: false, error: "incorrect current password" };355      }356      case "GET_SITE_PASSWORD_POLICY": {357        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });358        if (!tab?.id) return { ok: true, data: null };359        try {360          const response = await chrome.tabs.sendMessage(tab.id, {361            type: "GET_SITE_PASSWORD_POLICY",362          });363          return (response as MessageResponse) ?? { ok: true, data: null };364        } catch {365          return { ok: true, data: null };366        }367      }368      case "GET_SYNC_STATE": {369        const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');370        const { isOnTailscale } = await import('./sync-network');371        const { isProUser } = await import('./license');372        const pairedDevices = await getPairedDevices();373        const trustedNetworks = await getTrustedNetworks();374        const tailscale = await isOnTailscale();375        const pro = await isProUser();376        377        let status: SyncStatus = "offline";378        if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {379          if (pairedDevices.length === 0) {380            status = "no_peers";381          } else {382            status = "synced";383          }384        } else {385          status = "not_trusted";386        }387        388        return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };389      }390      case "TRIGGER_SYNC": {391        return { ok: true };392      }393      case "START_PAIRING": {394        return { ok: true };395      }396      case "CANCEL_PAIRING": {397        return { ok: true };398      }399      case "ACCEPT_PAIRING": {400        return { ok: true };401      }402      case "REMOVE_PAIRED_DEVICE": {403        const { deviceId } = msg.payload as { deviceId: string };404        const { removePairedDevice } = await import('./sync-storage');405        await removePairedDevice(deviceId);406        return { ok: true };407      }408      case "REMOVE_TRUSTED_NETWORK": {409        const { ssid, bssid, isTailscale } = msg.payload as any;410        const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');411        const networks = await getTrustedNetworks();412        const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 413          (isTailscale ? n.isTailscale : n.ssid === ssid) && 414          (!bssid || n.bssid === bssid)415        );416        if (network) {417          await removeTrustedNetwork(network.addedAt);418        }419        return { ok: true };420      }421      case "MARK_NETWORK_TRUSTED": {422        const { addTrustedNetwork } = await import('./sync-storage');423        const { getCurrentNetwork } = await import('./sync-network');424        const network = await getCurrentNetwork();425        await addTrustedNetwork({426          ssid: network.ssid,427          bssid: network.bssid,428          isTailscale: network.isTailscale,429          addedAt: Date.now(),430        });431        return { ok: true };432      }433      case "RECORD_MANUAL_SUMMON": {434        const { domain } = msg.payload as { domain: string };435        const settings = vault.getSettings();436        const existing = settings.manualSummonDomains?.[domain];437        const now = Date.now();438        await vault.updateSettings({439          manualSummonDomains: {440            ...settings.manualSummonDomains,441            [domain]: {442              firstSummonedAt: existing?.firstSummonedAt ?? now,443              summonCount: (existing?.summonCount ?? 0) + 1,444              lastSummonedAt: now,445            },446          },447        });448        return { ok: true };449      }450      case "DECLINE_CARD_SAVE": {451        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };452        await vault.declineCardSaveOffer(last4, expMonth, expYear);453        return { ok: true };454      }455      case "IS_CARD_DECLINED": {456        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };457        const declined = await vault.isCardDeclined(last4, expMonth, expYear);458        return { ok: true, data: declined };459      }460      default:461        return { ok: false, error: "unknown message type" };462    }463  } catch (err) {464    return {465      ok: false,466      error: err instanceof Error ? err.message : "operation failed",467    };468  }469}470471/** Notify all content scripts that the vault just became available. */472function broadcastToContentScripts(message: { type: string }): void {473  chrome.tabs.query({}, (tabs) => {474    for (const tab of tabs) {475      if (tab.id !== undefined) {476        chrome.tabs.sendMessage(tab.id, message).catch(() => {477          // Tab may not have a content script — silently ignore478        });479      }480    }481  });482}483484function shouldRefreshAutoLock(type: MessageType): boolean {485  return (486    type !== "HAS_VAULT" &&487    type !== "IS_UNLOCKED" &&488    type !== "LOCK" &&489    type !== "UPDATE_SETTINGS" &&490    type !== "GET_PENDING_PASSKEY" &&491    type !== "GET_PASSKEY_PROVIDER_STATE" &&492    type !== "DECLINE_CARD_SAVE" &&493    type !== "IS_CARD_DECLINED"494  );495}496497function isAllowedCanaryWebhookUrl(value: string): boolean {498  try {499    const url = new URL(value);500    if (url.protocol !== "https:") return false;501502    const hostname = url.hostname.toLowerCase();503    if (504      hostname === "localhost" ||505      hostname.endsWith(".localhost") ||506      isPrivateIpv4(hostname) ||507      isPrivateIpv6(hostname)508    ) {509      return false;510    }511512    return true;513  } catch {514    return false;515  }516}517518function isPrivateIpv4(hostname: string): boolean {519  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {520    return true;521  }522523  const octets = hostname.split(".").map((part) => Number(part));524  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {525    return false;526  }527528  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;529}530531function isPrivateIpv6(hostname: string): boolean {532  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();533  return (534    normalized === "::1" ||535    normalized.startsWith("fc") ||536    normalized.startsWith("fd") ||537    normalized.startsWith("fe80:")538  );539}