test-firefox-passkeys.mjs

11.1 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. Check no PEACH_PASSKEY_RESPONSE postMessage listener exists.132  //    Dispatch a forged response and verify no handler picks it up.133  let forgedResponseReceived = false;134  const forgedListener = (event) => {135    if (event.data?.type === "PEACH_PASSKEY_RESPONSE") {136      forgedResponseReceived = true;137    }138  };139  window.addEventListener("message", forgedListener);140  window.postMessage(141    { type: "PEACH_PASSKEY_RESPONSE", id: "forged-uuid-test", payload: { ok: true } },142    window.location.origin,143  );144  await new Promise((r) => setTimeout(r, 200));145  window.removeEventListener("message", forgedListener);146147  // 3. Verify no PEACH_PASSKEY_REQUEST postMessage is used by the bridge.148  //    We check the page for any existing listeners that post PEACH_PASSKEY_REQUEST.149  const pageSourceHasRelay = document.documentElement.innerHTML.includes("PEACH_PASSKEY_REQUEST");150151  // 4. Call navigator.credentials.create with a publicKey request.152  //    With a locked vault, the bridge should fall through to native153  //    (return null in headless Firefox without a real authenticator).154  let createResult = null;155  let createError = null;156  try {157    createResult = await navigator.credentials.create({158      publicKey: {159        rp: { id: "example.com", name: "Test" },160        user: { id: new Uint8Array(16), name: "test", displayName: "Test" },161        challenge: new Uint8Array(16),162        pubKeyCredParams: [{ alg: -7, type: "public-key" }],163      },164    });165  } catch (e) {166    createError = e?.name ?? String(e);167  }168169  // 5. Call navigator.credentials.get — should also fall through.170  let getResult = null;171  let getError = null;172  try {173    getResult = await navigator.credentials.get({174      publicKey: {175        rpId: "example.com",176        challenge: new Uint8Array(16),177        allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }],178      },179    });180  } catch (e) {181    getError = e?.name ?? String(e);182  }183184  done({185    pageOrigin: location.origin,186    createIsNative,187    getIsNative,188    forgedResponseReceived,189    pageSourceHasRelay,190    createResult,191    createError,192    getResult,193    getError,194  });195`;196197async function run() {198  if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");199  if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");200201  const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-passkey-"));202  let driver;203  let driverPort;204  let sessionId;205  try {206    const xpiPath = await buildTestExtension(tempRoot);207    const firefoxBinary = findFirefoxBinary();208    const port = await findFreePort();209    driverPort = port;210    driver = await start({211      host: "127.0.0.1",212      port,213      geckoDriverVersion: "0.37.1",214      ...(firefoxBinary ? { binary: firefoxBinary } : {}),215      log: "error",216    });217    await waitForDriver(port);218219    const capabilities = {220      browserName: "firefox",221      acceptInsecureCerts: true,222      "moz:firefoxOptions": {223        args: ["-headless"],224        ...(firefoxBinary ? { binary: firefoxBinary } : {}),225        prefs: {226          "browser.shell.checkDefaultBrowser": false,227          "browser.startup.page": 0,228          "browser.startup.homepage": "about:blank",229        },230      },231    };232    const session = await webdriverRequest(port, "/session", {233      method: "POST",234      body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),235    });236    sessionId = session.sessionId;237238    // Install the temporary add-on.239    await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {240      method: "POST",241      body: JSON.stringify({242        addon: (await readFile(xpiPath)).toString("base64"),243        temporary: true,244      }),245    });246247    // Navigate to a test page and wait for the content script to install.248    await webdriverRequest(port, `/session/${sessionId}/url`, {249      method: "POST",250      body: JSON.stringify({ url: "https://example.com/" }),251    });252    // Give the content script time to run at document_start.253    await new Promise((r) => setTimeout(r, 2000));254255    const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {256      method: "POST",257      body: JSON.stringify({ script: PROBE_SCRIPT, args: [] }),258    });259260    if (result.pageOrigin !== "https://example.com") {261      throw new Error(`Unexpected page origin: ${result.pageOrigin}`);262    }263264    const failures = [];265266    // 1. navigator.credentials.create must be wrapped (not native).267    if (result.createIsNative) {268      failures.push("navigator.credentials.create is not wrapped by exportFunction — the privileged bridge was not installed");269    }270271    // 2. navigator.credentials.get must be wrapped (not native).272    if (result.getIsNative) {273      failures.push("navigator.credentials.get is not wrapped by exportFunction — the privileged bridge was not installed");274    }275276    // 3. No forgeable PEACH_PASSKEY_RESPONSE channel.277    if (result.forgedResponseReceived) {278      failures.push("a PEACH_PASSKEY_RESPONSE postMessage listener exists — the forgeable relay was not removed");279    }280281    // 4. No PEACH_PASSKEY_REQUEST in page source.282    if (result.pageSourceHasRelay) {283      failures.push("PEACH_PASSKEY_REQUEST found in page source — the old relay may still be present");284    }285286    // 5. Create with locked vault should fall through (null or NotAllowedError287    //    from native authenticator, not a crash/hang).288    if (result.createError && result.createError !== "NotAllowedError" && result.createError !== "AbortError") {289      failures.push(`navigator.credentials.create threw unexpected error: ${result.createError}`);290    }291292    // 6. Get with locked vault should fall through.293    if (result.getError && result.getError !== "NotAllowedError" && result.getError !== "AbortError") {294      failures.push(`navigator.credentials.get threw unexpected error: ${result.getError}`);295    }296297    if (failures.length > 0) {298      throw new Error(`Firefox passkey matrix failures:\n  - ${failures.join("\n  - ")}`);299    }300301    console.log("Firefox passkey matrix passed: exportFunction bridge installed, no forgeable relay, bridge falls through with locked vault.");302  } finally {303    if (driver && sessionId) {304      try {305        await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });306      } catch {307        // Browser may already have exited.308      }309    }310    driver?.kill("SIGTERM");311    await rm(tempRoot, { recursive: true, force: true });312  }313}314315run().catch((error) => {316  console.error(error instanceof Error ? error.message : error);317  process.exitCode = 1;318});