passkeys-firefox.ts

2.7 KB · raw

1/**2 * passkeys-firefox.ts — MAIN-world detection layer for Firefox 150+.3 *4 * Firefox does not expose `chrome.webAuthenticationProxy`, so Peach cannot5 * intercept WebAuthn calls the way it does on Chrome. Instead, this script6 * runs in the page's main world (per `manifest.firefox.json`'s `world: "MAIN"`)7 * and monkey-patches `navigator.credentials.create` / `.get` to *observe*8 * passkey activity and emit CustomEvents the rest of Peach can react to.9 *10 * The original calls always proceed unchanged. Peach never blocks, modifies,11 * or replaces the page's own WebAuthn flow from here. Registration and12 * authentication on behalf of the user happen separately, from the popup13 * context, where Firefox 150+ allows extensions to call `navigator.credentials`14 * directly with their own host-permitted RP ID.15 *16 * Events emitted:17 *   `peach:passkey-create` — page invoked navigator.credentials.create with18 *                            a publicKey option set. detail = { rpId, userName }19 *   `peach:passkey-get`    — page invoked navigator.credentials.get with a20 *                            publicKey option set. detail = { rpId, allowCredentials }21 */2223(() => {24  const credentials = navigator.credentials;25  if (!credentials || typeof credentials.create !== "function" || typeof credentials.get !== "function") {26    return;27  }2829  const FLAG = "__peachPasskeysFirefoxAttached";30  const w = window as unknown as Record<string, unknown>;31  if (w[FLAG]) return;32  w[FLAG] = true;3334  const originalCreate = credentials.create.bind(credentials);35  const originalGet = credentials.get.bind(credentials);3637  function emit(payload: Record<string, unknown>) {38    try {39      // postMessage crosses the MAIN/ISOLATED-world boundary on Firefox; a40      // CustomEvent on `window` does not. Same-origin postMessage is the41      // documented bridge between page-world detectors and content scripts.42      window.postMessage({ source: "peach-passkey-firefox", ...payload }, location.origin);43    } catch { /* postMessage must never break the page */ }44  }4546  credentials.create = async function patchedCreate(options) {47    const publicKey = options?.publicKey;48    if (publicKey) {49      emit({50        type: "create",51        rpId: publicKey.rp?.id ?? location.hostname,52        rpName: publicKey.rp?.name,53        userName: publicKey.user?.name,54        userDisplayName: publicKey.user?.displayName,55      });56    }57    return originalCreate(options);58  };5960  credentials.get = async function patchedGet(options) {61    const publicKey = options?.publicKey;62    if (publicKey) {63      emit({64        type: "get",65        rpId: publicKey.rpId ?? location.hostname,66      });67    }68    return originalGet(options);69  };70})();