passkeys-firefox.ts

16.6 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  function pagePromise<T>(task: () => Promise<T>): Promise<T> {90    return new window.Promise<T>((resolve, reject) => {91      task().then(resolve, reject);92    });93  }9495  exportFunction(96    function patchedCreate(options?: CredentialCreationOptions): Promise<Credential | null> {97      return pagePromise(async () => {98        if (!options?.publicKey) return originalCreate(options);99100        try {101          const publicKey = options.publicKey;102          const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));103          const clientDataJSON = encodeJsonBytes({104            type: "webauthn.create",105            challenge,106            origin: window.location.origin,107            crossOrigin: false,108          });109110          const userId = bufferToBase64url(toArrayBuffer(publicKey.user.id));111112          const response = await sendMessage({113            type: "PASSKEY_CREATE",114            payload: {115              rpId: publicKey.rp.id ?? window.location.hostname,116              rpName: publicKey.rp.name,117              userId,118              userName: publicKey.user.name,119              userDisplayName: publicKey.user.displayName,120              challenge,121              clientDataJSON: Array.from(clientDataJSON),122              origin: window.location.origin,123              pubKeyCredParams: publicKey.pubKeyCredParams,124              authenticatorSelection: publicKey.authenticatorSelection,125              timeout: publicKey.timeout,126              attestation: publicKey.attestation,127            },128          });129130          if (!response?.ok) {131            const error = response?.error;132            if (error === "user_cancelled") {133              throw new window.DOMException(134                "The operation either timed out or was not allowed.",135                "NotAllowedError",136              );137            }138            if (error === "pro_required") {139              window.dispatchEvent(140                new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),141              );142              return originalCreate(options);143            }144            // vault_locked / no_credentials / not_applicable / timeout / unknown:145            // fall through to the platform authenticator.146            return originalCreate(options);147          }148149          const cred = validateCreateCredential(response.credential);150          return buildPublicKeyCredential(cred);151        } catch (err) {152          if (err instanceof DOMException && err.name === "NotAllowedError") throw err;153          // Validation failure or unexpected error — fail closed, fall through.154          return originalCreate(options);155        }156      });157    },158    pageCredentials,159    { defineAs: "create" },160  );161162  exportFunction(163    function patchedGet(options?: CredentialRequestOptions): Promise<Credential | null> {164      return pagePromise(async () => {165        // Conditional autofill must remain pending under Firefox's UI and cancellation rules.166        if (!options?.publicKey || options.mediation === "conditional") return originalGet(options);167168        try {169          const publicKey = options.publicKey;170          const rpId = publicKey.rpId ?? window.location.hostname;171          const allowCredentials = (publicKey.allowCredentials ?? []).map((c) => ({172            id: bufferToBase64url(toArrayBuffer(c.id)),173            type: c.type,174          }));175176          // Cheap pre-check before showing any Peach UI.177          const has = await sendMessage({178            type: "PASSKEY_HAS_CREDENTIALS",179            payload: { rpId, allowCredentials },180          });181          if (!has?.ok || !has.hasCredentials) {182            return originalGet(options);183          }184185          const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));186          const clientDataJSON = encodeJsonBytes({187            type: "webauthn.get",188            challenge,189            origin: window.location.origin,190            crossOrigin: false,191          });192193          const response = await sendMessage({194            type: "PASSKEY_GET",195            payload: {196              rpId,197              challenge,198              clientDataJSON: Array.from(clientDataJSON),199              origin: window.location.origin,200              allowCredentials,201              userVerification: publicKey.userVerification,202            },203          });204205          if (!response?.ok) {206            const error = response?.error;207            if (error === "user_cancelled") {208              throw new window.DOMException(209                "The operation either timed out or was not allowed.",210                "NotAllowedError",211              );212            }213            if (error === "pro_required") {214              window.dispatchEvent(215                new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),216              );217              return originalGet(options);218            }219            return originalGet(options);220          }221222          const cred = validateGetCredential(response.credential);223          return buildPublicKeyCredential(cred);224        } catch (err) {225          if (err instanceof DOMException && err.name === "NotAllowedError") throw err;226          return originalGet(options);227        }228      });229    },230    pageCredentials,231    { defineAs: "get" },232  );233234  // ── validation ────────────────────────────────────────────────────────235236  const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;237238  function isBase64url(value: unknown): value is string {239    return typeof value === "string" && value.length > 0 && BASE64URL_RE.test(value);240  }241242  function isByteArray(value: unknown): value is number[] {243    return (244      Array.isArray(value) &&245      value.length > 0 &&246      value.every((b) => typeof b === "number" && Number.isInteger(b) && b >= 0 && b <= 255)247    );248  }249250  function validateCreateCredential(cred: unknown): CreateCredential {251    if (!cred || typeof cred !== "object") throw new ValidationError("missing credential");252    const c = cred as Record<string, unknown>;253    if (c.type !== "create") throw new ValidationError("wrong credential type");254    if (!isBase64url(c.id)) throw new ValidationError("id must be base64url");255    if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array");256    if (!isBase64url(c.attestationObject)) throw new ValidationError("attestationObject must be base64url");257    if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url");258    if (!isBase64url(c.publicKey)) throw new ValidationError("publicKey must be base64url");259    if (c.algorithm !== -7 && c.algorithm !== -8) throw new ValidationError("unsupported algorithm");260    return c as unknown as CreateCredential;261  }262263  function validateGetCredential(cred: unknown): GetCredential {264    if (!cred || typeof cred !== "object") throw new ValidationError("missing credential");265    const c = cred as Record<string, unknown>;266    if (c.type !== "get") throw new ValidationError("wrong credential type");267    if (!isBase64url(c.id)) throw new ValidationError("id must be base64url");268    if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array");269    if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url");270    if (!isBase64url(c.signature)) throw new ValidationError("signature must be base64url");271    if (c.userHandle !== undefined && c.userHandle !== null && !isBase64url(c.userHandle)) {272      throw new ValidationError("userHandle must be base64url");273    }274    return c as unknown as GetCredential;275  }276277  // ── helpers ────────────────────────────────────────────────────────────278279  class ValidationError extends Error {280    constructor(message: string) {281      super(message);282      this.name = "ValidationError";283    }284  }285286  interface CreateCredential {287    type: "create";288    id: string;289    clientDataJSON: number[];290    attestationObject: string;291    authenticatorData: string;292    publicKey: string;293    algorithm: -7 | -8;294  }295296  interface GetCredential {297    type: "get";298    id: string;299    clientDataJSON: number[];300    authenticatorData: string;301    signature: string;302    userHandle: string | null;303  }304305  function toArrayBuffer(view: BufferSource): ArrayBuffer {306    const bytes = ArrayBuffer.isView(view)307      ? new Uint8Array(view.buffer, view.byteOffset, view.byteLength)308      : new Uint8Array(view);309    return new Uint8Array(bytes).buffer;310  }311312  function bufferToBase64url(buffer: ArrayBuffer): string {313    const bytes = new Uint8Array(buffer);314    let str = "";315    for (let i = 0; i < bytes.length; i += 1) str += String.fromCharCode(bytes[i]!);316    return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");317  }318319  function base64urlToBuffer(value: string): ArrayBuffer {320    const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(321      Math.ceil(value.length / 4) * 4,322      "=",323    );324    const raw = atob(padded);325    const bytes = new Uint8Array(raw.length);326    for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);327    return bytes.buffer;328  }329330  function encodeJsonBytes(value: unknown): Uint8Array {331    return new TextEncoder().encode(JSON.stringify(value));332  }333334  /**335   * Reconstruct a PublicKeyCredential-shaped object from the background's336   * serialized response. `cloneInto` makes the object and its methods337   * callable from page-world — without it the page would see isolated-world338   * objects through an Xray wrapper and could not call the getter methods.339   *340   * `instanceof PublicKeyCredential` will be false because we cannot341   * construct a real one from content script. RPs that perform that check342   * directly will reject — but no major RP does. RPs read the duck-typed343   * shape.344   */345  function buildPublicKeyCredential(cred: CreateCredential | GetCredential): PublicKeyCredential {346    const credentialId = base64urlToBuffer(cred.id);347    const clientDataJSON = new Uint8Array(cred.clientDataJSON).buffer;348349    if (cred.type === "create") {350      const attestationObject = base64urlToBuffer(cred.attestationObject);351      const authenticatorData = base64urlToBuffer(cred.authenticatorData);352      const publicKey = base64urlToBuffer(cred.publicKey);353      const obj = {354        id: cred.id,355        rawId: credentialId,356        type: "public-key" as const,357        authenticatorAttachment: "platform" as const,358        response: {359          clientDataJSON,360          attestationObject,361          getTransports: () => cloneInto(["internal"], window),362          getAuthenticatorData: () => cloneInto(authenticatorData, window),363          getPublicKey: () => cloneInto(publicKey, window),364          getPublicKeyAlgorithm: () => cred.algorithm ?? -7,365        } as AuthenticatorAttestationResponse,366        getClientExtensionResults: () => cloneInto({}, window),367        toJSON: () => cloneInto({368          id: cred.id,369          rawId: cred.id,370          type: "public-key" as const,371          response: {372            clientDataJSON: bufferToBase64url(clientDataJSON),373            attestationObject: cred.attestationObject,374            authenticatorData: cred.authenticatorData,375            publicKey: cred.publicKey,376            publicKeyAlgorithm: cred.algorithm ?? -7,377            transports: ["internal"],378          },379          authenticatorAttachment: "platform" as const,380          clientExtensionResults: {},381        }, window),382      } as unknown as PublicKeyCredential;383      return cloneInto(obj, window, { cloneFunctions: true });384    }385386    const authenticatorData = base64urlToBuffer(cred.authenticatorData);387    const signature = base64urlToBuffer(cred.signature);388    const userHandle = cred.userHandle ? base64urlToBuffer(cred.userHandle) : null;389    const obj = {390      id: cred.id,391      rawId: credentialId,392      type: "public-key" as const,393      authenticatorAttachment: "platform" as const,394      response: {395        clientDataJSON,396        authenticatorData,397        signature,398        userHandle,399      } as AuthenticatorAssertionResponse,400      getClientExtensionResults: () => cloneInto({}, window),401      toJSON: () => cloneInto({402        id: cred.id,403        rawId: cred.id,404        type: "public-key" as const,405        response: {406          clientDataJSON: bufferToBase64url(clientDataJSON),407          authenticatorData: cred.authenticatorData,408          signature: cred.signature,409          userHandle: cred.userHandle ?? null,410        },411        authenticatorAttachment: "platform" as const,412        clientExtensionResults: {},413      }, window),414    } as unknown as PublicKeyCredential;415    return cloneInto(obj, window, { cloneFunctions: true });416  }417}418419installPeachPasskeyProxy();