passkeys-firefox.ts

16.3 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        if (!options?.publicKey) return originalGet(options);166167        try {168          const publicKey = options.publicKey;169          const rpId = publicKey.rpId ?? window.location.hostname;170          const allowCredentials = (publicKey.allowCredentials ?? []).map((c) => ({171            id: bufferToBase64url(toArrayBuffer(c.id)),172            type: c.type,173          }));174175          // Cheap pre-check before showing any Peach UI.176          const has = await sendMessage({177            type: "PASSKEY_HAS_CREDENTIALS",178            payload: { rpId, allowCredentials },179          });180          if (!has?.ok || !has.hasCredentials) {181            return originalGet(options);182          }183184          const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));185          const clientDataJSON = encodeJsonBytes({186            type: "webauthn.get",187            challenge,188            origin: window.location.origin,189            crossOrigin: false,190          });191192          const response = await sendMessage({193            type: "PASSKEY_GET",194            payload: {195              rpId,196              challenge,197              clientDataJSON: Array.from(clientDataJSON),198              origin: window.location.origin,199              allowCredentials,200              userVerification: publicKey.userVerification,201            },202          });203204          if (!response?.ok) {205            const error = response?.error;206            if (error === "user_cancelled") {207              throw new window.DOMException(208                "The operation either timed out or was not allowed.",209                "NotAllowedError",210              );211            }212            if (error === "pro_required") {213              window.dispatchEvent(214                new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),215              );216              return originalGet(options);217            }218            return originalGet(options);219          }220221          const cred = validateGetCredential(response.credential);222          return buildPublicKeyCredential(cred);223        } catch (err) {224          if (err instanceof DOMException && err.name === "NotAllowedError") throw err;225          return originalGet(options);226        }227      });228    },229    pageCredentials,230    { defineAs: "get" },231  );232233  // ── validation ────────────────────────────────────────────────────────234235  const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;236237  function isBase64url(value: unknown): value is string {238    return typeof value === "string" && value.length > 0 && BASE64URL_RE.test(value);239  }240241  function isByteArray(value: unknown): value is number[] {242    return (243      Array.isArray(value) &&244      value.length > 0 &&245      value.every((b) => typeof b === "number" && Number.isInteger(b) && b >= 0 && b <= 255)246    );247  }248249  function validateCreateCredential(cred: unknown): CreateCredential {250    if (!cred || typeof cred !== "object") throw new ValidationError("missing credential");251    const c = cred as Record<string, unknown>;252    if (c.type !== "create") throw new ValidationError("wrong credential type");253    if (!isBase64url(c.id)) throw new ValidationError("id must be base64url");254    if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array");255    if (!isBase64url(c.attestationObject)) throw new ValidationError("attestationObject must be base64url");256    if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url");257    if (!isBase64url(c.publicKey)) throw new ValidationError("publicKey must be base64url");258    if (c.algorithm !== -7 && c.algorithm !== -8) throw new ValidationError("unsupported algorithm");259    return c as unknown as CreateCredential;260  }261262  function validateGetCredential(cred: unknown): GetCredential {263    if (!cred || typeof cred !== "object") throw new ValidationError("missing credential");264    const c = cred as Record<string, unknown>;265    if (c.type !== "get") throw new ValidationError("wrong credential type");266    if (!isBase64url(c.id)) throw new ValidationError("id must be base64url");267    if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array");268    if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url");269    if (!isBase64url(c.signature)) throw new ValidationError("signature must be base64url");270    if (c.userHandle !== undefined && c.userHandle !== null && !isBase64url(c.userHandle)) {271      throw new ValidationError("userHandle must be base64url");272    }273    return c as unknown as GetCredential;274  }275276  // ── helpers ────────────────────────────────────────────────────────────277278  class ValidationError extends Error {279    constructor(message: string) {280      super(message);281      this.name = "ValidationError";282    }283  }284285  interface CreateCredential {286    type: "create";287    id: string;288    clientDataJSON: number[];289    attestationObject: string;290    authenticatorData: string;291    publicKey: string;292    algorithm: -7 | -8;293  }294295  interface GetCredential {296    type: "get";297    id: string;298    clientDataJSON: number[];299    authenticatorData: string;300    signature: string;301    userHandle: string | null;302  }303304  function toArrayBuffer(view: BufferSource): ArrayBuffer {305    if (view instanceof ArrayBuffer) return view;306    const u8 = view as ArrayBufferView;307    const out = new Uint8Array(u8.byteLength);308    out.set(new Uint8Array(u8.buffer, u8.byteOffset, u8.byteLength));309    return out.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: () => ["internal"],362          getAuthenticatorData: () => authenticatorData,363          getPublicKey: () => publicKey,364          getPublicKeyAlgorithm: () => cred.algorithm ?? -7,365        } as AuthenticatorAttestationResponse,366        getClientExtensionResults: () => ({}),367        toJSON: () => ({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        }),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: () => ({}),401      toJSON: () => ({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      }),414    } as unknown as PublicKeyCredential;415    return cloneInto(obj, window, { cloneFunctions: true });416  }417}418419installPeachPasskeyProxy();