messages.ts

19.2 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 "GET_VAULT_STATUS":96        return { ok: true, unlocked: vault.isUnlocked() };97      case "HAS_VAULT":98        return { ok: true, data: await vault.hasVault() };99      case "GET_ENTRIES":100        return { ok: true, data: vault.getEntries() };101      case "ADD_ENTRY": {102        const entry = await vault.addEntry(msg.payload as any);103        return { ok: true, data: entry };104      }105      case "BATCH_ADD_ENTRIES": {106        const { entries } = msg.payload as { entries: any[] };107        const added = await vault.batchAddEntries(entries);108        return { ok: true, data: added };109      }110      case "RESTORE_ENTRIES": {111        const { entries, mode, settings } = msg.payload as {112          entries: any[];113          mode: "replace" | "merge";114          settings?: vault.VaultSettings;115        };116        const summary = await vault.restoreEntries(entries, mode);117        if (settings && typeof settings === "object") {118          await vault.updateSettings(settings);119        }120        return { ok: true, data: summary };121      }122      case "CLEAR_VAULT_ENTRIES": {123        await vault.clearVaultEntries("REPLACE_ALL");124        return { ok: true };125      }126      case "LIST_VAULT_SNAPSHOTS": {127        const snapshots = await vault.listSnapshots();128        return { ok: true, data: snapshots };129      }130      case "RESTORE_VAULT_SNAPSHOT": {131        const { index, password } = msg.payload as { index: number; password: string };132        const ok = await vault.restoreFromSnapshot(index, password);133        if (ok) broadcastToContentScripts({ type: "VAULT_UNLOCKED" });134        return { ok };135      }136      case "COUNT_DUPLICATES": {137        return { ok: true, data: vault.countDuplicates() };138      }139      case "DEDUPLICATE_ENTRIES": {140        const removed = await vault.deduplicateEntries();141        return { ok: true, data: removed };142      }143      case "UPDATE_ENTRY": {144        const { id, patch } = msg.payload as any;145        await vault.updateEntry(id, patch);146        return { ok: true };147      }148      case "DELETE_ENTRY": {149        const { id } = msg.payload as { id: string };150        await vault.deleteEntry(id);151        return { ok: true };152      }153      case "GET_DELETED_ENTRIES":154        return { ok: true, data: vault.getDeletedEntries() };155      case "RESTORE_ENTRY": {156        const { id } = msg.payload as { id: string };157        await vault.restoreEntry(id);158        return { ok: true };159      }160      case "PURGE_ENTRY": {161        const { id } = msg.payload as { id: string };162        await vault.purgeEntry(id);163        return { ok: true };164      }165      case "PURGE_EXPIRED_TRASH": {166        const { retentionMs } = msg.payload as { retentionMs: number };167        const purged = await vault.purgeExpiredTrash(retentionMs);168        return { ok: true, data: purged };169      }170      case "AUTOFILL_QUERY": {171        if (!vault.isUnlocked()) {172          return { ok: false, error: "VAULT_LOCKED" };173        }174        const { domain, includeAll } = msg.payload as {175          domain: string;176          includeAll?: boolean;177        };178        const matches = includeAll179          ? vault180              .getEntries()181              .filter((entry) => entry.type !== "note" && entry.type !== "passkey" && entry.type !== "card")182          : vault.queryDomain(domain);183        return {184          ok: true,185          data: matches.map((e) =>186            includeAll187              ? {188                  id: e.id,189                  title: e.title,190                }191              : {192                  id: e.id,193                  title: e.title,194                  username: e.username,195                  url: e.url,196                  hasTotp: Boolean(e.totp),197                }198          ),199        };200      }201      case "GET_ENTRY_FOR_FILL": {202        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };203        const { id } = msg.payload as { id: string };204        const entry = vault.getEntryById(id);205        if (!entry) return { ok: false, error: "entry not found" };206        if (entry.type === "passkey") return { ok: false, error: "passkey not fillable" };207        // Content scripts never need the passkey private key; strip it208        // defensively even though login entries do not carry one.209        const { privateKeyJwk: _stripped, ...safeEntry } = entry;210        return { ok: true, data: safeEntry };211      }212      case "COPY": {213        const { text, label, alreadyWritten } = msg.payload as {214          text: string;215          label: string;216          alreadyWritten?: boolean;217        };218        await copyToClipboard(text, label, Boolean(alreadyWritten));219        return { ok: true };220      }221      case "CHECK_BREACH": {222        const { password } = msg.payload as { password: string };223        const count = await vault.checkBreach(password);224        return { ok: true, data: count };225      }226      case "CHECK_ALL_BREACHES": {227        const allEntries = vault.getEntries();228        const results: Record<string, number> = {};229        for (const entry of allEntries) {230          if (entry.type === "note") continue;231          if (!entry.password) continue;232          results[entry.id] = await vault.checkBreach(entry.password);233          // Small delay — be a good citizen to the HIBP API234          await new Promise((r) => setTimeout(r, 200));235        }236        // Persist any non-zero counts back to entries237        for (const [id, count] of Object.entries(results)) {238          const entry = vault.getEntryById(id);239          if (entry && (entry.breach ?? 0) !== count) {240            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });241          }242        }243        return { ok: true, data: results };244      }245      case "GET_SETTINGS":246        return { ok: true, data: await vault.ensureSettingsLoaded() };247      case "UPDATE_SETTINGS": {248        const patch = msg.payload as Record<string, unknown>;249        await vault.updateSettings(patch as any);250        if (typeof patch.autoLockMinutes === "number") {251          await setupAutoLock();252          if (vault.isUnlocked()) touchAutoLock();253        }254        return { ok: true };255      }256      case "GET_PENDING_PASSKEY":257        return { ok: true, data: await getPendingPasskeyRequest() };258      case "APPROVE_PASSKEY": {259        const { requestId } = msg.payload as { requestId: number };260        await resolvePendingPasskeyRequest(requestId, true);261        return { ok: true };262      }263      case "DENY_PASSKEY": {264        const { requestId } = msg.payload as { requestId: number };265        await resolvePendingPasskeyRequest(requestId, false);266        return { ok: true };267      }268      case "GET_PASSKEYS":269        return {270          ok: true,271          data: vault.getEntries().filter((entry) => entry.type === "passkey"),272        };273      case "DELETE_PASSKEY": {274        const { id } = msg.payload as { id: string };275        await vault.deleteEntry(id);276        return { ok: true };277      }278      case "GET_PASSKEY_PROVIDER_STATE":279        return { ok: true, data: await getPasskeyProviderState() };280      case "SET_PASSKEY_PROVIDER_ENABLED": {281        const { enabled } = msg.payload as { enabled: boolean };282        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };283      }284      case "FIRE_CANARY_PING": {285        const { webhookUrl, entryTitle, trigger } = msg.payload as {286          webhookUrl: string;287          entryTitle: string;288          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";289        };290        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {291          return { ok: false, error: "invalid webhook url" };292        }293        const body = JSON.stringify({294          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",295          entry: entryTitle,296          trigger,297          timestamp: new Date().toISOString(),298          source: "peach-extension",299        });300        if (trigger === "test") {301          // Permitted outbound fetch: user-configured canary alert endpoint302          // (validated by isAllowedCanaryWebhookUrl above). This is the303          // only non-HIBP outbound call in the extension — see304          // Projects.md §3.7. Test pings are awaited so the UI can show305          // success/failure.306          try {307            const res = await fetch(webhookUrl, {308              method: "POST",309              headers: { "Content-Type": "application/json" },310              body,311            });312            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };313          } catch (err) {314            return { ok: false, error: err instanceof Error ? err.message : "network error" };315          }316        }317        // Non-test triggers are fire-and-forget318        fetch(webhookUrl, {319          method: "POST",320          headers: { "Content-Type": "application/json" },321          body,322        }).catch(() => {});323        return { ok: true };324      }325      case "ACTIVATE_LICENSE": {326        const { key } = msg.payload as { key: string };327        const licenseInfo = await activateLicense(key);328        return { ok: true, data: licenseInfo };329      }330      case "GET_LICENSE": {331        const licenseInfo = await getLicenseInfo();332        return { ok: true, data: licenseInfo };333      }334      case "IS_DURESS_ENABLED": {335        const enabled = await vault.isDuressEnabled();336        return { ok: true, data: enabled };337      }338      case "SETUP_DURESS": {339        const { duressPassword } = msg.payload as { duressPassword: string };340        await vault.setupDuress(duressPassword);341        return { ok: true };342      }343      case "DISABLE_DURESS": {344        await vault.disableDuress();345        return { ok: true };346      }347      case "VERIFY_MAIN_PASSWORD": {348        const { password } = msg.payload as { password: string };349        const ok = await vault.verifyMainPassword(password);350        return { ok: true, data: ok };351      }352      case "CHANGE_PASSWORD": {353        const { currentPassword, newPassword } = msg.payload as {354          currentPassword: string;355          newPassword: string;356        };357        const changed = await vault.changePassword(currentPassword, newPassword);358        return changed359          ? { ok: true }360          : { ok: false, error: "incorrect current password" };361      }362      case "GET_SITE_PASSWORD_POLICY": {363        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });364        if (!tab?.id) return { ok: true, data: null };365        try {366          const response = await chrome.tabs.sendMessage(tab.id, {367            type: "GET_SITE_PASSWORD_POLICY",368          });369          return (response as MessageResponse) ?? { ok: true, data: null };370        } catch {371          return { ok: true, data: null };372        }373      }374      case "GET_SYNC_STATE": {375        const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');376        const { isOnTailscale } = await import('./sync-network');377        const { isProUser } = await import('./license');378        const pairedDevices = await getPairedDevices();379        const trustedNetworks = await getTrustedNetworks();380        const tailscale = await isOnTailscale();381        const pro = await isProUser();382        383        let status: SyncStatus = "offline";384        if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {385          if (pairedDevices.length === 0) {386            status = "no_peers";387          } else {388            status = "synced";389          }390        } else {391          status = "not_trusted";392        }393        394        return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };395      }396      case "TRIGGER_SYNC": {397        return { ok: true };398      }399      case "START_PAIRING": {400        return { ok: true };401      }402      case "CANCEL_PAIRING": {403        return { ok: true };404      }405      case "ACCEPT_PAIRING": {406        return { ok: true };407      }408      case "REMOVE_PAIRED_DEVICE": {409        const { deviceId } = msg.payload as { deviceId: string };410        const { removePairedDevice } = await import('./sync-storage');411        await removePairedDevice(deviceId);412        return { ok: true };413      }414      case "REMOVE_TRUSTED_NETWORK": {415        const { ssid, bssid, isTailscale } = msg.payload as any;416        const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');417        const networks = await getTrustedNetworks();418        const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 419          (isTailscale ? n.isTailscale : n.ssid === ssid) && 420          (!bssid || n.bssid === bssid)421        );422        if (network) {423          await removeTrustedNetwork(network.addedAt);424        }425        return { ok: true };426      }427      case "MARK_NETWORK_TRUSTED": {428        const { addTrustedNetwork } = await import('./sync-storage');429        const { getCurrentNetwork } = await import('./sync-network');430        const network = await getCurrentNetwork();431        await addTrustedNetwork({432          ssid: network.ssid,433          bssid: network.bssid,434          isTailscale: network.isTailscale,435          addedAt: Date.now(),436        });437        return { ok: true };438      }439      case "RECORD_MANUAL_SUMMON": {440        const { domain } = msg.payload as { domain: string };441        const settings = vault.getSettings();442        const existing = settings.manualSummonDomains?.[domain];443        const now = Date.now();444        await vault.updateSettings({445          manualSummonDomains: {446            ...settings.manualSummonDomains,447            [domain]: {448              firstSummonedAt: existing?.firstSummonedAt ?? now,449              summonCount: (existing?.summonCount ?? 0) + 1,450              lastSummonedAt: now,451            },452          },453        });454        return { ok: true };455      }456      case "DECLINE_CARD_SAVE": {457        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };458        await vault.declineCardSaveOffer(last4, expMonth, expYear);459        return { ok: true };460      }461      case "IS_CARD_DECLINED": {462        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };463        const declined = await vault.isCardDeclined(last4, expMonth, expYear);464        return { ok: true, data: declined };465      }466      default:467        return { ok: false, error: "unknown message type" };468    }469  } catch (err) {470    return {471      ok: false,472      error: err instanceof Error ? err.message : "operation failed",473    };474  }475}476477/** Notify all content scripts that the vault just became available. */478function broadcastToContentScripts(message: { type: string }): void {479  chrome.tabs.query({}, (tabs) => {480    for (const tab of tabs) {481      if (tab.id !== undefined) {482        chrome.tabs.sendMessage(tab.id, message).catch(() => {483          // Tab may not have a content script — silently ignore484        });485      }486    }487  });488}489490function shouldRefreshAutoLock(type: MessageType): boolean {491  return (492    type !== "HAS_VAULT" &&493    type !== "IS_UNLOCKED" &&494    type !== "GET_VAULT_STATUS" &&495    type !== "LOCK" &&496    type !== "UPDATE_SETTINGS" &&497    type !== "GET_PENDING_PASSKEY" &&498    type !== "GET_PASSKEY_PROVIDER_STATE" &&499    type !== "DECLINE_CARD_SAVE" &&500    type !== "IS_CARD_DECLINED"501  );502}503504function isAllowedCanaryWebhookUrl(value: string): boolean {505  try {506    const url = new URL(value);507    if (url.protocol !== "https:") return false;508509    const hostname = url.hostname.toLowerCase();510    if (511      hostname === "localhost" ||512      hostname.endsWith(".localhost") ||513      isPrivateIpv4(hostname) ||514      isPrivateIpv6(hostname)515    ) {516      return false;517    }518519    return true;520  } catch {521    return false;522  }523}524525function isPrivateIpv4(hostname: string): boolean {526  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {527    return true;528  }529530  const octets = hostname.split(".").map((part) => Number(part));531  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {532    return false;533  }534535  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;536}537538function isPrivateIpv6(hostname: string): boolean {539  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();540  return (541    normalized === "::1" ||542    normalized.startsWith("fc") ||543    normalized.startsWith("fd") ||544    normalized.startsWith("fe80:")545  );546}