messages.ts

45.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  getLicenseKey,20  isProUser,21  markCelebrationPlayed,22  shouldPlayCelebration,23} from "./license";24import {25  cancelFirefoxPasskeyCreate,26  completeFirefoxPasskeyCreate,27  getPasskeyProviderState,28  getPendingPasskeyRequest,29  handleFirefoxPasskeyCreate,30  handleFirefoxPasskeyGet,31  handlePasskeyHasCredentials,32  initiateFirefoxPasskeyCreate,33  isPasskeyOfferDeclined,34  recordPasskeyOfferDecline,35  resolvePendingPasskeyRequest,36  setPasskeyProviderEnabled,37} from "./passkey";38import { buildAdvancedHealthReport, resolveResetUrl } from "./health";39import { storageClear, storageGet } from "../shared/storage";40import { findInvalidWords } from "../shared/recovery-words";41import {42  addPairedDevice,43  addTrustedNetwork,44  clearIncomingPairingRequest,45  getIncomingPairingRequest,46  getPairedDevices,47  getTrustedNetworks,48  removePairedDevice,49  removeTrustedNetwork,50  setIncomingPairingRequest,51} from "./sync-storage";52import { getCurrentNetwork, isOnTailscale } from "./sync-network";53import {54  consumeRecentPairing,55  setActivePairingToken,56} from "./pairing-server-state";57import type {58  Message,59  MessageResponse,60  MessageType,61  ProFeature,62  VaultEntry,63} from "../shared/types";64import { isPasskeyOnly } from "../shared/types";65import type { SyncStatus } from "../shared/sync-types";66import { isNativePlatform, nativePlugin } from "../shared/native";6768/**69 * The single Pro gate. Every gated handler calls this first; the broadcast70 * lets the popup react (navigate to the License section, highlight the71 * relevant feature). Background never touches the popup view directly —72 * the popup owns navigation. See Projects.md "Pro gate rule".73 */74async function requirePro(75  feature: ProFeature,76  trigger: "user" | "background" = "user"77): Promise<{ allowed: boolean }> {78  if (await isProUser()) return { allowed: true };7980  if (trigger === "user") {81    // User deliberately triggered this feature — redirect the popup immediately.82    chrome.runtime83      .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })84      .catch(() => {85        // Popup may be closed — the gate signal is best-effort.86      });87  } else {88    // Background trigger (e.g. passive sync initiation) — no popup is open.89    // Queue a notice so the next popup open shows a soft banner instead of90    // silently failing.91    if (chrome.storage.session) {92      chrome.storage.session93        .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })94        .catch(() => {});95    }96  }9798  return { allowed: false };99}100101// ---------------------------------------------------------------------------102// Local device name. Peers see this string in PAIR_REQUEST / PAIR_ACCEPT and103// next to the device in their paired-devices list. Default to a platform-aware104// fallback when the user hasn't set one explicitly.105const DEVICE_NAME_KEY = "peach_device_name";106107async function getLocalDeviceName(): Promise<string> {108  try {109    const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);110    const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];111    if (typeof value === "string" && value.trim()) return value.trim();112  } catch {113    // fall through to default114  }115  return "Peach";116}117118// ---------------------------------------------------------------------------119// 6-digit code pairing.120121const PAIRING_TOKEN_TTL_MS = 5 * 60 * 1000;122123124function generateSixDigitToken(): string {125  // crypto.getRandomValues, not Math.random — the token is the only secret126  // protecting the pairing window from a same-LAN attacker. 6 decimal digits127  // is 1 in 1,000,000; combined with a 5-minute TTL and pairing-mode gating128  // on the responder this is the right tradeoff between UX and security.129  const buf = new Uint32Array(1);130  crypto.getRandomValues(buf);131  const value = buf[0] % 1_000_000;132  return value.toString().padStart(6, "0");133}134135// ---------------------------------------------------------------------------136// Peer discovery glue (Phase 7 popup pairing flow).137138const PRIVATE_IPV4_RANGES = [139  { prefix: "10." },140  { prefix: "127." },141  { prefix: "169.254." },142  { prefix: "192.168." },143];144145// Empty disables network submission while the UI continues to function as a146// no-op (the handler returns ok:true without calling fetch). When set, the147// URL is validated by `isAllowedCanaryWebhookUrl` before any request, so the148// same private-IP/HTTPS rules that gate canary webhooks apply here.149const FEEDBACK_ENDPOINT = "";150151export function setupMessageHandler(): void {152  chrome.runtime.onMessage.addListener(153    (msg: unknown, sender, sendResponse) => {154      if (!isMessage(msg)) {155        sendResponse({ ok: false, error: "invalid message" });156        return true;157      }158      handleMessage(msg, sender)159        .then(sendResponse)160        .catch((err) =>161          sendResponse({162            ok: false,163            error: err instanceof Error ? err.message : String(err),164          })165        );166      return true;167    }168  );169170  // Android: the chrome shim queues sendMessage calls until this handler is171  // registered. Without this hookup, every message hangs on Capacitor builds.172  // eslint-disable-next-line @typescript-eslint/no-explicit-any173  const reg = (globalThis as any).__peachRegisterMessageHandler;174  if (typeof reg === "function") {175    reg(async (m: unknown) => {176      if (!isMessage(m)) return { ok: false, error: "invalid message" };177      try {178        return await handleMessage(m);179      } catch (err) {180        return {181          ok: false,182          error: err instanceof Error ? err.message : String(err),183        };184      }185    });186  }187188  // Android: subscribe to SyncPlugin pair-probe events. When a remote device189  // connects to the phone's WS server and validates the pairing token, auto-190  // accept and broadcast PAIRING_COMPLETED so AddDeviceView shows the success191  // screen and the paired device is persisted.192  if (isNativePlatform()) {193    const syncPlug = nativePlugin<{194      addListener: (195        event: string,196        handler: (data: Record<string, string>) => void197      ) => void;198    }>("SyncPlugin");199    syncPlug?.addListener?.("peach:pair-probe-answered", (data) => {200      const deviceId = data.connectionId ?? "unknown";201      const deviceName = data.deviceName ?? "Peach";202      void (async () => {203        const { addPairedDevice, setHasPaired: setPaired } = await import("./sync-storage");204        await addPairedDevice({205          deviceId,206          displayName: deviceName,207          x25519PublicKey: data.publicKeyHex ?? "",208          pairedAt: Date.now(),209        });210        await setPaired();211        chrome.runtime.sendMessage({212          type: "PAIRING_COMPLETED",213          payload: { deviceName },214        }).catch(() => {});215      })();216    });217  }218}219220function isMessage(m: unknown): m is Message {221  return typeof m === "object" && m !== null && "type" in m;222}223224export async function handleMessage(225  msg: Message,226  sender: Partial<chrome.runtime.MessageSender> = {}227): Promise<MessageResponse> {228  try {229    // Validate sender — only allow messages from our extension pages/scripts.230    // `sender.id` is undefined for messages originating from extension UI231    // (popup/options), which is legitimate; reject only when it is defined232    // and does not match our extension id.233    if (234      sender.id !== undefined &&235      sender.id !== chrome.runtime.id236    ) {237      return { ok: false, error: "invalid sender" };238    }239240    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {241      touchAutoLock();242    }243244    switch (msg.type) {245      case "CREATE_VAULT": {246        const { password } = msg.payload as { password: string };247        await vault.createVault(password);248        await setupAutoLock();249        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });250        return { ok: true };251      }252      case "UNLOCK": {253        const { password } = msg.payload as { password: string };254        const ok = await vault.unlock(password);255        if (ok) {256          await setupAutoLock();257          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });258          if (isNativePlatform()) await publishInMemoryVault(vault.getEntries());259        }260        return { ok };261      }262      case "VALIDATE_RECOVERY_WORDS": {263        const { words } = msg.payload as { words: string[] };264        const invalid = findInvalidWords(words);265        return { ok: true, data: { valid: invalid.length === 0, invalid } };266      }267      case "GET_RECOVERY_WORDS": {268        // Must be unlocked and have recovery set up (vault must be v2.12+).269        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };270        try {271          const words = await vault.getRecoveryWords();272          return { ok: true, data: words };273        } catch (e) {274          // pre-v2.12 vault that hasn't migrated275          return { ok: false, error: "recovery_not_setup" };276        }277      }278      case "RECOVER_WITH_WORDS": {279        // User has lost their password but has their 12 recovery words.280        // We: (1) validate the words, (2) re-encrypt vault with new password.281        const { words, newPassword } = msg.payload as {282          words: string[];283          newPassword: string;284        };285        const stored = await vault.loadSlotA();286        if (!stored) return { ok: false, error: "no vault" };287        if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {288          return { ok: false, error: "recovery_not_setup" };289        }290        // Unwrap the root key from the recovery escrow.291        try {292          await vault.unwrapRootKeyForRecovery(293            stored.recovery_wrapped_root_key,294            stored.recovery_nonce ?? "",295            stored.recovery_tag ?? "",296            stored.recovery_salt,297            words298          );299        } catch {300          return { ok: false, error: "invalid_recovery_words" };301        }302        // Re-key: derive fresh salt + key from new password.303        const newSalt = vault.generateSalt();304        const newKey = await vault.deriveKey(newPassword, newSalt);305        // Re-wrap root key under the new password's derived key so the same306        // recovery words continue to work after the password change.307        await vault.rewrapRootKeyForPassword(newKey, newSalt);308        // Re-encrypt vault entries with the new key.309        await vault.changePasswordWithNewKey(newSalt, newKey);310        await setupAutoLock();311        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });312        return { ok: true };313      }314      case "LOCK":315        vault.lock();316        if (isNativePlatform()) await clearInMemoryVault();317        return { ok: true };318      case "IS_UNLOCKED":319        return { ok: true, data: vault.isUnlocked() };320      case "GET_VAULT_STATUS":321        return { ok: true, unlocked: vault.isUnlocked() };322      case "HAS_VAULT":323        return { ok: true, data: await vault.hasVault() };324      case "GET_ENTRIES":325        return { ok: true, data: vault.getEntries() };326      case "ADD_ENTRY": {327        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };328        const entry = await vault.addEntry(msg.payload as any);329        return { ok: true, data: entry };330      }331      case "BATCH_ADD_ENTRIES": {332        const { entries } = msg.payload as { entries: any[] };333        const added = await vault.batchAddEntries(entries);334        return { ok: true, data: added };335      }336      case "RESTORE_ENTRIES": {337        const { entries, mode, settings } = msg.payload as {338          entries: any[];339          mode: "replace" | "merge";340          settings?: vault.VaultSettings;341        };342        const summary = await vault.restoreEntries(entries, mode);343        if (settings && typeof settings === "object") {344          await vault.updateSettings(settings);345        }346        return { ok: true, data: summary };347      }348      case "CLEAR_VAULT_ENTRIES": {349        await vault.clearVaultEntries("REPLACE_ALL");350        return { ok: true };351      }352      case "GET_VAULT_HISTORY": {353        const snapshots = await vault.getVaultHistory();354        return { ok: true, data: snapshots };355      }356      case "RESTORE_VAULT_SNAPSHOT": {357        const { snapshotId } = msg.payload as { snapshotId: string };358        const result = await vault.restoreVaultSnapshot(snapshotId);359        if (!result.ok) return { ok: false, error: result.error };360        broadcastToContentScripts({ type: "VAULT_LOCKED" });361        return { ok: true };362      }363      case "COUNT_DUPLICATES": {364        return { ok: true, data: vault.countDuplicates() };365      }366      case "DEDUPLICATE_ENTRIES": {367        const removed = await vault.deduplicateEntries();368        return { ok: true, data: removed };369      }370      case "UPDATE_ENTRY": {371        const { id, patch } = msg.payload as any;372        await vault.updateEntry(id, patch);373        return { ok: true };374      }375      case "ADD_ADDITIONAL_URL": {376        const { id, url } = msg.payload as { id: string; url: string };377        const trimmed = typeof url === "string" ? url.trim() : "";378        if (!trimmed) return { ok: false, error: "url required" };379        const existing = vault.getEntryById(id);380        if (!existing) return { ok: false, error: "entry not found" };381        if (existing.type !== "login") return { ok: false, error: "login entries only" };382        const merged = [...(existing.additionalUrls ?? []), trimmed];383        await vault.updateEntry(id, { additionalUrls: merged });384        return { ok: true };385      }386      case "DELETE_ENTRY": {387        const { id } = msg.payload as { id: string };388        await vault.deleteEntry(id);389        return { ok: true };390      }391      case "GET_DELETED_ENTRIES":392        return { ok: true, data: vault.getDeletedEntries() };393      case "RESTORE_ENTRY": {394        const { id } = msg.payload as { id: string };395        await vault.restoreEntry(id);396        return { ok: true };397      }398      case "PURGE_ENTRY": {399        const { id } = msg.payload as { id: string };400        await vault.purgeEntry(id);401        return { ok: true };402      }403      case "PURGE_EXPIRED_TRASH": {404        const { retentionMs } = msg.payload as { retentionMs: number };405        const purged = await vault.purgeExpiredTrash(retentionMs);406        return { ok: true, data: purged };407      }408      case "AUTOFILL_QUERY": {409        if (!vault.isUnlocked()) {410          return { ok: false, error: "VAULT_LOCKED" };411        }412        const { domain, includeAll, kind } = msg.payload as {413          domain: string;414          includeAll?: boolean;415          kind?: "login" | "card";416        };417        // kind="card": return saved cards as compact summaries for the418        // inline card silver platter. Cards are not domain-scoped — every419        // saved card is a candidate on every checkout page — so domain is420        // ignored when kind === "card".421        if (kind === "card") {422          const cards = vault.getEntries().filter((e) => e.type === "card");423          return {424            ok: true,425            data: cards.map((e) => ({426              id: e.id,427              title: e.cardNickname || e.title,428              cardholderName: e.cardholderName,429              last4: e.last4,430              expMonth: e.expMonth,431              expYear: e.expYear,432              network: e.network,433            })),434          };435        }436        // The inline selector fills username+password into a form. Passkey-437        // only entries have no password to fill; sign-in for those goes438        // through the WebAuthn path instead. Filter them out everywhere so439        // the user doesn't see four "GitHub" rows that mostly do nothing.440        // Notes and cards never belonged here either.441        const matches = (442          includeAll443            ? vault444                .getEntries()445                .filter((entry) => entry.type !== "note" && entry.type !== "card")446            : vault.queryDomain(domain)447        ).filter((entry) => !isPasskeyOnly(entry));448        return {449          ok: true,450          data: matches.map((e) =>451            includeAll452              ? {453                  id: e.id,454                  title: e.title,455                  hasPasskey: e.passkey !== undefined,456                }457              : {458                  id: e.id,459                  title: e.title,460                  username: e.username,461                  email: e.email,462                  url: e.url,463                  hasTotp: Boolean(e.totp),464                  hasPasskey: e.passkey !== undefined,465                }466          ),467        };468      }469      case "GET_ENTRY_FOR_FILL": {470        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };471        const { id } = msg.payload as { id: string };472        const entry = vault.getEntryById(id);473        if (!entry) return { ok: false, error: "entry not found" };474        // Passkey-only entries (passkey credential, no password) cannot be475        // filled into a form — sign-in goes through the WebAuthn path. A476        // login that ALSO has a passkey is still fillable for its password.477        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };478        // Strip the passkey credential from the response — content scripts479        // never need the private key, and there's no reason to expose the480        // public credential metadata over the boundary either.481        const { passkey: _strippedPasskey, ...safeEntry } = entry;482        return { ok: true, data: safeEntry };483      }484      case "COPY": {485        const { text, label, alreadyWritten } = msg.payload as {486          text: string;487          label: string;488          alreadyWritten?: boolean;489        };490        await copyToClipboard(text, label, Boolean(alreadyWritten));491        return { ok: true };492      }493      case "CHECK_ALL_BREACHES": {494        const allEntries = vault.getEntries();495        const results: Record<string, number> = {};496        for (const entry of allEntries) {497          if (entry.type === "note") continue;498          if (!entry.password) continue;499          results[entry.id] = await vault.checkBreach(entry.password);500          // Small delay — be a good citizen to the HIBP API501          await new Promise((r) => setTimeout(r, 200));502        }503        // Persist any non-zero counts back to entries504        for (const [id, count] of Object.entries(results)) {505          const entry = vault.getEntryById(id);506          if (entry && (entry.breach ?? 0) !== count) {507            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });508          }509        }510        return { ok: true, data: results };511      }512      case "GET_SETTINGS":513        return { ok: true, data: await vault.ensureSettingsLoaded() };514      case "UPDATE_SETTINGS": {515        const patch = msg.payload as Record<string, unknown>;516        await vault.updateSettings(patch as any);517        if (typeof patch.autoLockMinutes === "number") {518          await setupAutoLock();519          if (vault.isUnlocked()) touchAutoLock();520        }521        return { ok: true };522      }523      case "GET_PENDING_PASSKEY":524        return { ok: true, data: await getPendingPasskeyRequest() };525      case "APPROVE_PASSKEY": {526        const { requestId } = msg.payload as { requestId: number };527        await resolvePendingPasskeyRequest(requestId, true);528        return { ok: true };529      }530      case "DENY_PASSKEY": {531        const { requestId } = msg.payload as { requestId: number };532        await resolvePendingPasskeyRequest(requestId, false);533        return { ok: true };534      }535      case "GET_PASSKEY_PROVIDER_STATE":536        return { ok: true, data: await getPasskeyProviderState() };537      case "SET_PASSKEY_PROVIDER_ENABLED": {538        const { enabled } = msg.payload as { enabled: boolean };539        // Disabling is always free; gating fires only when a free user is540        // trying to turn passkey interception on.541        if (enabled) {542          const gate = await requirePro("passkeys");543          if (!gate.allowed) return { ok: false, error: "pro_required" };544        }545        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };546      }547      case "FIRE_CANARY_PING": {548        const { webhookUrl, entryTitle, trigger } = msg.payload as {549          webhookUrl: string;550          entryTitle: string;551          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";552        };553        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {554          return { ok: false, error: "invalid webhook url" };555        }556        const body = JSON.stringify({557          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",558          entry: entryTitle,559          trigger,560          timestamp: new Date().toISOString(),561          source: "peach-extension",562        });563        if (trigger === "test") {564          // Permitted outbound fetch: user-configured canary alert endpoint565          // (validated by isAllowedCanaryWebhookUrl above). This is the566          // only non-HIBP outbound call in the extension — see567          // Projects.md §3.7. Test pings are awaited so the UI can show568          // success/failure.569          try {570            const res = await fetch(webhookUrl, {571              method: "POST",572              headers: { "Content-Type": "application/json" },573              body,574            });575            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };576          } catch (err) {577            return { ok: false, error: err instanceof Error ? err.message : "network error" };578          }579        }580        // Non-test triggers are fire-and-forget581        fetch(webhookUrl, {582          method: "POST",583          headers: { "Content-Type": "application/json" },584          body,585        }).catch(() => {});586        return { ok: true };587      }588      case "ACTIVATE_LICENSE": {589        const { key } = msg.payload as { key: string };590        const licenseInfo = await activateLicense(key);591        return { ok: true, data: licenseInfo };592      }593      case "GET_LICENSE": {594        const licenseInfo = await getLicenseInfo();595        return { ok: true, data: licenseInfo };596      }597      case "GET_LICENSE_KEY": {598        const key = await getLicenseKey();599        return { ok: true, data: key };600      }601      case "DEACTIVATE_LICENSE": {602        await deactivateLicense();603        return { ok: true };604      }605      case "SHOULD_PLAY_CELEBRATION": {606        const should = await shouldPlayCelebration();607        return { ok: true, data: should };608      }609      case "MARK_CELEBRATION_PLAYED": {610        await markCelebrationPlayed();611        return { ok: true };612      }613      case "OPEN_PRO_GATE": {614        // Content script requests: open popup and navigate to License section.615        const { feature } = (msg.payload ?? {}) as { feature?: string };616        if (chrome.storage.session) {617          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });618        }619        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }620        return { ok: true };621      }622      case "GET_IDENTITIES": {623        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };624        return { ok: true, data: vault.getIdentities() };625      }626      case "GET_ADVANCED_HEALTH_REPORT": {627        const gate = await requirePro("advanced_health");628        if (!gate.allowed) return { ok: false, error: "pro_required" };629        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };630        const report = await buildAdvancedHealthReport(vault.getEntries());631        return { ok: true, data: report };632      }633      case "INITIATE_PASSWORD_CHANGE": {634        const gate = await requirePro("smart_password_change");635        if (!gate.allowed) return { ok: false, error: "pro_required" };636        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };637        const { id } = msg.payload as { id: string };638        const entry = vault.getEntryById(id);639        if (!entry) return { ok: false, error: "entry not found" };640        const url = resolveResetUrl(entry);641        if (!url) return { ok: false, error: "no reset url" };642        try {643          await chrome.tabs.create({ url });644          return { ok: true, data: { url } };645        } catch (err) {646          return {647            ok: false,648            error: err instanceof Error ? err.message : "failed to open tab",649          };650        }651      }652      case "IS_DURESS_ENABLED": {653        const enabled = await vault.isDuressEnabled();654        return { ok: true, data: enabled };655      }656      case "SETUP_DURESS": {657        const { duressPassword } = msg.payload as { duressPassword: string };658        await vault.setupDuress(duressPassword);659        return { ok: true };660      }661      case "DISABLE_DURESS": {662        await vault.disableDuress();663        return { ok: true };664      }665      case "VERIFY_MAIN_PASSWORD": {666        const { password } = msg.payload as { password: string };667        const ok = await vault.verifyMainPassword(password);668        return { ok: true, data: ok };669      }670      case "CHANGE_PASSWORD": {671        const { currentPassword, newPassword } = msg.payload as {672          currentPassword: string;673          newPassword: string;674        };675        const changed = await vault.changePassword(currentPassword, newPassword);676        return changed677          ? { ok: true }678          : { ok: false, error: "incorrect current password" };679      }680      case "GET_SITE_PASSWORD_POLICY": {681        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });682        if (!tab?.id) return { ok: true, data: null };683        try {684          const response = await chrome.tabs.sendMessage(tab.id, {685            type: "GET_SITE_PASSWORD_POLICY",686          });687          return (response as MessageResponse) ?? { ok: true, data: null };688        } catch {689          return { ok: true, data: null };690        }691      }692      case "GET_SYNC_STATE": {693        const pairedDevices = await getPairedDevices();694        const trustedNetworks = await getTrustedNetworks();695        const tailscale = await isOnTailscale();696        const pro = await isProUser();697698        let status: SyncStatus = "offline";699        if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {700          if (pairedDevices.length === 0) {701            status = "no_peers";702          } else {703            status = "synced";704          }705        } else {706          status = "not_trusted";707        }708709        const lastSyncRaw = await storageGet("peach_last_sync_at");710        const lastSyncParsed = lastSyncRaw ? Number.parseInt(lastSyncRaw, 10) : NaN;711        const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : undefined;712713        return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };714      }715      case "GET_PAIRED_DEVICES": {716        const devices = await getPairedDevices();717        return { ok: true, data: { devices } };718      }719      case "GET_SYNC_STATUS": {720        const lastSyncRaw = await storageGet("peach_last_sync_at");721        const lastSyncParsed = lastSyncRaw ? parseInt(lastSyncRaw, 10) : NaN;722        const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : null;723        const status: 'up_to_date' | 'never' = lastSyncAt ? 'up_to_date' : 'never';724        return { ok: true, data: { status, lastSyncAt } };725      }726      case "ACCEPT_PAIRING": {727        // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.728        return { ok: true };729      }730      case "REMOVE_PAIRED_DEVICE": {731        const { deviceId } = msg.payload as { deviceId: string };732        await removePairedDevice(deviceId);733        return { ok: true };734      }735      case "REMOVE_TRUSTED_NETWORK": {736        const { ssid, bssid, isTailscale } = msg.payload as any;737        const networks = await getTrustedNetworks();738        const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 739          (isTailscale ? n.isTailscale : n.ssid === ssid) && 740          (!bssid || n.bssid === bssid)741        );742        if (network) {743          await removeTrustedNetwork(network.addedAt);744        }745        return { ok: true };746      }747      case "MARK_NETWORK_TRUSTED": {748        const network = await getCurrentNetwork();749        await addTrustedNetwork({750          ssid: network.ssid,751          bssid: network.bssid,752          isTailscale: network.isTailscale,753          addedAt: Date.now(),754        });755        return { ok: true };756      }757      case "GET_INCOMING_PAIRING_REQUEST": {758        const request = await getIncomingPairingRequest();759        return { ok: true, data: request };760      }761      case "RESPOND_INCOMING_PAIRING": {762        const { accept } = msg.payload as { accept: boolean };763        const request = await getIncomingPairingRequest();764        if (!request) return { ok: false, error: "no_pending_request" };765        if (Date.now() > request.expiresAt) {766          await clearIncomingPairingRequest();767          return { ok: false, error: "expired" };768        }769        await clearIncomingPairingRequest();770        if (accept) {771          await addPairedDevice({772            deviceId: request.publicKeyHex,773            displayName: request.deviceName,774            x25519PublicKey: request.publicKeyHex,775            pairedAt: Date.now(),776            lastSeenAt: Date.now(),777          });778        }779        return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };780      }781      case "START_PAIRING_SERVER": {782        // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.783        const token = generateSixDigitToken();784        const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;785        await setActivePairingToken({ token, expiresAt });786        return { ok: true, data: { token, expiresAt } };787      }788      case "STOP_PAIRING_SERVER": {789        try {790          await setActivePairingToken(null);791        } catch { /* best-effort */ }792        return { ok: true };793      }794      case "GET_RECENT_PAIRING": {795        const recent = await consumeRecentPairing();796        return { ok: true, data: recent };797      }798      case "DEBUG_INJECT_INCOMING_PAIRING": {799        // Manual E2E rehearsal hook. Inserts a synthetic incoming pairing800        // request into session storage so Phase 7 banner UX can be exercised801        // without a live native host. No Pro gate — this is a no-op in802        // production unless explicitly invoked from a Settings debug action.803        const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {804          deviceName?: string;805          publicKeyHex?: string;806        };807        if (!deviceName || !publicKeyHex) {808          return { ok: false, error: "missing_payload" };809        }810        const now = Date.now();811        await setIncomingPairingRequest({812          deviceName,813          publicKeyHex,814          receivedAt: now,815          expiresAt: now + 60_000,816        });817        return { ok: true };818      }819      case "RESET_SYNC_IDENTITY": {820        const gate = await requirePro("sync");821        if (!gate.allowed) return { ok: false, error: "pro_required" };822        const devices = await getPairedDevices();823        for (const device of devices) {824          await removePairedDevice(device.deviceId);825        }826        return { ok: true, data: { removedDevices: devices.length, nativePublicKey: null } };827      }828      case "RECORD_MANUAL_SUMMON": {829        const { domain } = msg.payload as { domain: string };830        const settings = vault.getSettings();831        const existing = settings.manualSummonDomains?.[domain];832        const now = Date.now();833        await vault.updateSettings({834          manualSummonDomains: {835            ...settings.manualSummonDomains,836            [domain]: {837              firstSummonedAt: existing?.firstSummonedAt ?? now,838              summonCount: (existing?.summonCount ?? 0) + 1,839              lastSummonedAt: now,840            },841          },842        });843        return { ok: true };844      }845      case "DECLINE_CARD_SAVE": {846        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };847        await vault.declineCardSaveOffer(last4, expMonth, expYear);848        return { ok: true };849      }850      case "IS_CARD_DECLINED": {851        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };852        const declined = await vault.isCardDeclined(last4, expMonth, expYear);853        return { ok: true, data: declined };854      }855      case "INITIATE_FIREFOX_PASSKEY_CREATE": {856        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {857          rpId?: string;858          rpName?: string;859          userName?: string;860          userDisplayName?: string;861        };862        if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };863        return await initiateFirefoxPasskeyCreate({864          rpId,865          rpName: rpName ?? rpId,866          userName,867          userDisplayName,868        });869      }870871      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {872        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;873        if (!payload) return { ok: false, error: "missing payload" };874        return await completeFirefoxPasskeyCreate(payload);875      }876877      case "PASSKEY_OFFER_DECLINE": {878        const { domain } = (msg.payload ?? {}) as { domain?: string };879        if (!domain) return { ok: false, error: "domain required" };880        await recordPasskeyOfferDecline(domain);881        return { ok: true };882      }883884      case "GET_PASSKEY_OFFER_DECLINED": {885        const { domain } = (msg.payload ?? {}) as { domain?: string };886        if (!domain) return { ok: false, error: "domain required" };887        return { ok: true, data: await isPasskeyOfferDeclined(domain) };888      }889890      case "PASSKEY_HAS_CREDENTIALS": {891        const payload = msg.payload as {892          rpId?: string;893          allowCredentials?: Array<{ id: string; type?: string }>;894        } | undefined;895        if (!payload?.rpId) return { ok: false, error: "rpId required" };896        return await handlePasskeyHasCredentials({897          rpId: payload.rpId,898          allowCredentials: payload.allowCredentials ?? [],899        });900      }901902      case "PASSKEY_CREATE": {903        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;904        if (!payload?.rpId || !payload?.userName) {905          return { ok: false, error: "rpId and userName required" };906        }907        return await handleFirefoxPasskeyCreate(payload);908      }909910      case "PASSKEY_GET": {911        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;912        if (!payload?.rpId) return { ok: false, error: "rpId required" };913        return await handleFirefoxPasskeyGet(payload);914      }915916      case "GET_PASSKEY_FOR_DOMAIN": {917        const { domain } = (msg.payload ?? {}) as { domain?: string };918        if (!domain) return { ok: false, error: "domain required" };919        if (!vault.isUnlocked()) return { ok: true, data: null };920        const match = vault921          .getEntries()922          .find((entry) => entry.passkey?.rpId === domain);923        if (!match || !match.passkey) return { ok: true, data: null };924        // Slim shape — only the fields State 11 needs. Never expose private925        // key material, even though this is locked behind isUnlocked.926        return {927          ok: true,928          data: {929            rpId: match.passkey.rpId,930            userName: match.passkey.userName,931            browser: match.passkey.browser,932          },933        };934      }935936      case "DESTROY_VAULT": {937        // Irreversible by design. Lock zeroes the derived key + in-memory entries;938        // storage.local.clear() wipes every Peach key (slots, settings, license,939        // onboarded flag, history, sync state, declined cards, recall, etc.);940        // session.clear() wipes pending passkey requests + pro-notice queues.941        // Caller (Panic.tsx) handles UI navigation back to onboarding.942        vault.lock();943        await storageClear();944        await vault.clearVaultHistory();945        if (chrome.storage.session) {946          await chrome.storage.session.clear();947        }948        return { ok: true };949      }950951      case "SUBMIT_FEEDBACK": {952        const { category, message, version, browser } = msg.payload as {953          category: string;954          message: string;955          version: string;956          browser: string;957        };958        if (!message.trim()) return { ok: false, error: "message is empty" };959        if (!FEEDBACK_ENDPOINT) return { ok: true };960        if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {961          return { ok: false, error: "invalid feedback endpoint" };962        }963        try {964          const res = await fetch(FEEDBACK_ENDPOINT, {965            method: "POST",966            headers: { "Content-Type": "application/json" },967            body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),968          });969          return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };970        } catch (err) {971          return { ok: false, error: err instanceof Error ? err.message : "network error" };972        }973      }974      case "TRIGGER_SYNC": {975        const gate = await requirePro("sync");976        if (!gate.allowed) return { ok: false, error: "pro_required" };977        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };978        if (isNativePlatform()) {979          syncBridge()?.ensureServerRunning?.();980          return { ok: true, data: { syncedCount: 0, mode: "server" as const } };981        }982        // Desktop has no outbound sync path — paired devices are mobile, and983        // the mobile side initiates. From desktop, "trigger sync" just means984        // "make sure the pairing server is reachable", which is handled by985        // START_PAIRING_SERVER. Surface no_paired_devices to keep callers tidy.986        return { ok: false, error: "no_paired_devices" };987      }988      // Android-only: biometric unlock with pre-derived key.989      case "UNLOCK_WITH_KEY": {990        const { masterKeyHex } = msg.payload as { masterKeyHex: string };991        if (!isNativePlatform()) {992          return { ok: false, error: "desktop_only_uses_password" };993        }994        try {995          const result = await vault.unlockWithKey(masterKeyHex);996          if (result.ok) await publishInMemoryVault(result.entries);997          return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };998        } catch {999          return { ok: false, error: "invalid_key" };1000        }1001      }1002      // Android: returns current in-memory derived key as hex. Used by biometric enable flow.1003      case "GET_DERIVED_KEY": {1004        if (!isNativePlatform()) {1005          return { ok: false, error: "android_only" };1006        }1007        if (!vault.isUnlocked()) {1008          return { ok: false, error: "vault_locked" };1009        }1010        return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };1011      }1012      // Android: scanned a QR containing a desktop pairing token.1013      case "SCAN_PAIRING_QR": {1014        if (!isNativePlatform()) {1015          return { ok: false, error: "android_only" };1016        }1017        const { token } = msg.payload as { token: string };1018        syncBridge()?.startPairingSession?.({ token });1019        return { ok: true };1020      }1021      // Android: returns the phone's WiFi IP address.1022      case "GET_LOCAL_IP": {1023        if (!isNativePlatform()) {1024          return { ok: false, error: "android_only" };1025        }1026        const { ip } = (await syncBridge()?.getLocalIp?.()) ?? { ip: "" };1027        return { ok: true, data: { ip } };1028      }1029      case "GET_IDENTITY_SUGGESTIONS": {1030        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1031        const entries = vault.getEntries();1032        const suggestions = buildIdentitySuggestions(entries);1033        return { ok: true, data: { suggestions } };1034      }1035      case "POPULATE_INLINE_VAULT": {1036        if (!isNativePlatform()) return { ok: false };1037        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1038        const entries = vault.getEntries();1039        await publishInMemoryVault(entries);1040        return { ok: true };1041      }1042      default:1043        return { ok: false, error: "unknown message type" };1044    }1045  } catch (err) {1046    return {1047      ok: false,1048      error: err instanceof Error ? err.message : "operation failed",1049    };1050  }1051}10521053// --- Mobile platform helpers ---10541055// Android: populates the in-memory vault used by PeachAutofillService.1056async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {1057  const plugin = nativePlugin<{1058    setEntries?: (args: {1059      entries: Array<{1060        id: string;1061        title: string;1062        username: string;1063        email: string;1064        password: string;1065        url: string;1066        totp: string;1067        additionalUrls: string[];1068      }>;1069    }) => Promise<void>;1070  }>("InMemoryVaultPlugin");1071  if (!plugin?.setEntries) return;1072  await plugin.setEntries({1073    entries: entries.map((e) => ({1074      id: e.id,1075      title: e.title ?? "",1076      username: e.username ?? "",1077      email: (e as VaultEntry & { email?: string }).email ?? "",1078      password: e.password ?? "",1079      url: e.url ?? "",1080      totp: (e as VaultEntry & { totp?: string }).totp ?? "",1081      additionalUrls: (e as VaultEntry & { additionalUrls?: string[] }).additionalUrls ?? [],1082    })),1083  });1084}10851086// Android: clear the autofill in-memory store on lock.1087async function clearInMemoryVault(): Promise<void> {1088  const plugin = nativePlugin<{ clearEntries?: () => Promise<void> }>("InMemoryVaultPlugin");1089  if (!plugin?.clearEntries) return;1090  await plugin.clearEntries();1091}10921093interface SyncBridge {1094  ensureServerRunning?: () => void;1095  startPairingSession?: (args: { token: string }) => void;1096  getLocalIp?: () => Promise<{ ip: string }>;1097}10981099// Resolve the Capacitor SyncPlugin at call time — module-load resolution1100// races with Capacitor's plugin registration on the Android WebView.1101function syncBridge(): SyncBridge | null {1102  return nativePlugin<SyncBridge>("SyncPlugin");1103}11041105// Returns username use-counts from the vault, sorted by frequency. Each row1106// also carries the entry's email so the popup can suggest the email/username1107// pair the user has actually used together.1108function buildIdentitySuggestions(1109  entries: VaultEntry[],1110): Array<{ email: string; username: string; count: number }> {1111  const usernameCounts = new Map<string, number>();1112  const usernameEmail = new Map<string, string>();1113  for (const entry of entries) {1114    const username = entry.username ?? "";1115    const email = (entry as VaultEntry & { email?: string }).email ?? "";1116    if (!username && !email) continue;1117    const key = username || email;1118    usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1119    if (!usernameEmail.has(key)) usernameEmail.set(key, email);1120  }1121  return Array.from(usernameCounts.entries())1122    .sort((a, b) => b[1] - a[1])1123    .map(([username, count]) => ({1124      email: usernameEmail.get(username) ?? "",1125      username,1126      count,1127    }));1128}11291130/** Notify all content scripts that the vault just became available. */1131function broadcastToContentScripts(message: { type: string }): void {1132  if (!chrome.tabs?.query) return;1133  chrome.tabs.query({}, (tabs) => {1134    for (const tab of tabs) {1135      if (tab.id !== undefined) {1136        chrome.tabs.sendMessage(tab.id, message).catch(() => {1137          // Tab may not have a content script — silently ignore1138        });1139      }1140    }1141  });1142}11431144function shouldRefreshAutoLock(type: MessageType): boolean {1145  return (1146    type !== "HAS_VAULT" &&1147    type !== "IS_UNLOCKED" &&1148    type !== "GET_VAULT_STATUS" &&1149    type !== "LOCK" &&1150    type !== "UPDATE_SETTINGS" &&1151    type !== "GET_PENDING_PASSKEY" &&1152    type !== "GET_PASSKEY_PROVIDER_STATE" &&1153    type !== "DECLINE_CARD_SAVE" &&1154    type !== "IS_CARD_DECLINED" &&1155    type !== "SUBMIT_FEEDBACK"1156  );1157}11581159function isAllowedCanaryWebhookUrl(value: string): boolean {1160  try {1161    const url = new URL(value);1162    if (url.protocol !== "https:") return false;11631164    const hostname = url.hostname.toLowerCase();1165    if (1166      hostname === "localhost" ||1167      hostname.endsWith(".localhost") ||1168      isPrivateIpv4(hostname) ||1169      isPrivateIpv6(hostname)1170    ) {1171      return false;1172    }11731174    return true;1175  } catch {1176    return false;1177  }1178}11791180function isPrivateIpv4(hostname: string): boolean {1181  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1182    return true;1183  }11841185  const octets = hostname.split(".").map((part) => Number(part));1186  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1187    return false;1188  }11891190  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1191}11921193function isPrivateIpv6(hostname: string): boolean {1194  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1195  return (1196    normalized === "::1" ||1197    normalized.startsWith("fc") ||1198    normalized.startsWith("fd") ||1199    normalized.startsWith("fe80:")1200  );1201}