messages.ts

43.9 KB · raw

1/**2 * messages.ts — the only background message handler.3 *4 * All `chrome.runtime.onMessage` handling for the service worker lives here.5 * Do not handle messages inline in entry.ts, alarms.ts, or any other6 * background file. Every background-bound MessageType in shared/types.ts7 * must have a case below. Content-script-bound message types (PING,8 * SUMMON_AUTOFILL, VAULT_LOCKED) are documented in shared/types.ts and9 * intentionally have no case here — they are sent *to* content scripts.10 */1112import { copyToClipboard } from "./clipboard";13import { setupAutoLock, touchAutoLock } from "./alarms";14import * as vault from "./vault";15import {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  }187}188189function isMessage(m: unknown): m is Message {190  return typeof m === "object" && m !== null && "type" in m;191}192193export async function handleMessage(194  msg: Message,195  sender: Partial<chrome.runtime.MessageSender> = {}196): Promise<MessageResponse> {197  try {198    // Validate sender — only allow messages from our extension pages/scripts.199    // `sender.id` is undefined for messages originating from extension UI200    // (popup/options), which is legitimate; reject only when it is defined201    // and does not match our extension id.202    if (203      sender.id !== undefined &&204      sender.id !== chrome.runtime.id205    ) {206      return { ok: false, error: "invalid sender" };207    }208209    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {210      touchAutoLock();211    }212213    switch (msg.type) {214      case "CREATE_VAULT": {215        const { password } = msg.payload as { password: string };216        await vault.createVault(password);217        await setupAutoLock();218        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });219        return { ok: true };220      }221      case "UNLOCK": {222        const { password } = msg.payload as { password: string };223        const ok = await vault.unlock(password);224        if (ok) {225          await setupAutoLock();226          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });227          if (isNativePlatform()) await publishInMemoryVault(vault.getEntries());228        }229        return { ok };230      }231      case "VALIDATE_RECOVERY_WORDS": {232        const { words } = msg.payload as { words: string[] };233        const invalid = findInvalidWords(words);234        return { ok: true, data: { valid: invalid.length === 0, invalid } };235      }236      case "GET_RECOVERY_WORDS": {237        // Must be unlocked and have recovery set up (vault must be v2.12+).238        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };239        try {240          const words = await vault.getRecoveryWords();241          return { ok: true, data: words };242        } catch (e) {243          // pre-v2.12 vault that hasn't migrated244          return { ok: false, error: "recovery_not_setup" };245        }246      }247      case "RECOVER_WITH_WORDS": {248        // User has lost their password but has their 12 recovery words.249        // We: (1) validate the words, (2) re-encrypt vault with new password.250        const { words, newPassword } = msg.payload as {251          words: string[];252          newPassword: string;253        };254        const stored = await vault.loadSlotA();255        if (!stored) return { ok: false, error: "no vault" };256        if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {257          return { ok: false, error: "recovery_not_setup" };258        }259        // Unwrap the root key from the recovery escrow.260        try {261          await vault.unwrapRootKeyForRecovery(262            stored.recovery_wrapped_root_key,263            stored.recovery_nonce ?? "",264            stored.recovery_tag ?? "",265            stored.recovery_salt,266            words267          );268        } catch {269          return { ok: false, error: "invalid_recovery_words" };270        }271        // Re-key: derive fresh salt + key from new password.272        const newSalt = vault.generateSalt();273        const newKey = await vault.deriveKey(newPassword, newSalt);274        // Re-wrap root key under the new password's derived key so the same275        // recovery words continue to work after the password change.276        await vault.rewrapRootKeyForPassword(newKey, newSalt);277        // Re-encrypt vault entries with the new key.278        await vault.changePasswordWithNewKey(newSalt, newKey);279        await setupAutoLock();280        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });281        return { ok: true };282      }283      case "LOCK":284        vault.lock();285        if (isNativePlatform()) await clearInMemoryVault();286        return { ok: true };287      case "IS_UNLOCKED":288        return { ok: true, data: vault.isUnlocked() };289      case "GET_VAULT_STATUS":290        return { ok: true, unlocked: vault.isUnlocked() };291      case "HAS_VAULT":292        return { ok: true, data: await vault.hasVault() };293      case "GET_ENTRIES":294        return { ok: true, data: vault.getEntries() };295      case "ADD_ENTRY": {296        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };297        const entry = await vault.addEntry(msg.payload as any);298        return { ok: true, data: entry };299      }300      case "BATCH_ADD_ENTRIES": {301        const { entries } = msg.payload as { entries: any[] };302        const added = await vault.batchAddEntries(entries);303        return { ok: true, data: added };304      }305      case "RESTORE_ENTRIES": {306        const { entries, mode, settings } = msg.payload as {307          entries: any[];308          mode: "replace" | "merge";309          settings?: vault.VaultSettings;310        };311        const summary = await vault.restoreEntries(entries, mode);312        if (settings && typeof settings === "object") {313          await vault.updateSettings(settings);314        }315        return { ok: true, data: summary };316      }317      case "CLEAR_VAULT_ENTRIES": {318        await vault.clearVaultEntries("REPLACE_ALL");319        return { ok: true };320      }321      case "GET_VAULT_HISTORY": {322        const snapshots = await vault.getVaultHistory();323        return { ok: true, data: snapshots };324      }325      case "RESTORE_VAULT_SNAPSHOT": {326        const { snapshotId } = msg.payload as { snapshotId: string };327        const result = await vault.restoreVaultSnapshot(snapshotId);328        if (!result.ok) return { ok: false, error: result.error };329        broadcastToContentScripts({ type: "VAULT_LOCKED" });330        return { ok: true };331      }332      case "COUNT_DUPLICATES": {333        return { ok: true, data: vault.countDuplicates() };334      }335      case "DEDUPLICATE_ENTRIES": {336        const removed = await vault.deduplicateEntries();337        return { ok: true, data: removed };338      }339      case "UPDATE_ENTRY": {340        const { id, patch } = msg.payload as any;341        await vault.updateEntry(id, patch);342        return { ok: true };343      }344      case "ADD_ADDITIONAL_URL": {345        const { id, url } = msg.payload as { id: string; url: string };346        const trimmed = typeof url === "string" ? url.trim() : "";347        if (!trimmed) return { ok: false, error: "url required" };348        const existing = vault.getEntryById(id);349        if (!existing) return { ok: false, error: "entry not found" };350        if (existing.type !== "login") return { ok: false, error: "login entries only" };351        const merged = [...(existing.additionalUrls ?? []), trimmed];352        await vault.updateEntry(id, { additionalUrls: merged });353        return { ok: true };354      }355      case "DELETE_ENTRY": {356        const { id } = msg.payload as { id: string };357        await vault.deleteEntry(id);358        return { ok: true };359      }360      case "GET_DELETED_ENTRIES":361        return { ok: true, data: vault.getDeletedEntries() };362      case "RESTORE_ENTRY": {363        const { id } = msg.payload as { id: string };364        await vault.restoreEntry(id);365        return { ok: true };366      }367      case "PURGE_ENTRY": {368        const { id } = msg.payload as { id: string };369        await vault.purgeEntry(id);370        return { ok: true };371      }372      case "PURGE_EXPIRED_TRASH": {373        const { retentionMs } = msg.payload as { retentionMs: number };374        const purged = await vault.purgeExpiredTrash(retentionMs);375        return { ok: true, data: purged };376      }377      case "AUTOFILL_QUERY": {378        if (!vault.isUnlocked()) {379          return { ok: false, error: "VAULT_LOCKED" };380        }381        const { domain, includeAll, kind } = msg.payload as {382          domain: string;383          includeAll?: boolean;384          kind?: "login" | "card";385        };386        // kind="card": return saved cards as compact summaries for the387        // inline card silver platter. Cards are not domain-scoped — every388        // saved card is a candidate on every checkout page — so domain is389        // ignored when kind === "card".390        if (kind === "card") {391          const cards = vault.getEntries().filter((e) => e.type === "card");392          return {393            ok: true,394            data: cards.map((e) => ({395              id: e.id,396              title: e.cardNickname || e.title,397              cardholderName: e.cardholderName,398              last4: e.last4,399              expMonth: e.expMonth,400              expYear: e.expYear,401              network: e.network,402            })),403          };404        }405        // The inline selector fills username+password into a form. Passkey-406        // only entries have no password to fill; sign-in for those goes407        // through the WebAuthn path instead. Filter them out everywhere so408        // the user doesn't see four "GitHub" rows that mostly do nothing.409        // Notes and cards never belonged here either.410        const matches = (411          includeAll412            ? vault413                .getEntries()414                .filter((entry) => entry.type !== "note" && entry.type !== "card")415            : vault.queryDomain(domain)416        ).filter((entry) => !isPasskeyOnly(entry));417        return {418          ok: true,419          data: matches.map((e) =>420            includeAll421              ? {422                  id: e.id,423                  title: e.title,424                  hasPasskey: e.passkey !== undefined,425                }426              : {427                  id: e.id,428                  title: e.title,429                  username: e.username,430                  email: e.email,431                  url: e.url,432                  hasTotp: Boolean(e.totp),433                  hasPasskey: e.passkey !== undefined,434                }435          ),436        };437      }438      case "GET_ENTRY_FOR_FILL": {439        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };440        const { id } = msg.payload as { id: string };441        const entry = vault.getEntryById(id);442        if (!entry) return { ok: false, error: "entry not found" };443        // Passkey-only entries (passkey credential, no password) cannot be444        // filled into a form — sign-in goes through the WebAuthn path. A445        // login that ALSO has a passkey is still fillable for its password.446        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };447        // Strip the passkey credential from the response — content scripts448        // never need the private key, and there's no reason to expose the449        // public credential metadata over the boundary either.450        const { passkey: _strippedPasskey, ...safeEntry } = entry;451        return { ok: true, data: safeEntry };452      }453      case "COPY": {454        const { text, label, alreadyWritten } = msg.payload as {455          text: string;456          label: string;457          alreadyWritten?: boolean;458        };459        await copyToClipboard(text, label, Boolean(alreadyWritten));460        return { ok: true };461      }462      case "CHECK_ALL_BREACHES": {463        const allEntries = vault.getEntries();464        const results: Record<string, number> = {};465        for (const entry of allEntries) {466          if (entry.type === "note") continue;467          if (!entry.password) continue;468          results[entry.id] = await vault.checkBreach(entry.password);469          // Small delay — be a good citizen to the HIBP API470          await new Promise((r) => setTimeout(r, 200));471        }472        // Persist any non-zero counts back to entries473        for (const [id, count] of Object.entries(results)) {474          const entry = vault.getEntryById(id);475          if (entry && (entry.breach ?? 0) !== count) {476            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });477          }478        }479        return { ok: true, data: results };480      }481      case "GET_SETTINGS":482        return { ok: true, data: await vault.ensureSettingsLoaded() };483      case "UPDATE_SETTINGS": {484        const patch = msg.payload as Record<string, unknown>;485        await vault.updateSettings(patch as any);486        if (typeof patch.autoLockMinutes === "number") {487          await setupAutoLock();488          if (vault.isUnlocked()) touchAutoLock();489        }490        return { ok: true };491      }492      case "GET_PENDING_PASSKEY":493        return { ok: true, data: await getPendingPasskeyRequest() };494      case "APPROVE_PASSKEY": {495        const { requestId } = msg.payload as { requestId: number };496        await resolvePendingPasskeyRequest(requestId, true);497        return { ok: true };498      }499      case "DENY_PASSKEY": {500        const { requestId } = msg.payload as { requestId: number };501        await resolvePendingPasskeyRequest(requestId, false);502        return { ok: true };503      }504      case "GET_PASSKEY_PROVIDER_STATE":505        return { ok: true, data: await getPasskeyProviderState() };506      case "SET_PASSKEY_PROVIDER_ENABLED": {507        const { enabled } = msg.payload as { enabled: boolean };508        // Disabling is always free; gating fires only when a free user is509        // trying to turn passkey interception on.510        if (enabled) {511          const gate = await requirePro("passkeys");512          if (!gate.allowed) return { ok: false, error: "pro_required" };513        }514        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };515      }516      case "FIRE_CANARY_PING": {517        const { webhookUrl, entryTitle, trigger } = msg.payload as {518          webhookUrl: string;519          entryTitle: string;520          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";521        };522        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {523          return { ok: false, error: "invalid webhook url" };524        }525        const body = JSON.stringify({526          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",527          entry: entryTitle,528          trigger,529          timestamp: new Date().toISOString(),530          source: "peach-extension",531        });532        if (trigger === "test") {533          // Permitted outbound fetch: user-configured canary alert endpoint534          // (validated by isAllowedCanaryWebhookUrl above). This is the535          // only non-HIBP outbound call in the extension — see536          // Projects.md §3.7. Test pings are awaited so the UI can show537          // success/failure.538          try {539            const res = await fetch(webhookUrl, {540              method: "POST",541              headers: { "Content-Type": "application/json" },542              body,543            });544            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };545          } catch (err) {546            return { ok: false, error: err instanceof Error ? err.message : "network error" };547          }548        }549        // Non-test triggers are fire-and-forget550        fetch(webhookUrl, {551          method: "POST",552          headers: { "Content-Type": "application/json" },553          body,554        }).catch(() => {});555        return { ok: true };556      }557      case "ACTIVATE_LICENSE": {558        const { key } = msg.payload as { key: string };559        const licenseInfo = await activateLicense(key);560        return { ok: true, data: licenseInfo };561      }562      case "GET_LICENSE": {563        const licenseInfo = await getLicenseInfo();564        return { ok: true, data: licenseInfo };565      }566      case "GET_LICENSE_KEY": {567        const key = await getLicenseKey();568        return { ok: true, data: key };569      }570      case "DEACTIVATE_LICENSE": {571        await deactivateLicense();572        return { ok: true };573      }574      case "SHOULD_PLAY_CELEBRATION": {575        const should = await shouldPlayCelebration();576        return { ok: true, data: should };577      }578      case "MARK_CELEBRATION_PLAYED": {579        await markCelebrationPlayed();580        return { ok: true };581      }582      case "OPEN_PRO_GATE": {583        // Content script requests: open popup and navigate to License section.584        const { feature } = (msg.payload ?? {}) as { feature?: string };585        if (chrome.storage.session) {586          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });587        }588        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }589        return { ok: true };590      }591      case "GET_IDENTITIES": {592        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };593        return { ok: true, data: vault.getIdentities() };594      }595      case "GET_ADVANCED_HEALTH_REPORT": {596        const gate = await requirePro("advanced_health");597        if (!gate.allowed) return { ok: false, error: "pro_required" };598        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };599        const report = await buildAdvancedHealthReport(vault.getEntries());600        return { ok: true, data: report };601      }602      case "INITIATE_PASSWORD_CHANGE": {603        const gate = await requirePro("smart_password_change");604        if (!gate.allowed) return { ok: false, error: "pro_required" };605        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };606        const { id } = msg.payload as { id: string };607        const entry = vault.getEntryById(id);608        if (!entry) return { ok: false, error: "entry not found" };609        const url = resolveResetUrl(entry);610        if (!url) return { ok: false, error: "no reset url" };611        try {612          await chrome.tabs.create({ url });613          return { ok: true, data: { url } };614        } catch (err) {615          return {616            ok: false,617            error: err instanceof Error ? err.message : "failed to open tab",618          };619        }620      }621      case "IS_DURESS_ENABLED": {622        const enabled = await vault.isDuressEnabled();623        return { ok: true, data: enabled };624      }625      case "SETUP_DURESS": {626        const { duressPassword } = msg.payload as { duressPassword: string };627        await vault.setupDuress(duressPassword);628        return { ok: true };629      }630      case "DISABLE_DURESS": {631        await vault.disableDuress();632        return { ok: true };633      }634      case "VERIFY_MAIN_PASSWORD": {635        const { password } = msg.payload as { password: string };636        const ok = await vault.verifyMainPassword(password);637        return { ok: true, data: ok };638      }639      case "CHANGE_PASSWORD": {640        const { currentPassword, newPassword } = msg.payload as {641          currentPassword: string;642          newPassword: string;643        };644        const changed = await vault.changePassword(currentPassword, newPassword);645        return changed646          ? { ok: true }647          : { ok: false, error: "incorrect current password" };648      }649      case "GET_SITE_PASSWORD_POLICY": {650        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });651        if (!tab?.id) return { ok: true, data: null };652        try {653          const response = await chrome.tabs.sendMessage(tab.id, {654            type: "GET_SITE_PASSWORD_POLICY",655          });656          return (response as MessageResponse) ?? { ok: true, data: null };657        } catch {658          return { ok: true, data: null };659        }660      }661      case "GET_SYNC_STATE": {662        const pairedDevices = await getPairedDevices();663        const trustedNetworks = await getTrustedNetworks();664        const tailscale = await isOnTailscale();665        const pro = await isProUser();666667        let status: SyncStatus = "offline";668        if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {669          if (pairedDevices.length === 0) {670            status = "no_peers";671          } else {672            status = "synced";673          }674        } else {675          status = "not_trusted";676        }677678        const lastSyncRaw = await storageGet("peach_last_sync_at");679        const lastSyncParsed = lastSyncRaw ? Number.parseInt(lastSyncRaw, 10) : NaN;680        const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : undefined;681682        return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };683      }684      case "GET_PAIRED_DEVICES": {685        const devices = await getPairedDevices();686        return { ok: true, data: { devices } };687      }688      case "GET_SYNC_STATUS": {689        const lastSyncRaw = await storageGet("peach_last_sync_at");690        const lastSyncParsed = lastSyncRaw ? parseInt(lastSyncRaw, 10) : NaN;691        const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : null;692        const status: 'up_to_date' | 'never' = lastSyncAt ? 'up_to_date' : 'never';693        return { ok: true, data: { status, lastSyncAt } };694      }695      case "ACCEPT_PAIRING": {696        // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.697        return { ok: true };698      }699      case "REMOVE_PAIRED_DEVICE": {700        const { deviceId } = msg.payload as { deviceId: string };701        await removePairedDevice(deviceId);702        return { ok: true };703      }704      case "REMOVE_TRUSTED_NETWORK": {705        const { ssid, bssid, isTailscale } = msg.payload as any;706        const networks = await getTrustedNetworks();707        const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 708          (isTailscale ? n.isTailscale : n.ssid === ssid) && 709          (!bssid || n.bssid === bssid)710        );711        if (network) {712          await removeTrustedNetwork(network.addedAt);713        }714        return { ok: true };715      }716      case "MARK_NETWORK_TRUSTED": {717        const network = await getCurrentNetwork();718        await addTrustedNetwork({719          ssid: network.ssid,720          bssid: network.bssid,721          isTailscale: network.isTailscale,722          addedAt: Date.now(),723        });724        return { ok: true };725      }726      case "GET_INCOMING_PAIRING_REQUEST": {727        const request = await getIncomingPairingRequest();728        return { ok: true, data: request };729      }730      case "RESPOND_INCOMING_PAIRING": {731        const { accept } = msg.payload as { accept: boolean };732        const request = await getIncomingPairingRequest();733        if (!request) return { ok: false, error: "no_pending_request" };734        if (Date.now() > request.expiresAt) {735          await clearIncomingPairingRequest();736          return { ok: false, error: "expired" };737        }738        await clearIncomingPairingRequest();739        if (accept) {740          await addPairedDevice({741            deviceId: request.publicKeyHex,742            displayName: request.deviceName,743            x25519PublicKey: request.publicKeyHex,744            pairedAt: Date.now(),745            lastSeenAt: Date.now(),746          });747        }748        return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };749      }750      case "START_PAIRING_SERVER": {751        // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.752        const token = generateSixDigitToken();753        const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;754        await setActivePairingToken({ token, expiresAt });755        return { ok: true, data: { token, expiresAt } };756      }757      case "STOP_PAIRING_SERVER": {758        try {759          await setActivePairingToken(null);760        } catch { /* best-effort */ }761        return { ok: true };762      }763      case "GET_RECENT_PAIRING": {764        const recent = await consumeRecentPairing();765        return { ok: true, data: recent };766      }767      case "DEBUG_INJECT_INCOMING_PAIRING": {768        // Manual E2E rehearsal hook. Inserts a synthetic incoming pairing769        // request into session storage so Phase 7 banner UX can be exercised770        // without a live native host. No Pro gate — this is a no-op in771        // production unless explicitly invoked from a Settings debug action.772        const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {773          deviceName?: string;774          publicKeyHex?: string;775        };776        if (!deviceName || !publicKeyHex) {777          return { ok: false, error: "missing_payload" };778        }779        const now = Date.now();780        await setIncomingPairingRequest({781          deviceName,782          publicKeyHex,783          receivedAt: now,784          expiresAt: now + 60_000,785        });786        return { ok: true };787      }788      case "RESET_SYNC_IDENTITY": {789        const gate = await requirePro("sync");790        if (!gate.allowed) return { ok: false, error: "pro_required" };791        const devices = await getPairedDevices();792        for (const device of devices) {793          await removePairedDevice(device.deviceId);794        }795        return { ok: true, data: { removedDevices: devices.length, nativePublicKey: null } };796      }797      case "RECORD_MANUAL_SUMMON": {798        const { domain } = msg.payload as { domain: string };799        const settings = vault.getSettings();800        const existing = settings.manualSummonDomains?.[domain];801        const now = Date.now();802        await vault.updateSettings({803          manualSummonDomains: {804            ...settings.manualSummonDomains,805            [domain]: {806              firstSummonedAt: existing?.firstSummonedAt ?? now,807              summonCount: (existing?.summonCount ?? 0) + 1,808              lastSummonedAt: now,809            },810          },811        });812        return { ok: true };813      }814      case "DECLINE_CARD_SAVE": {815        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };816        await vault.declineCardSaveOffer(last4, expMonth, expYear);817        return { ok: true };818      }819      case "IS_CARD_DECLINED": {820        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };821        const declined = await vault.isCardDeclined(last4, expMonth, expYear);822        return { ok: true, data: declined };823      }824      case "INITIATE_FIREFOX_PASSKEY_CREATE": {825        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {826          rpId?: string;827          rpName?: string;828          userName?: string;829          userDisplayName?: string;830        };831        if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };832        return await initiateFirefoxPasskeyCreate({833          rpId,834          rpName: rpName ?? rpId,835          userName,836          userDisplayName,837        });838      }839840      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {841        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;842        if (!payload) return { ok: false, error: "missing payload" };843        return await completeFirefoxPasskeyCreate(payload);844      }845846      case "PASSKEY_OFFER_DECLINE": {847        const { domain } = (msg.payload ?? {}) as { domain?: string };848        if (!domain) return { ok: false, error: "domain required" };849        await recordPasskeyOfferDecline(domain);850        return { ok: true };851      }852853      case "GET_PASSKEY_OFFER_DECLINED": {854        const { domain } = (msg.payload ?? {}) as { domain?: string };855        if (!domain) return { ok: false, error: "domain required" };856        return { ok: true, data: await isPasskeyOfferDeclined(domain) };857      }858859      case "PASSKEY_HAS_CREDENTIALS": {860        const payload = msg.payload as {861          rpId?: string;862          allowCredentials?: Array<{ id: string; type?: string }>;863        } | undefined;864        if (!payload?.rpId) return { ok: false, error: "rpId required" };865        return await handlePasskeyHasCredentials({866          rpId: payload.rpId,867          allowCredentials: payload.allowCredentials ?? [],868        });869      }870871      case "PASSKEY_CREATE": {872        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;873        if (!payload?.rpId || !payload?.userName) {874          return { ok: false, error: "rpId and userName required" };875        }876        return await handleFirefoxPasskeyCreate(payload);877      }878879      case "PASSKEY_GET": {880        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;881        if (!payload?.rpId) return { ok: false, error: "rpId required" };882        return await handleFirefoxPasskeyGet(payload);883      }884885      case "GET_PASSKEY_FOR_DOMAIN": {886        const { domain } = (msg.payload ?? {}) as { domain?: string };887        if (!domain) return { ok: false, error: "domain required" };888        if (!vault.isUnlocked()) return { ok: true, data: null };889        const match = vault890          .getEntries()891          .find((entry) => entry.passkey?.rpId === domain);892        if (!match || !match.passkey) return { ok: true, data: null };893        // Slim shape — only the fields State 11 needs. Never expose private894        // key material, even though this is locked behind isUnlocked.895        return {896          ok: true,897          data: {898            rpId: match.passkey.rpId,899            userName: match.passkey.userName,900            browser: match.passkey.browser,901          },902        };903      }904905      case "DESTROY_VAULT": {906        // Irreversible by design. Lock zeroes the derived key + in-memory entries;907        // storage.local.clear() wipes every Peach key (slots, settings, license,908        // onboarded flag, history, sync state, declined cards, recall, etc.);909        // session.clear() wipes pending passkey requests + pro-notice queues.910        // Caller (Panic.tsx) handles UI navigation back to onboarding.911        vault.lock();912        await storageClear();913        await vault.clearVaultHistory();914        if (chrome.storage.session) {915          await chrome.storage.session.clear();916        }917        return { ok: true };918      }919920      case "SUBMIT_FEEDBACK": {921        const { category, message, version, browser } = msg.payload as {922          category: string;923          message: string;924          version: string;925          browser: string;926        };927        if (!message.trim()) return { ok: false, error: "message is empty" };928        if (!FEEDBACK_ENDPOINT) return { ok: true };929        if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {930          return { ok: false, error: "invalid feedback endpoint" };931        }932        try {933          const res = await fetch(FEEDBACK_ENDPOINT, {934            method: "POST",935            headers: { "Content-Type": "application/json" },936            body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),937          });938          return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };939        } catch (err) {940          return { ok: false, error: err instanceof Error ? err.message : "network error" };941        }942      }943      case "TRIGGER_SYNC": {944        const gate = await requirePro("sync");945        if (!gate.allowed) return { ok: false, error: "pro_required" };946        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };947        if (isNativePlatform()) {948          syncBridge()?.ensureServerRunning?.();949          return { ok: true, data: { syncedCount: 0, mode: "server" as const } };950        }951        // Desktop has no outbound sync path — paired devices are mobile, and952        // the mobile side initiates. From desktop, "trigger sync" just means953        // "make sure the pairing server is reachable", which is handled by954        // START_PAIRING_SERVER. Surface no_paired_devices to keep callers tidy.955        return { ok: false, error: "no_paired_devices" };956      }957      // Android-only: biometric unlock with pre-derived key.958      case "UNLOCK_WITH_KEY": {959        const { masterKeyHex } = msg.payload as { masterKeyHex: string };960        if (!isNativePlatform()) {961          return { ok: false, error: "desktop_only_uses_password" };962        }963        try {964          const result = await vault.unlockWithKey(masterKeyHex);965          if (result.ok) await publishInMemoryVault(result.entries);966          return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };967        } catch {968          return { ok: false, error: "invalid_key" };969        }970      }971      // Android: returns current in-memory derived key as hex. Used by biometric enable flow.972      case "GET_DERIVED_KEY": {973        if (!isNativePlatform()) {974          return { ok: false, error: "android_only" };975        }976        if (!vault.isUnlocked()) {977          return { ok: false, error: "vault_locked" };978        }979        return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };980      }981      // Android: scanned a QR containing a desktop pairing token.982      case "SCAN_PAIRING_QR": {983        if (!isNativePlatform()) {984          return { ok: false, error: "android_only" };985        }986        const { token } = msg.payload as { token: string };987        syncBridge()?.startPairingSession?.({ token });988        return { ok: true };989      }990      // Android: returns the phone's WiFi IP address.991      case "GET_LOCAL_IP": {992        if (!isNativePlatform()) {993          return { ok: false, error: "android_only" };994        }995        const { ip } = (await syncBridge()?.getLocalIp?.()) ?? { ip: "" };996        return { ok: true, data: { ip } };997      }998      case "GET_IDENTITY_SUGGESTIONS": {999        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1000        const entries = vault.getEntries();1001        const suggestions = buildIdentitySuggestions(entries);1002        return { ok: true, data: { suggestions } };1003      }1004      case "POPULATE_INLINE_VAULT": {1005        if (!isNativePlatform()) return { ok: false };1006        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1007        const entries = vault.getEntries();1008        await publishInMemoryVault(entries);1009        return { ok: true };1010      }1011      default:1012        return { ok: false, error: "unknown message type" };1013    }1014  } catch (err) {1015    return {1016      ok: false,1017      error: err instanceof Error ? err.message : "operation failed",1018    };1019  }1020}10211022// --- Mobile platform helpers ---10231024// Android: populates the in-memory vault used by PeachAutofillService.1025async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {1026  const plugin = nativePlugin<{1027    setEntries?: (args: {1028      entries: Array<{1029        id: string;1030        title: string;1031        username: string;1032        email: string;1033        password: string;1034        url: string;1035        totp: string;1036        additionalUrls: string[];1037      }>;1038    }) => Promise<void>;1039  }>("InMemoryVaultPlugin");1040  if (!plugin?.setEntries) return;1041  await plugin.setEntries({1042    entries: entries.map((e) => ({1043      id: e.id,1044      title: e.title ?? "",1045      username: e.username ?? "",1046      email: (e as VaultEntry & { email?: string }).email ?? "",1047      password: e.password ?? "",1048      url: e.url ?? "",1049      totp: (e as VaultEntry & { totp?: string }).totp ?? "",1050      additionalUrls: (e as VaultEntry & { additionalUrls?: string[] }).additionalUrls ?? [],1051    })),1052  });1053}10541055// Android: clear the autofill in-memory store on lock.1056async function clearInMemoryVault(): Promise<void> {1057  const plugin = nativePlugin<{ clearEntries?: () => Promise<void> }>("InMemoryVaultPlugin");1058  if (!plugin?.clearEntries) return;1059  await plugin.clearEntries();1060}10611062interface SyncBridge {1063  ensureServerRunning?: () => void;1064  startPairingSession?: (args: { token: string }) => void;1065  getLocalIp?: () => Promise<{ ip: string }>;1066}10671068// Resolve the Capacitor SyncPlugin at call time — module-load resolution1069// races with Capacitor's plugin registration on the Android WebView.1070function syncBridge(): SyncBridge | null {1071  return nativePlugin<SyncBridge>("SyncPlugin");1072}10731074// Returns username use-counts from the vault, sorted by frequency. Each row1075// also carries the entry's email so the popup can suggest the email/username1076// pair the user has actually used together.1077function buildIdentitySuggestions(1078  entries: VaultEntry[],1079): Array<{ email: string; username: string; count: number }> {1080  const usernameCounts = new Map<string, number>();1081  const usernameEmail = new Map<string, string>();1082  for (const entry of entries) {1083    const username = entry.username ?? "";1084    const email = (entry as VaultEntry & { email?: string }).email ?? "";1085    if (!username && !email) continue;1086    const key = username || email;1087    usernameCounts.set(key, (usernameCounts.get(key) ?? 0) + 1);1088    if (!usernameEmail.has(key)) usernameEmail.set(key, email);1089  }1090  return Array.from(usernameCounts.entries())1091    .sort((a, b) => b[1] - a[1])1092    .map(([username, count]) => ({1093      email: usernameEmail.get(username) ?? "",1094      username,1095      count,1096    }));1097}10981099/** Notify all content scripts that the vault just became available. */1100function broadcastToContentScripts(message: { type: string }): void {1101  if (!chrome.tabs?.query) return;1102  chrome.tabs.query({}, (tabs) => {1103    for (const tab of tabs) {1104      if (tab.id !== undefined) {1105        chrome.tabs.sendMessage(tab.id, message).catch(() => {1106          // Tab may not have a content script — silently ignore1107        });1108      }1109    }1110  });1111}11121113function shouldRefreshAutoLock(type: MessageType): boolean {1114  return (1115    type !== "HAS_VAULT" &&1116    type !== "IS_UNLOCKED" &&1117    type !== "GET_VAULT_STATUS" &&1118    type !== "LOCK" &&1119    type !== "UPDATE_SETTINGS" &&1120    type !== "GET_PENDING_PASSKEY" &&1121    type !== "GET_PASSKEY_PROVIDER_STATE" &&1122    type !== "DECLINE_CARD_SAVE" &&1123    type !== "IS_CARD_DECLINED" &&1124    type !== "SUBMIT_FEEDBACK"1125  );1126}11271128function isAllowedCanaryWebhookUrl(value: string): boolean {1129  try {1130    const url = new URL(value);1131    if (url.protocol !== "https:") return false;11321133    const hostname = url.hostname.toLowerCase();1134    if (1135      hostname === "localhost" ||1136      hostname.endsWith(".localhost") ||1137      isPrivateIpv4(hostname) ||1138      isPrivateIpv6(hostname)1139    ) {1140      return false;1141    }11421143    return true;1144  } catch {1145    return false;1146  }1147}11481149function isPrivateIpv4(hostname: string): boolean {1150  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {1151    return true;1152  }11531154  const octets = hostname.split(".").map((part) => Number(part));1155  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {1156    return false;1157  }11581159  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;1160}11611162function isPrivateIpv6(hostname: string): boolean {1163  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1164  return (1165    normalized === "::1" ||1166    normalized.startsWith("fc") ||1167    normalized.startsWith("fd") ||1168    normalized.startsWith("fe80:")1169  );1170}