passkeys-firefox.ts

17.7 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        const hints = (options?.publicKey as (PublicKeyCredentialRequestOptions & { hints?: string[] }) | undefined)?.hints;167        if (!options?.publicKey || options.mediation === "conditional" || hints?.[0] === "security-key") {168          return originalGet(options);169        }170171        try {172          const publicKey = options.publicKey;173          const rpId = publicKey.rpId ?? window.location.hostname;174          const allowCredentials = (publicKey.allowCredentials ?? []).map((c) => ({175            id: bufferToBase64url(toArrayBuffer(c.id)),176            type: c.type,177          }));178179          // Only retry the read-only check; repeating GET could sign twice.180          let has: { ok: boolean; error?: string; hasCredentials?: boolean } | undefined;181          for (let attempt = 0; attempt < 3; attempt++) {182            try {183              has = await sendMessage({184                type: "PASSKEY_HAS_CREDENTIALS",185                payload: { rpId, allowCredentials },186              });187            } catch {188              // A suspended background may not have a receiver on the first delivery.189              has = undefined;190            }191            if (has) break;192            if (attempt < 2) await new Promise<void>((resolve) => window.setTimeout(resolve, 150));193          }194          if (has?.error === "not_applicable" || (has?.ok && has.hasCredentials === false)) {195            return originalGet(options);196          }197          if (has?.error !== "vault_locked" && !(has?.ok && has.hasCredentials === true)) {198            throw new window.DOMException("Peach could not connect to its passkey provider. Try again.", "NotAllowedError");199          }200201          const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));202          const clientDataJSON = encodeJsonBytes({203            type: "webauthn.get",204            challenge,205            origin: window.location.origin,206            crossOrigin: false,207          });208209          const response = await sendMessage({210            type: "PASSKEY_GET",211            payload: {212              rpId,213              challenge,214              clientDataJSON: Array.from(clientDataJSON),215              origin: window.location.origin,216              allowCredentials,217              userVerification: publicKey.userVerification,218            },219          });220221          if (!response?.ok) {222            const error = response?.error;223            if (error === "user_cancelled") {224              throw new window.DOMException(225                "The operation either timed out or was not allowed.",226                "NotAllowedError",227              );228            }229            if (error === "pro_required") {230              window.dispatchEvent(231                new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),232              );233              return originalGet(options);234            }235            if (error === "no_credentials" || error === "not_applicable") return originalGet(options);236            throw new window.DOMException("Peach could not complete this passkey request. Try again.", "NotAllowedError");237          }238239          const cred = validateGetCredential(response.credential);240          return buildPublicKeyCredential(cred);241        } catch (err) {242          if (err instanceof DOMException && err.name === "NotAllowedError") throw err;243          throw new window.DOMException("Peach could not complete this passkey request. Try again.", "NotAllowedError");244        }245      });246    },247    pageCredentials,248    { defineAs: "get" },249  );250251  // ── validation ────────────────────────────────────────────────────────252253  const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;254255  function isBase64url(value: unknown): value is string {256    return typeof value === "string" && value.length > 0 && BASE64URL_RE.test(value);257  }258259  function isByteArray(value: unknown): value is number[] {260    return (261      Array.isArray(value) &&262      value.length > 0 &&263      value.every((b) => typeof b === "number" && Number.isInteger(b) && b >= 0 && b <= 255)264    );265  }266267  function validateCreateCredential(cred: unknown): CreateCredential {268    if (!cred || typeof cred !== "object") throw new ValidationError("missing credential");269    const c = cred as Record<string, unknown>;270    if (c.type !== "create") throw new ValidationError("wrong credential type");271    if (!isBase64url(c.id)) throw new ValidationError("id must be base64url");272    if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array");273    if (!isBase64url(c.attestationObject)) throw new ValidationError("attestationObject must be base64url");274    if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url");275    if (!isBase64url(c.publicKey)) throw new ValidationError("publicKey must be base64url");276    if (c.algorithm !== -7 && c.algorithm !== -8) throw new ValidationError("unsupported algorithm");277    return c as unknown as CreateCredential;278  }279280  function validateGetCredential(cred: unknown): GetCredential {281    if (!cred || typeof cred !== "object") throw new ValidationError("missing credential");282    const c = cred as Record<string, unknown>;283    if (c.type !== "get") throw new ValidationError("wrong credential type");284    if (!isBase64url(c.id)) throw new ValidationError("id must be base64url");285    if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array");286    if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url");287    if (!isBase64url(c.signature)) throw new ValidationError("signature must be base64url");288    if (c.userHandle !== undefined && c.userHandle !== null && !isBase64url(c.userHandle)) {289      throw new ValidationError("userHandle must be base64url");290    }291    return c as unknown as GetCredential;292  }293294  // ── helpers ────────────────────────────────────────────────────────────295296  class ValidationError extends Error {297    constructor(message: string) {298      super(message);299      this.name = "ValidationError";300    }301  }302303  interface CreateCredential {304    type: "create";305    id: string;306    clientDataJSON: number[];307    attestationObject: string;308    authenticatorData: string;309    publicKey: string;310    algorithm: -7 | -8;311  }312313  interface GetCredential {314    type: "get";315    id: string;316    clientDataJSON: number[];317    authenticatorData: string;318    signature: string;319    userHandle: string | null;320  }321322  function toArrayBuffer(view: BufferSource): ArrayBuffer {323    const bytes = ArrayBuffer.isView(view)324      ? new Uint8Array(view.buffer, view.byteOffset, view.byteLength)325      : new Uint8Array(view);326    return new Uint8Array(bytes).buffer;327  }328329  function bufferToBase64url(buffer: ArrayBuffer): string {330    const bytes = new Uint8Array(buffer);331    let str = "";332    for (let i = 0; i < bytes.length; i += 1) str += String.fromCharCode(bytes[i]!);333    return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");334  }335336  function base64urlToBuffer(value: string): ArrayBuffer {337    const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(338      Math.ceil(value.length / 4) * 4,339      "=",340    );341    const raw = atob(padded);342    const bytes = new Uint8Array(raw.length);343    for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);344    return bytes.buffer;345  }346347  function encodeJsonBytes(value: unknown): Uint8Array {348    return new TextEncoder().encode(JSON.stringify(value));349  }350351  /**352   * Reconstruct a PublicKeyCredential-shaped object from the background's353   * serialized response. `cloneInto` makes the object and its methods354   * callable from page-world — without it the page would see isolated-world355   * objects through an Xray wrapper and could not call the getter methods.356   *357   * `instanceof PublicKeyCredential` will be false because we cannot358   * construct a real one from content script. RPs that perform that check359   * directly will reject — but no major RP does. RPs read the duck-typed360   * shape.361   */362  function buildPublicKeyCredential(cred: CreateCredential | GetCredential): PublicKeyCredential {363    const credentialId = base64urlToBuffer(cred.id);364    const clientDataJSON = new Uint8Array(cred.clientDataJSON).buffer;365366    if (cred.type === "create") {367      const attestationObject = base64urlToBuffer(cred.attestationObject);368      const authenticatorData = base64urlToBuffer(cred.authenticatorData);369      const publicKey = base64urlToBuffer(cred.publicKey);370      const obj = {371        id: cred.id,372        rawId: credentialId,373        type: "public-key" as const,374        authenticatorAttachment: "platform" as const,375        response: {376          clientDataJSON,377          attestationObject,378          getTransports: () => cloneInto(["internal"], window),379          getAuthenticatorData: () => cloneInto(authenticatorData, window),380          getPublicKey: () => cloneInto(publicKey, window),381          getPublicKeyAlgorithm: () => cred.algorithm ?? -7,382        } as AuthenticatorAttestationResponse,383        getClientExtensionResults: () => cloneInto({}, window),384        toJSON: () => cloneInto({385          id: cred.id,386          rawId: cred.id,387          type: "public-key" as const,388          response: {389            clientDataJSON: bufferToBase64url(clientDataJSON),390            attestationObject: cred.attestationObject,391            authenticatorData: cred.authenticatorData,392            publicKey: cred.publicKey,393            publicKeyAlgorithm: cred.algorithm ?? -7,394            transports: ["internal"],395          },396          authenticatorAttachment: "platform" as const,397          clientExtensionResults: {},398        }, window),399      } as unknown as PublicKeyCredential;400      return cloneInto(obj, window, { cloneFunctions: true });401    }402403    const authenticatorData = base64urlToBuffer(cred.authenticatorData);404    const signature = base64urlToBuffer(cred.signature);405    const userHandle = cred.userHandle ? base64urlToBuffer(cred.userHandle) : null;406    const obj = {407      id: cred.id,408      rawId: credentialId,409      type: "public-key" as const,410      authenticatorAttachment: "platform" as const,411      response: {412        clientDataJSON,413        authenticatorData,414        signature,415        userHandle,416      } as AuthenticatorAssertionResponse,417      getClientExtensionResults: () => cloneInto({}, window),418      toJSON: () => cloneInto({419        id: cred.id,420        rawId: cred.id,421        type: "public-key" as const,422        response: {423          clientDataJSON: bufferToBase64url(clientDataJSON),424          authenticatorData: cred.authenticatorData,425          signature: cred.signature,426          userHandle: cred.userHandle ?? null,427        },428        authenticatorAttachment: "platform" as const,429        clientExtensionResults: {},430      }, window),431    } as unknown as PublicKeyCredential;432    return cloneInto(obj, window, { cloneFunctions: true });433  }434}435436installPeachPasskeyProxy();