messages.ts

17.0 KB · raw

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