passkeys-firefox.ts

12.2 KB · raw

1/**2 * passkeys-firefox.ts — MAIN-world WebAuthn interception for Firefox.3 *4 * Firefox does not yet expose `chrome.webAuthenticationProxy`, so Peach5 * cannot intercept passkey ceremonies the way it does on Chrome. Until that6 * API ships, this script runs in the page's main world (per7 * `manifest.firefox.json`'s `world: "MAIN"`) and replaces8 * `navigator.credentials.create` and `navigator.credentials.get`.9 *10 * Architecture:11 *   - This file owns the page-side patch. It cannot import anything that12 *     touches extension APIs (different world). All extension-bound calls13 *     go through window.postMessage to the relay content script14 *     (passkeys-firefox-relay.ts) running in the extension world.15 *   - The relay forwards each request to the background service worker via16 *     chrome.runtime.sendMessage and posts the response back.17 *   - The background owns the vault, runs the Pro gate, shows the approval18 *     UI, performs the cryptographic operation, and returns a serialized19 *     PublicKeyCredential payload.20 *21 * Origin guarantee: clientDataJSON is built here with `origin =22 * window.location.origin`. The RP hashes the same bytes the background23 * signed, and verifies origin against its registered RP — never against24 * `moz-extension://`. This is what makes the assertion validate.25 *26 * Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime27 * flag in src/shared/passkey-flags.ts evaluates to false and this entire28 * file (along with the relay) gets deleted. See that file for the full29 * checklist.30 */3132export function installPeachPasskeyProxy(): void {33  const w = window as unknown as Record<string, unknown>;34  if (w.__peachPasskeyProxyInstalled) return;35  w.__peachPasskeyProxyInstalled = true;3637  const credentials = navigator.credentials;38  if (!credentials || typeof credentials.create !== "function" || typeof credentials.get !== "function") {39    return;40  }4142  const originalCreate = credentials.create.bind(credentials);43  const originalGet = credentials.get.bind(credentials);4445  credentials.create = async function patchedCreate(46    options?: CredentialCreationOptions47  ): Promise<Credential | null> {48    if (!options?.publicKey) return originalCreate(options);4950    const publicKey = options.publicKey;51    const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));52    const clientDataJSON = encodeJsonBytes({53      type: "webauthn.create",54      challenge,55      origin: window.location.origin,56      crossOrigin: false,57    });5859    const userId = bufferToBase64url(toArrayBuffer(publicKey.user.id));6061    const response = await sendToBackground({62      type: "PASSKEY_CREATE",63      payload: {64        rpId: publicKey.rp.id ?? window.location.hostname,65        rpName: publicKey.rp.name,66        userId,67        userName: publicKey.user.name,68        userDisplayName: publicKey.user.displayName,69        challenge,70        clientDataJSON: Array.from(clientDataJSON),71        origin: window.location.origin,72        pubKeyCredParams: publicKey.pubKeyCredParams,73        authenticatorSelection: publicKey.authenticatorSelection,74        timeout: publicKey.timeout,75        attestation: publicKey.attestation,76      },77    });7879    if (!response?.ok) {80      const error = response?.error;81      if (error === "user_cancelled") {82        // The user denied via Peach. Match the WebAuthn contract — this is83        // the same exception the platform throws when the user dismisses84        // the OS prompt.85        throw new DOMException(86          "The operation either timed out or was not allowed.",87          "NotAllowedError"88        );89      }90      if (error === "pro_required") {91        // Notify the extension-world script to show the inline Pro gate, then92        // hand the call back to the platform so the user is not stranded.93        window.dispatchEvent(94          new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } })95        );96        return originalCreate(options);97      }98      // vault_locked / no_credentials / not_applicable / timeout / unknown:99      // fall through to the platform authenticator. Peach is not in a100      // position to fulfill this ceremony right now and the user should101      // still be able to register / authenticate against the RP.102      return originalCreate(options);103    }104105    return buildPublicKeyCredential(response.credential);106  };107108  credentials.get = async function patchedGet(109    options?: CredentialRequestOptions110  ): Promise<Credential | null> {111    if (!options?.publicKey) return originalGet(options);112113    const publicKey = options.publicKey;114    const rpId = publicKey.rpId ?? window.location.hostname;115    const allowCredentials = (publicKey.allowCredentials ?? []).map((c) => ({116      id: bufferToBase64url(toArrayBuffer(c.id)),117      type: c.type,118    }));119120    // Cheap pre-check before showing any Peach UI. If the vault has no match121    // for this rpId, hand the ceremony to the platform — no point prompting.122    const has = await sendToBackground({123      type: "PASSKEY_HAS_CREDENTIALS",124      payload: { rpId, allowCredentials },125    });126    if (!has?.ok || !has.hasCredentials) {127      return originalGet(options);128    }129130    const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));131    const clientDataJSON = encodeJsonBytes({132      type: "webauthn.get",133      challenge,134      origin: window.location.origin,135      crossOrigin: false,136    });137138    const response = await sendToBackground({139      type: "PASSKEY_GET",140      payload: {141        rpId,142        challenge,143        clientDataJSON: Array.from(clientDataJSON),144        origin: window.location.origin,145        allowCredentials,146        userVerification: publicKey.userVerification,147      },148    });149150    if (!response?.ok) {151      const error = response?.error;152      if (error === "user_cancelled") {153        throw new DOMException(154          "The operation either timed out or was not allowed.",155          "NotAllowedError"156        );157      }158      if (error === "pro_required") {159        window.dispatchEvent(160          new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } })161        );162        return originalGet(options);163      }164      return originalGet(options);165    }166167    return buildPublicKeyCredential(response.credential);168  };169170  // ── helpers ────────────────────────────────────────────────────────────171172  function toArrayBuffer(view: BufferSource): ArrayBuffer {173    if (view instanceof ArrayBuffer) return view;174    const u8 = view as ArrayBufferView;175    const out = new Uint8Array(u8.byteLength);176    out.set(new Uint8Array(u8.buffer, u8.byteOffset, u8.byteLength));177    return out.buffer;178  }179180  function bufferToBase64url(buffer: ArrayBuffer): string {181    const bytes = new Uint8Array(buffer);182    let str = "";183    for (let i = 0; i < bytes.length; i += 1) str += String.fromCharCode(bytes[i]!);184    return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");185  }186187  function base64urlToBuffer(value: string): ArrayBuffer {188    const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(189      Math.ceil(value.length / 4) * 4,190      "="191    );192    const raw = atob(padded);193    const bytes = new Uint8Array(raw.length);194    for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);195    return bytes.buffer;196  }197198  function encodeJsonBytes(value: unknown): Uint8Array {199    return new TextEncoder().encode(JSON.stringify(value));200  }201202  /**203   * postMessage round-trip with a per-call UUID. The relay (extension-world204   * content script) listens for PEACH_PASSKEY_REQUEST and posts back205   * PEACH_PASSKEY_RESPONSE with the same id. A 60s timeout matches the206   * background's approval window — outliving the user's decision time207   * without leaking the listener forever.208   */209  function sendToBackground(message: object): Promise<any> {210    return new Promise((resolve) => {211      const id = generateRequestId();212      const handler = (event: MessageEvent) => {213        if (event.source !== window) return;214        const data = event.data;215        if (!data || typeof data !== "object") return;216        if ((data as { type?: string }).type !== "PEACH_PASSKEY_RESPONSE") return;217        if ((data as { id?: string }).id !== id) return;218        cleanup();219        resolve((data as { payload?: unknown }).payload);220      };221      const timer = setTimeout(() => {222        cleanup();223        resolve({ ok: false, error: "timeout" });224      }, 60_000);225      function cleanup() {226        window.removeEventListener("message", handler);227        clearTimeout(timer);228      }229      window.addEventListener("message", handler);230      window.postMessage({ type: "PEACH_PASSKEY_REQUEST", id, payload: message }, "*");231    });232  }233234  function generateRequestId(): string {235    if (typeof crypto.randomUUID === "function") return crypto.randomUUID();236    const bytes = crypto.getRandomValues(new Uint8Array(16));237    return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");238  }239240  /**241   * Reconstruct a PublicKeyCredential-shaped object from the background's242   * serialized response. The shape must satisfy what RPs read off the243   * credential — id, rawId, response.{clientDataJSON, attestationObject |244   * authenticatorData, signature, userHandle}, plus the helper getters that245   * some RPs call (getPublicKey, getTransports, etc.).246   *247   * `instanceof PublicKeyCredential` will be false because we cannot248   * construct a real one from page script. RPs that perform that check249   * directly will reject — but no major RP does, because the original250   * Spec/JS-API path on iOS Safari produced non-instance credentials for251   * years. RPs read the duck-typed shape.252   */253  function buildPublicKeyCredential(cred: any): PublicKeyCredential {254    const credentialId = base64urlToBuffer(cred.id);255    const clientDataJSON = new Uint8Array(cred.clientDataJSON).buffer;256257    if (cred.type === "create") {258      const attestationObject = base64urlToBuffer(cred.attestationObject);259      const authenticatorData = base64urlToBuffer(cred.authenticatorData);260      const publicKey = base64urlToBuffer(cred.publicKey);261      return {262        id: cred.id,263        rawId: credentialId,264        type: "public-key",265        authenticatorAttachment: "platform",266        response: {267          clientDataJSON,268          attestationObject,269          getTransports: () => ["internal"],270          getAuthenticatorData: () => authenticatorData,271          getPublicKey: () => publicKey,272          getPublicKeyAlgorithm: () => cred.algorithm ?? -7,273        } as AuthenticatorAttestationResponse,274        getClientExtensionResults: () => ({}),275        toJSON: () => ({276          id: cred.id,277          rawId: cred.id,278          type: "public-key",279          response: {280            clientDataJSON: bufferToBase64url(clientDataJSON),281            attestationObject: cred.attestationObject,282            authenticatorData: cred.authenticatorData,283            publicKey: cred.publicKey,284            publicKeyAlgorithm: cred.algorithm ?? -7,285            transports: ["internal"],286          },287          authenticatorAttachment: "platform",288          clientExtensionResults: {},289        }),290      } as unknown as PublicKeyCredential;291    }292293    const authenticatorData = base64urlToBuffer(cred.authenticatorData);294    const signature = base64urlToBuffer(cred.signature);295    const userHandle = cred.userHandle ? base64urlToBuffer(cred.userHandle) : null;296    return {297      id: cred.id,298      rawId: credentialId,299      type: "public-key",300      authenticatorAttachment: "platform",301      response: {302        clientDataJSON,303        authenticatorData,304        signature,305        userHandle,306      } as AuthenticatorAssertionResponse,307      getClientExtensionResults: () => ({}),308      toJSON: () => ({309        id: cred.id,310        rawId: cred.id,311        type: "public-key",312        response: {313          clientDataJSON: bufferToBase64url(clientDataJSON),314          authenticatorData: cred.authenticatorData,315          signature: cred.signature,316          userHandle: cred.userHandle ?? null,317        },318        authenticatorAttachment: "platform",319        clientExtensionResults: {},320      }),321    } as unknown as PublicKeyCredential;322  }323}324325installPeachPasskeyProxy();