passkeys-firefox.ts

15.9 KB · raw

1/**2 * passkeys-firefox.ts — isolated-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 extension's isolated world (per7 * `manifest.firefox.json`'s content_scripts) and replaces8 * `navigator.credentials.create` and `navigator.credentials.get` using9 * Firefox's privileged `exportFunction()` API.10 *11 * Architecture:12 *   - This file is the ONLY content script for Firefox passkeys. It runs in13 *     the isolated world and has direct access to `chrome.runtime.sendMessage`.14 *   - `exportFunction()` installs wrapper functions into the page's main15 *     world. The wrappers retain their privileged closure — the page can16 *     call them but cannot forge their return values or race a separate17 *     response event (PB-26-209).18 *   - The background owns the vault, runs the Pro gate, shows the approval19 *     UI, performs the cryptographic operation, and returns a serialized20 *     PublicKeyCredential payload.21 *22 * Security property: the page cannot forge the privileged function's23 * returned credential or race a separate public response event. There is24 * no `window.postMessage` response bus — the wrapper returns the result25 * directly from its privileged closure.26 *27 * Origin guarantee: clientDataJSON is built here with `origin =28 * window.location.origin`. The RP hashes the same bytes the background29 * signed, and verifies origin against its registered RP. The background30 * additionally derives the browser-attested origin from `MessageSender`31 * and enforces rpId<->origin — page-supplied origin fields are untrusted.32 *33 * If `exportFunction` is unavailable (unsupported Firefox version), the34 * provider is disabled and falls through to native WebAuthn. Do not retain35 * an unauthenticated response bus for feature parity.36 *37 * Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime38 * flag in src/shared/passkey-flags.ts evaluates to false and this entire39 * file gets deleted. See that file for the full checklist.40 */4142// Firefox WebExtension privileged APIs — available in content scripts but43// not in jsdom. The declarations let TypeScript compile without a DOM lib44// that includes them.45declare function exportFunction(46  func: (...args: any[]) => any,47  targetScope: any,48  options?: { defineAs?: string },49): (...args: any[]) => any;5051declare function cloneInto<T>(52  obj: T,53  targetScope: any,54  options?: { cloneFunctions?: boolean },55): T;5657export function installPeachPasskeyProxy(): void {58  const w = window as unknown as Record<string, unknown>;59  if (w.__peachPasskeyProxyInstalled) return;60  w.__peachPasskeyProxyInstalled = true;6162  // Capture sendMessage at installation time — the wrapper closure must not63  // re-look it up on a page-writable global (PB-26-209). chrome.runtime is64  // extension-only (not page-writable), but capturing the reference means65  // later replacement of chrome.runtime.sendMessage has no effect.66  const sendMessage = chrome.runtime.sendMessage;6768  // exportFunction / cloneInto are Firefox-only. If unavailable (unsupported69  // Firefox version, or a non-Firefox test context), disable the provider70  // and fall through to native WebAuthn.71  if (typeof exportFunction !== "function" || typeof cloneInto !== "function") {72    return;73  }7475  const credentials = navigator.credentials;76  if (!credentials || typeof credentials.create !== "function" || typeof credentials.get !== "function") {77    return;78  }7980  // Access the page-world credentials object through the Xray boundary.81  const pageCredentials =82    (credentials as unknown as { wrappedJSObject?: CredentialsContainer }).wrappedJSObject ??83    credentials;8485  // Save originals for fall-through.86  const originalCreate = credentials.create.bind(credentials);87  const originalGet = credentials.get.bind(credentials);8889  exportFunction(90    async function patchedCreate(options?: CredentialCreationOptions): Promise<Credential | null> {91      if (!options?.publicKey) return originalCreate(options);9293      try {94        const publicKey = options.publicKey;95        const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));96        const clientDataJSON = encodeJsonBytes({97          type: "webauthn.create",98          challenge,99          origin: window.location.origin,100          crossOrigin: false,101        });102103        const userId = bufferToBase64url(toArrayBuffer(publicKey.user.id));104105        const response = await sendMessage({106          type: "PASSKEY_CREATE",107          payload: {108            rpId: publicKey.rp.id ?? window.location.hostname,109            rpName: publicKey.rp.name,110            userId,111            userName: publicKey.user.name,112            userDisplayName: publicKey.user.displayName,113            challenge,114            clientDataJSON: Array.from(clientDataJSON),115            origin: window.location.origin,116            pubKeyCredParams: publicKey.pubKeyCredParams,117            authenticatorSelection: publicKey.authenticatorSelection,118            timeout: publicKey.timeout,119            attestation: publicKey.attestation,120          },121        });122123        if (!response?.ok) {124          const error = response?.error;125          if (error === "user_cancelled") {126            throw new DOMException(127              "The operation either timed out or was not allowed.",128              "NotAllowedError",129            );130          }131          if (error === "pro_required") {132            window.dispatchEvent(133              new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),134            );135            return originalCreate(options);136          }137          // vault_locked / no_credentials / not_applicable / timeout / unknown:138          // fall through to the platform authenticator.139          return originalCreate(options);140        }141142        const cred = validateCreateCredential(response.credential);143        return buildPublicKeyCredential(cred);144      } catch (err) {145        if (err instanceof DOMException && err.name === "NotAllowedError") throw err;146        // Validation failure or unexpected error — fail closed, fall through.147        return originalCreate(options);148      }149    },150    pageCredentials,151    { defineAs: "create" },152  );153154  exportFunction(155    async function patchedGet(options?: CredentialRequestOptions): Promise<Credential | null> {156      if (!options?.publicKey) return originalGet(options);157158      try {159        const publicKey = options.publicKey;160        const rpId = publicKey.rpId ?? window.location.hostname;161        const allowCredentials = (publicKey.allowCredentials ?? []).map((c) => ({162          id: bufferToBase64url(toArrayBuffer(c.id)),163          type: c.type,164        }));165166        // Cheap pre-check before showing any Peach UI.167        const has = await sendMessage({168          type: "PASSKEY_HAS_CREDENTIALS",169          payload: { rpId, allowCredentials },170        });171        if (!has?.ok || !has.hasCredentials) {172          return originalGet(options);173        }174175        const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));176        const clientDataJSON = encodeJsonBytes({177          type: "webauthn.get",178          challenge,179          origin: window.location.origin,180          crossOrigin: false,181        });182183        const response = await sendMessage({184          type: "PASSKEY_GET",185          payload: {186            rpId,187            challenge,188            clientDataJSON: Array.from(clientDataJSON),189            origin: window.location.origin,190            allowCredentials,191            userVerification: publicKey.userVerification,192          },193        });194195        if (!response?.ok) {196          const error = response?.error;197          if (error === "user_cancelled") {198            throw new DOMException(199              "The operation either timed out or was not allowed.",200              "NotAllowedError",201            );202          }203          if (error === "pro_required") {204            window.dispatchEvent(205              new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),206            );207            return originalGet(options);208          }209          return originalGet(options);210        }211212        const cred = validateGetCredential(response.credential);213        return buildPublicKeyCredential(cred);214      } catch (err) {215        if (err instanceof DOMException && err.name === "NotAllowedError") throw err;216        return originalGet(options);217      }218    },219    pageCredentials,220    { defineAs: "get" },221  );222223  // ── validation ────────────────────────────────────────────────────────224225  const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;226227  function isBase64url(value: unknown): value is string {228    return typeof value === "string" && value.length > 0 && BASE64URL_RE.test(value);229  }230231  function isByteArray(value: unknown): value is number[] {232    return (233      Array.isArray(value) &&234      value.length > 0 &&235      value.every((b) => typeof b === "number" && Number.isInteger(b) && b >= 0 && b <= 255)236    );237  }238239  function validateCreateCredential(cred: unknown): CreateCredential {240    if (!cred || typeof cred !== "object") throw new ValidationError("missing credential");241    const c = cred as Record<string, unknown>;242    if (c.type !== "create") throw new ValidationError("wrong credential type");243    if (!isBase64url(c.id)) throw new ValidationError("id must be base64url");244    if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array");245    if (!isBase64url(c.attestationObject)) throw new ValidationError("attestationObject must be base64url");246    if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url");247    if (!isBase64url(c.publicKey)) throw new ValidationError("publicKey must be base64url");248    if (c.algorithm !== -7 && c.algorithm !== -8) throw new ValidationError("unsupported algorithm");249    return c as unknown as CreateCredential;250  }251252  function validateGetCredential(cred: unknown): GetCredential {253    if (!cred || typeof cred !== "object") throw new ValidationError("missing credential");254    const c = cred as Record<string, unknown>;255    if (c.type !== "get") throw new ValidationError("wrong credential type");256    if (!isBase64url(c.id)) throw new ValidationError("id must be base64url");257    if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array");258    if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url");259    if (!isBase64url(c.signature)) throw new ValidationError("signature must be base64url");260    if (c.userHandle !== undefined && c.userHandle !== null && !isBase64url(c.userHandle)) {261      throw new ValidationError("userHandle must be base64url");262    }263    return c as unknown as GetCredential;264  }265266  // ── helpers ────────────────────────────────────────────────────────────267268  class ValidationError extends Error {269    constructor(message: string) {270      super(message);271      this.name = "ValidationError";272    }273  }274275  interface CreateCredential {276    type: "create";277    id: string;278    clientDataJSON: number[];279    attestationObject: string;280    authenticatorData: string;281    publicKey: string;282    algorithm: -7 | -8;283  }284285  interface GetCredential {286    type: "get";287    id: string;288    clientDataJSON: number[];289    authenticatorData: string;290    signature: string;291    userHandle: string | null;292  }293294  function toArrayBuffer(view: BufferSource): ArrayBuffer {295    if (view instanceof ArrayBuffer) return view;296    const u8 = view as ArrayBufferView;297    const out = new Uint8Array(u8.byteLength);298    out.set(new Uint8Array(u8.buffer, u8.byteOffset, u8.byteLength));299    return out.buffer;300  }301302  function bufferToBase64url(buffer: ArrayBuffer): string {303    const bytes = new Uint8Array(buffer);304    let str = "";305    for (let i = 0; i < bytes.length; i += 1) str += String.fromCharCode(bytes[i]!);306    return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");307  }308309  function base64urlToBuffer(value: string): ArrayBuffer {310    const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(311      Math.ceil(value.length / 4) * 4,312      "=",313    );314    const raw = atob(padded);315    const bytes = new Uint8Array(raw.length);316    for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);317    return bytes.buffer;318  }319320  function encodeJsonBytes(value: unknown): Uint8Array {321    return new TextEncoder().encode(JSON.stringify(value));322  }323324  /**325   * Reconstruct a PublicKeyCredential-shaped object from the background's326   * serialized response. `cloneInto` makes the object and its methods327   * callable from page-world — without it the page would see isolated-world328   * objects through an Xray wrapper and could not call the getter methods.329   *330   * `instanceof PublicKeyCredential` will be false because we cannot331   * construct a real one from content script. RPs that perform that check332   * directly will reject — but no major RP does. RPs read the duck-typed333   * shape.334   */335  function buildPublicKeyCredential(cred: CreateCredential | GetCredential): PublicKeyCredential {336    const credentialId = base64urlToBuffer(cred.id);337    const clientDataJSON = new Uint8Array(cred.clientDataJSON).buffer;338339    if (cred.type === "create") {340      const attestationObject = base64urlToBuffer(cred.attestationObject);341      const authenticatorData = base64urlToBuffer(cred.authenticatorData);342      const publicKey = base64urlToBuffer(cred.publicKey);343      const obj = {344        id: cred.id,345        rawId: credentialId,346        type: "public-key" as const,347        authenticatorAttachment: "platform" as const,348        response: {349          clientDataJSON,350          attestationObject,351          getTransports: () => ["internal"],352          getAuthenticatorData: () => authenticatorData,353          getPublicKey: () => publicKey,354          getPublicKeyAlgorithm: () => cred.algorithm ?? -7,355        } as AuthenticatorAttestationResponse,356        getClientExtensionResults: () => ({}),357        toJSON: () => ({358          id: cred.id,359          rawId: cred.id,360          type: "public-key" as const,361          response: {362            clientDataJSON: bufferToBase64url(clientDataJSON),363            attestationObject: cred.attestationObject,364            authenticatorData: cred.authenticatorData,365            publicKey: cred.publicKey,366            publicKeyAlgorithm: cred.algorithm ?? -7,367            transports: ["internal"],368          },369          authenticatorAttachment: "platform" as const,370          clientExtensionResults: {},371        }),372      } as unknown as PublicKeyCredential;373      return cloneInto(obj, window, { cloneFunctions: true });374    }375376    const authenticatorData = base64urlToBuffer(cred.authenticatorData);377    const signature = base64urlToBuffer(cred.signature);378    const userHandle = cred.userHandle ? base64urlToBuffer(cred.userHandle) : null;379    const obj = {380      id: cred.id,381      rawId: credentialId,382      type: "public-key" as const,383      authenticatorAttachment: "platform" as const,384      response: {385        clientDataJSON,386        authenticatorData,387        signature,388        userHandle,389      } as AuthenticatorAssertionResponse,390      getClientExtensionResults: () => ({}),391      toJSON: () => ({392        id: cred.id,393        rawId: cred.id,394        type: "public-key" as const,395        response: {396          clientDataJSON: bufferToBase64url(clientDataJSON),397          authenticatorData: cred.authenticatorData,398          signature: cred.signature,399          userHandle: cred.userHandle ?? null,400        },401        authenticatorAttachment: "platform" as const,402        clientExtensionResults: {},403      }),404    } as unknown as PublicKeyCredential;405    return cloneInto(obj, window, { cloneFunctions: true });406  }407}408409installPeachPasskeyProxy();