messages.ts

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