firefox-passkey.ts

4.0 KB · raw

1/**2 * firefox-passkey.ts — popup-context WebAuthn caller for the Firefox path.3 *4 * Runs inside the extension popup (the only context, on Firefox 150+, where5 * `navigator.credentials.create` accepts a host-permitted RP ID supplied by6 * the extension). The background handler `INITIATE_FIREFOX_PASSKEY_CREATE`7 * stashes a pending request in session storage; this module reads it,8 * triggers the platform authenticator, and sends the resulting credential9 * metadata back to the background via `COMPLETE_FIREFOX_PASSKEY_CREATE`.10 *11 * The private key never leaves the platform authenticator (Touch ID,12 * Windows Hello, security key). Peach stores only public metadata.13 */1415import { sendMessage } from "./App";16import type { PendingPasskeyRequest } from "../shared/types";1718function base64urlEncode(bytes: Uint8Array | ArrayBuffer): string {19  const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);20  let str = "";21  for (let i = 0; i < u8.length; i++) str += String.fromCharCode(u8[i]!);22  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");23}2425function base64urlDecode(value: string): ArrayBuffer {26  const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4);27  const bin = atob(padded);28  const buf = new ArrayBuffer(bin.length);29  const view = new Uint8Array(buf);30  for (let i = 0; i < bin.length; i++) view[i] = bin.charCodeAt(i);31  return buf;32}3334export async function createFirefoxPasskey(35  pending: PendingPasskeyRequest36): Promise<{ ok: boolean; error?: string }> {37  if (!pending.challenge || !pending.userHandle) {38    return { ok: false, error: "Firefox pending request missing challenge or userHandle" };39  }4041  const challenge = base64urlDecode(pending.challenge);42  const userHandle = base64urlDecode(pending.userHandle);4344  let credential: PublicKeyCredential;45  try {46    const result = await navigator.credentials.create({47      publicKey: {48        challenge,49        rp: { id: pending.rpId, name: pending.rpName },50        user: {51          id: userHandle,52          name: pending.userName,53          displayName: pending.userDisplayName ?? pending.userName,54        },55        pubKeyCredParams: [56          { type: "public-key", alg: -7 },  // ES25657          { type: "public-key", alg: -8 },  // EdDSA58        ],59        authenticatorSelection: {60          residentKey: "preferred",61          userVerification: "preferred",62        },63        timeout: 60_000,64      },65    });66    if (!result || !(result instanceof PublicKeyCredential)) {67      return { ok: false, error: "no credential returned" };68    }69    credential = result;70  } catch (err) {71    if (err instanceof DOMException && err.name === "NotAllowedError") {72      return { ok: false, error: "user cancelled" };73    }74    return { ok: false, error: err instanceof Error ? err.message : "credential creation failed" };75  }7677  const response = credential.response as AuthenticatorAttestationResponse;78  const algorithm = response.getPublicKeyAlgorithm() === -8 ? -8 : -7;79  const publicKeySpki = response.getPublicKey();80  const transports = typeof response.getTransports === "function"81    ? response.getTransports()82    : ["internal"];8384  const completion = await sendMessage({85    type: "COMPLETE_FIREFOX_PASSKEY_CREATE",86    payload: {87      requestId: pending.requestId,88      rpId: pending.rpId,89      rpName: pending.rpName,90      userName: pending.userName,91      userDisplayName: pending.userDisplayName,92      userHandle: pending.userHandle,93      credentialId: credential.id,94      // SPKI-formatted public key, not strictly COSE. Stored for reference;95      // Peach does not sign assertions for Firefox passkeys (the platform96      // authenticator handles that on every use).97      publicKeyCose: publicKeySpki ? base64urlEncode(publicKeySpki) : "",98      algorithm,99      transports,100      backupEligible: false,101      backupState: false,102    },103  });104105  if (!completion?.ok) {106    return { ok: false, error: completion?.error ?? "vault store failed" };107  }108  return { ok: true };109}