passkeys-firefox.ts

16.4 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    const bytes = ArrayBuffer.isView(view)306      ? new Uint8Array(view.buffer, view.byteOffset, view.byteLength)307      : new Uint8Array(view);308    return new Uint8Array(bytes).buffer;309  }310311  function bufferToBase64url(buffer: ArrayBuffer): string {312    const bytes = new Uint8Array(buffer);313    let str = "";314    for (let i = 0; i < bytes.length; i += 1) str += String.fromCharCode(bytes[i]!);315    return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");316  }317318  function base64urlToBuffer(value: string): ArrayBuffer {319    const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(320      Math.ceil(value.length / 4) * 4,321      "=",322    );323    const raw = atob(padded);324    const bytes = new Uint8Array(raw.length);325    for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);326    return bytes.buffer;327  }328329  function encodeJsonBytes(value: unknown): Uint8Array {330    return new TextEncoder().encode(JSON.stringify(value));331  }332333  /**334   * Reconstruct a PublicKeyCredential-shaped object from the background's335   * serialized response. `cloneInto` makes the object and its methods336   * callable from page-world — without it the page would see isolated-world337   * objects through an Xray wrapper and could not call the getter methods.338   *339   * `instanceof PublicKeyCredential` will be false because we cannot340   * construct a real one from content script. RPs that perform that check341   * directly will reject — but no major RP does. RPs read the duck-typed342   * shape.343   */344  function buildPublicKeyCredential(cred: CreateCredential | GetCredential): PublicKeyCredential {345    const credentialId = base64urlToBuffer(cred.id);346    const clientDataJSON = new Uint8Array(cred.clientDataJSON).buffer;347348    if (cred.type === "create") {349      const attestationObject = base64urlToBuffer(cred.attestationObject);350      const authenticatorData = base64urlToBuffer(cred.authenticatorData);351      const publicKey = base64urlToBuffer(cred.publicKey);352      const obj = {353        id: cred.id,354        rawId: credentialId,355        type: "public-key" as const,356        authenticatorAttachment: "platform" as const,357        response: {358          clientDataJSON,359          attestationObject,360          getTransports: () => cloneInto(["internal"], window),361          getAuthenticatorData: () => cloneInto(authenticatorData, window),362          getPublicKey: () => cloneInto(publicKey, window),363          getPublicKeyAlgorithm: () => cred.algorithm ?? -7,364        } as AuthenticatorAttestationResponse,365        getClientExtensionResults: () => cloneInto({}, window),366        toJSON: () => cloneInto({367          id: cred.id,368          rawId: cred.id,369          type: "public-key" as const,370          response: {371            clientDataJSON: bufferToBase64url(clientDataJSON),372            attestationObject: cred.attestationObject,373            authenticatorData: cred.authenticatorData,374            publicKey: cred.publicKey,375            publicKeyAlgorithm: cred.algorithm ?? -7,376            transports: ["internal"],377          },378          authenticatorAttachment: "platform" as const,379          clientExtensionResults: {},380        }, window),381      } as unknown as PublicKeyCredential;382      return cloneInto(obj, window, { cloneFunctions: true });383    }384385    const authenticatorData = base64urlToBuffer(cred.authenticatorData);386    const signature = base64urlToBuffer(cred.signature);387    const userHandle = cred.userHandle ? base64urlToBuffer(cred.userHandle) : null;388    const obj = {389      id: cred.id,390      rawId: credentialId,391      type: "public-key" as const,392      authenticatorAttachment: "platform" as const,393      response: {394        clientDataJSON,395        authenticatorData,396        signature,397        userHandle,398      } as AuthenticatorAssertionResponse,399      getClientExtensionResults: () => cloneInto({}, window),400      toJSON: () => cloneInto({401        id: cred.id,402        rawId: cred.id,403        type: "public-key" as const,404        response: {405          clientDataJSON: bufferToBase64url(clientDataJSON),406          authenticatorData: cred.authenticatorData,407          signature: cred.signature,408          userHandle: cred.userHandle ?? null,409        },410        authenticatorAttachment: "platform" as const,411        clientExtensionResults: {},412      }, window),413    } as unknown as PublicKeyCredential;414    return cloneInto(obj, window, { cloneFunctions: true });415  }416}417418installPeachPasskeyProxy();