test-firefox-passkeys.mjs

10.2 KB · raw

1#!/usr/bin/env node23// PB-26-210: mandatory Firefox release browser gate.4// Tests the PB-26-209 exportFunction privileged passkey bridge in real Firefox.5//6// If Firefox/Marionette cannot launch, this script exits non-zero — the gate7// FAILS rather than silently skipping.8//9// Security properties verified:10// 1. navigator.credentials.create is wrapped by exportFunction (not native).11// 2. navigator.credentials.get is wrapped by exportFunction (not native).12// 3. No forgeable PEACH_PASSKEY_RESPONSE postMessage channel exists.13// 4. The bridge falls through to native when vault is locked (no crash/hang).14// 5. The page cannot forge the extension's response via postMessage.1516import { execFile } from "node:child_process";17import { promisify } from "node:util";18import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";19import { existsSync } from "node:fs";20import { tmpdir } from "node:os";21import { join, resolve } from "node:path";22import net from "node:net";23import { start } from "geckodriver";2425const execFileAsync = promisify(execFile);26const ROOT = resolve(new URL("..", import.meta.url).pathname);27const DIST = join(ROOT, "dist-firefox");28const WEB_EXT = join(ROOT, "node_modules/.bin/web-ext");2930function findFirefoxBinary() {31  const candidates = [32    process.env.PEACH_FIREFOX_BINARY,33    process.env.FIREFOX_BIN,34    "/usr/bin/firefox",35    "/usr/local/bin/firefox",36    "/Applications/Firefox.app/Contents/MacOS/firefox",37    "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",38  ].filter(Boolean);39  return candidates.find((c) => existsSync(c));40}4142function findFreePort() {43  return new Promise((resolvePort, reject) => {44    const server = net.createServer();45    server.once("error", reject);46    server.listen(0, "127.0.0.1", () => {47      const address = server.address();48      if (!address || typeof address === "string") {49        server.close();50        reject(new Error("Could not allocate a WebDriver port"));51        return;52      }53      server.close(() => resolvePort(address.port));54    });55  });56}5758async function waitForDriver(port) {59  const deadline = Date.now() + 30_000;60  while (Date.now() < deadline) {61    try {62      const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 500);63      if (response.ok && (await response.json()).value?.ready) return;64    } catch {65      // geckodriver is still starting.66    }67    await new Promise((r) => setTimeout(r, 250));68  }69  throw new Error("geckodriver did not become ready within 30 seconds");70}7172async function fetchWithTimeout(url, options, timeoutMs) {73  const controller = new AbortController();74  let timeoutId;75  const timeout = new Promise((_, reject) => {76    timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);77  });78  try {79    return await Promise.race([80      fetch(url, { ...options, signal: controller.signal }),81      timeout,82    ]);83  } finally {84    clearTimeout(timeoutId);85    controller.abort();86  }87}8889async function webdriverRequest(port, path, options = {}) {90  const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {91    ...options,92    headers: { "content-type": "application/json", ...(options.headers ?? {}) },93  }, 10_000);94  const body = await response.json();95  if (!response.ok || body.value?.error) {96    throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);97  }98  return body.value;99}100101async function buildTestExtension(tempRoot) {102  const sourceDir = join(tempRoot, "extension");103  const artifactsDir = join(tempRoot, "artifacts");104  await cp(DIST, sourceDir, { recursive: true });105  await mkdir(artifactsDir, { recursive: true });106107  await execFileAsync(WEB_EXT, [108    "build",109    "--source-dir", sourceDir,110    "--artifacts-dir", artifactsDir,111    "--filename", "peach-firefox-passkey-matrix.xpi",112    "--overwrite-dest",113    "--no-input",114  ], { cwd: ROOT });115  return join(artifactsDir, "peach-firefox-passkey-matrix.xpi");116}117118const PROBE_SCRIPT = `119  const done = arguments[arguments.length - 1];120121  // 1. Check if navigator.credentials.create is wrapped by exportFunction.122  //    The wrapped function is from the page world via exportFunction, so123  //    it will NOT be the native function. We check by calling toString()124  //    — a native function returns "function create() { [native code] }"125  //    while an exportFunction wrapper returns something else.126  const createStr = navigator.credentials.create.toString();127  const getStr = navigator.credentials.get.toString();128  const createIsNative = createStr.includes("[native code]");129  const getIsNative = getStr.includes("[native code]");130131  // 2. Verify no PEACH_PASSKEY_REQUEST postMessage is used by the bridge.132  //    We check the page for any existing listeners that post PEACH_PASSKEY_REQUEST.133  const pageSourceHasRelay = document.documentElement.innerHTML.includes("PEACH_PASSKEY_REQUEST");134135  // 4. Call navigator.credentials.create with a publicKey request.136  //    With a locked vault, the bridge should fall through to native137  //    (return null in headless Firefox without a real authenticator).138  let createResult = null;139  let createError = null;140  try {141    createResult = await navigator.credentials.create({142      publicKey: {143        rp: { id: "example.com", name: "Test" },144        user: { id: new Uint8Array(16), name: "test", displayName: "Test" },145        challenge: new Uint8Array(16),146        pubKeyCredParams: [{ alg: -7, type: "public-key" }],147      },148    });149  } catch (e) {150    createError = e?.name ?? String(e);151  }152153  // 5. Call navigator.credentials.get — should also fall through.154  let getResult = null;155  let getError = null;156  try {157    getResult = await navigator.credentials.get({158      publicKey: {159        rpId: "example.com",160        challenge: new Uint8Array(16),161        allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }],162      },163    });164  } catch (e) {165    getError = e?.name ?? String(e);166  }167168  done({169    pageOrigin: location.origin,170    createIsNative,171    getIsNative,172    pageSourceHasRelay,173    createResult,174    createError,175    getResult,176    getError,177  });178`;179180async function run() {181  if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");182  if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");183184  const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-passkey-"));185  let driver;186  let driverPort;187  let sessionId;188  try {189    const xpiPath = await buildTestExtension(tempRoot);190    const firefoxBinary = findFirefoxBinary();191    const port = await findFreePort();192    driverPort = port;193    driver = await start({194      host: "127.0.0.1",195      port,196      geckoDriverVersion: "0.37.1",197      ...(firefoxBinary ? { binary: firefoxBinary } : {}),198      log: "error",199    });200    await waitForDriver(port);201202    const capabilities = {203      browserName: "firefox",204      acceptInsecureCerts: true,205      "moz:firefoxOptions": {206        args: ["-headless"],207        ...(firefoxBinary ? { binary: firefoxBinary } : {}),208        prefs: {209          "browser.shell.checkDefaultBrowser": false,210          "browser.startup.page": 0,211          "browser.startup.homepage": "about:blank",212        },213      },214    };215    const session = await webdriverRequest(port, "/session", {216      method: "POST",217      body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),218    });219    sessionId = session.sessionId;220221    // Install the temporary add-on.222    await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {223      method: "POST",224      body: JSON.stringify({225        addon: (await readFile(xpiPath)).toString("base64"),226        temporary: true,227      }),228    });229230    // Navigate to a test page and wait for the content script to install.231    await webdriverRequest(port, `/session/${sessionId}/url`, {232      method: "POST",233      body: JSON.stringify({ url: "https://example.com/" }),234    });235    // Give the content script time to run at document_start.236    await new Promise((r) => setTimeout(r, 2000));237238    const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {239      method: "POST",240      body: JSON.stringify({ script: PROBE_SCRIPT, args: [] }),241    });242243    if (result.pageOrigin !== "https://example.com") {244      throw new Error(`Unexpected page origin: ${result.pageOrigin}`);245    }246247    const failures = [];248249    // 1. navigator.credentials.create must be wrapped (not native).250    if (result.createIsNative) {251      failures.push("navigator.credentials.create is not wrapped by exportFunction — the privileged bridge was not installed");252    }253254    // 2. navigator.credentials.get must be wrapped (not native).255    if (result.getIsNative) {256      failures.push("navigator.credentials.get is not wrapped by exportFunction — the privileged bridge was not installed");257    }258259    // 4. No PEACH_PASSKEY_REQUEST in page source.260    if (result.pageSourceHasRelay) {261      failures.push("PEACH_PASSKEY_REQUEST found in page source — the old relay may still be present");262    }263264    // 5. Create with locked vault should fall through (null or NotAllowedError265    //    from native authenticator, not a crash/hang).266    if (result.createError && result.createError !== "NotAllowedError" && result.createError !== "AbortError") {267      failures.push(`navigator.credentials.create threw unexpected error: ${result.createError}`);268    }269270    // 6. Get with locked vault should fall through.271    if (result.getError && result.getError !== "NotAllowedError" && result.getError !== "AbortError") {272      failures.push(`navigator.credentials.get threw unexpected error: ${result.getError}`);273    }274275    if (failures.length > 0) {276      throw new Error(`Firefox passkey matrix failures:\n  - ${failures.join("\n  - ")}`);277    }278279    console.log("Firefox passkey matrix passed: exportFunction bridge installed, no forgeable relay, bridge falls through with locked vault.");280  } finally {281    if (driver && sessionId) {282      try {283        await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });284      } catch {285        // Browser may already have exited.286      }287    }288    driver?.kill("SIGTERM");289    await rm(tempRoot, { recursive: true, force: true });290  }291}292293run().catch((error) => {294  console.error(error instanceof Error ? error.message : error);295  process.exitCode = 1;296});