test-firefox-passkeys.mjs

14.6 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 { createServer as createHttpServer } from "node:http";21import { tmpdir } from "node:os";22import { join, resolve } from "node:path";23import net from "node:net";24import { start } from "geckodriver";2526const execFileAsync = promisify(execFile);27const ROOT = resolve(new URL("..", import.meta.url).pathname);28const DIST = join(ROOT, "dist-firefox");29const WEB_EXT = join(ROOT, "node_modules/.bin/web-ext");30const TEST_BACKGROUND = join(ROOT, "tests/fixtures/firefox-passkey-background.js");31const CONTEXT_PROBE = join(ROOT, "tests/fixtures/firefox-passkey-context-probe.js");32const CREATE_CREDENTIAL_ID = "AQIDBA";33const GET_CREDENTIAL_ID = "BQYHCA";34const EXTENSION_ID = "[email protected]";3536function findFirefoxBinary() {37  const candidates = [38    process.env.PEACH_FIREFOX_BINARY,39    process.env.FIREFOX_BIN,40    "/usr/bin/firefox",41    "/usr/local/bin/firefox",42    "/Applications/Firefox.app/Contents/MacOS/firefox",43    "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",44  ].filter(Boolean);45  return candidates.find((c) => existsSync(c));46}4748function findFreePort() {49  return new Promise((resolvePort, reject) => {50    const server = net.createServer();51    server.once("error", reject);52    server.listen(0, "127.0.0.1", () => {53      const address = server.address();54      if (!address || typeof address === "string") {55        server.close();56        reject(new Error("Could not allocate a WebDriver port"));57        return;58      }59      server.close(() => resolvePort(address.port));60    });61  });62}6364async function waitForDriver(port) {65  const deadline = Date.now() + 30_000;66  while (Date.now() < deadline) {67    try {68      const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 500);69      if (response.ok && (await response.json()).value?.ready) return;70    } catch {71      // geckodriver is still starting.72    }73    await new Promise((r) => setTimeout(r, 250));74  }75  throw new Error("geckodriver did not become ready within 30 seconds");76}7778async function fetchWithTimeout(url, options, timeoutMs) {79  const controller = new AbortController();80  let timeoutId;81  const timeout = new Promise((_, reject) => {82    timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);83  });84  try {85    return await Promise.race([86      fetch(url, { ...options, signal: controller.signal }),87      timeout,88    ]);89  } finally {90    clearTimeout(timeoutId);91    controller.abort();92  }93}9495async function webdriverRequest(port, path, options = {}) {96  const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {97    ...options,98    headers: { "content-type": "application/json", ...(options.headers ?? {}) },99  }, 10_000);100  const body = await response.json();101  if (!response.ok || body.value?.error) {102    throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);103  }104  return body.value;105}106107async function buildTestExtension(tempRoot) {108  const sourceDir = join(tempRoot, "extension");109  const artifactsDir = join(tempRoot, "artifacts");110  await cp(DIST, sourceDir, { recursive: true });111  await mkdir(artifactsDir, { recursive: true });112  await cp(TEST_BACKGROUND, join(sourceDir, "firefox-passkey-background.js"));113  await cp(CONTEXT_PROBE, join(sourceDir, "firefox-passkey-context-probe.js"));114115  const manifestPath = join(sourceDir, "manifest.json");116  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));117  manifest.background = {118    scripts: ["firefox-passkey-background.js"],119  };120  manifest.content_scripts.push({121    matches: ["<all_urls>"],122    js: ["firefox-passkey-context-probe.js"],123    run_at: "document_start",124  });125  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);126127  await execFileAsync(WEB_EXT, [128    "build",129    "--source-dir", sourceDir,130    "--artifacts-dir", artifactsDir,131    "--filename", "peach-firefox-passkey-matrix.xpi",132    "--overwrite-dest",133    "--no-input",134  ], { cwd: ROOT });135  return join(artifactsDir, "peach-firefox-passkey-matrix.xpi");136}137138const PROBE_SCRIPT = `139  const done = arguments[arguments.length - 1];140  void (async () => {141142  // Verify no PEACH_PASSKEY_REQUEST postMessage is used by the bridge.143  //    We check the page for any existing listeners that post PEACH_PASSKEY_REQUEST.144  const pageSourceHasRelay = document.documentElement.innerHTML.includes("PEACH_PASSKEY_REQUEST");145146  const passkeyMessages = [];147  window.addEventListener("message", (event) => {148    if (event.data?.type === "PEACH_PASSKEY_REQUEST" || event.data?.type === "PEACH_PASSKEY_RESPONSE") {149      passkeyMessages.push(event.data.type);150    }151  });152153  // 4. Exercise both wrappers against the test-only background responder.154  //    Abort quickly if the native authenticator receives either request.155  const createAbort = new AbortController();156  const createTimer = setTimeout(() => createAbort.abort(), 2000);157  let createResult = null;158  let createError = null;159  try {160    const credential = await navigator.credentials.create({161      signal: createAbort.signal,162      publicKey: {163        rp: { id: "127.0.0.1", name: "Test" },164        user: { id: new Uint8Array(16), name: "test", displayName: "Test" },165        challenge: new Uint8Array(16),166        pubKeyCredParams: [{ alg: -7, type: "public-key" }],167      },168    });169    createResult = credential ? { id: credential.id, type: credential.type } : null;170  } catch (e) {171    createError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null };172  } finally {173    clearTimeout(createTimer);174  }175176  const getAbort = new AbortController();177  const getTimer = setTimeout(() => getAbort.abort(), 2000);178  let getResult = null;179  let getError = null;180  try {181    const credential = await navigator.credentials.get({182      signal: getAbort.signal,183      publicKey: {184        rpId: "127.0.0.1",185        challenge: new Uint8Array(16),186        allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }],187      },188    });189    getResult = credential ? { id: credential.id, type: credential.type } : null;190  } catch (e) {191    getError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null };192  } finally {193    clearTimeout(getTimer);194  }195196  done({197    pageOrigin: location.origin,198    context: JSON.parse(document.documentElement.dataset.peachFirefoxPasskeyContext ?? "null"),199    pageSourceHasRelay,200    createResult,201    createError,202    getResult,203    getError,204    passkeyMessages,205  });206  })().catch((error) => done({207    probeError: error?.stack ?? error?.message ?? String(error),208  }));209`;210211async function run() {212  if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");213  if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");214215  const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-passkey-"));216  let driver;217  let driverPort;218  let sessionId;219  let fixtureServer;220  try {221    const xpiPath = await buildTestExtension(tempRoot);222    const firefoxBinary = findFirefoxBinary();223    const port = await findFreePort();224    driverPort = port;225    driver = await start({226      host: "127.0.0.1",227      port,228      geckoDriverVersion: "0.37.1",229      ...(firefoxBinary ? { binary: firefoxBinary } : {}),230      log: "error",231    });232    await waitForDriver(port);233234    const capabilities = {235      browserName: "firefox",236      acceptInsecureCerts: true,237      "moz:firefoxOptions": {238        args: ["-headless"],239        ...(firefoxBinary ? { binary: firefoxBinary } : {}),240        prefs: {241          "browser.shell.checkDefaultBrowser": false,242          "browser.startup.page": 0,243          "browser.startup.homepage": "about:blank",244          "remote.system-access-check.enabled": false,245        },246      },247    };248    const session = await webdriverRequest(port, "/session", {249      method: "POST",250      body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),251    });252    sessionId = session.sessionId;253254    fixtureServer = createHttpServer((_request, response) => {255      response.writeHead(200, { "content-type": "text/html; charset=utf-8" });256      response.end("<!doctype html><html><head><title>Peach passkey test</title></head><body></body></html>");257    });258    await new Promise((resolveListen, rejectListen) => {259      fixtureServer.once("error", rejectListen);260      fixtureServer.listen(0, "127.0.0.1", resolveListen);261    });262    const fixtureAddress = fixtureServer.address();263    if (!fixtureAddress || typeof fixtureAddress === "string") {264      throw new Error("Could not allocate the Firefox fixture server port");265    }266    const fixtureOrigin = `http://127.0.0.1:${fixtureAddress.port}`;267268    // Install the temporary add-on.269    const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {270      method: "POST",271      body: JSON.stringify({272        path: xpiPath,273        temporary: true,274      }),275    });276    if (install?.id && install.id !== EXTENSION_ID) {277      throw new Error(`Unexpected Firefox add-on id: ${install.id}`);278    }279280    await webdriverRequest(port, `/session/${sessionId}/moz/context`, {281      method: "POST",282      body: JSON.stringify({ context: "chrome" }),283    });284    const addonInfo = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {285      method: "POST",286      body: JSON.stringify({287        script: `288          const done = arguments[arguments.length - 1];289          const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");290          const { ExtensionPermissions } = ChromeUtils.importESModule("resource://gre/modules/ExtensionPermissions.sys.mjs");291          AddonManager.getAddonByID(arguments[0]).then(async (addon) => {292            if (!addon) return null;293            const policy = WebExtensionPolicy.getByID(addon.id);294            await ExtensionPermissions.add(295              addon.id,296              { permissions: [], origins: ["<all_urls>"] },297              policy.extension,298            );299            await addon.reload();300            const reloadedAddon = await AddonManager.getAddonByID(addon.id);301            const reloadedPolicy = WebExtensionPolicy.getByID(addon.id);302            return {303              id: reloadedAddon.id,304              active: reloadedAddon.isActive,305              permissions: await ExtensionPermissions.get(addon.id),306              allowedOrigins: reloadedPolicy.allowedOrigins.patterns.map((pattern) => pattern.pattern),307            };308          }).then(done, (error) => done({ error: String(error), stack: error?.stack }));309        `,310        args: [EXTENSION_ID],311      }),312    });313    await webdriverRequest(port, `/session/${sessionId}/moz/context`, {314      method: "POST",315      body: JSON.stringify({ context: "content" }),316    });317    if (!addonInfo?.active || !addonInfo.permissions?.origins?.includes("<all_urls>")) {318      throw new Error(`Firefox add-on host permission was not granted: ${JSON.stringify(addonInfo)}`);319    }320321    // Navigate to a test page and wait for the content script to install.322    await webdriverRequest(port, `/session/${sessionId}/url`, {323      method: "POST",324      body: JSON.stringify({ url: `${fixtureOrigin}/` }),325    });326    // Give the content script time to run at document_start.327    await new Promise((r) => setTimeout(r, 2000));328329    const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {330      method: "POST",331      body: JSON.stringify({ script: PROBE_SCRIPT, args: [] }),332    });333334    if (result.pageOrigin !== fixtureOrigin) {335      throw new Error(`Unexpected page origin: ${result.pageOrigin}`);336    }337338    const failures = [];339340    if (result.probeError) {341      failures.push(`page probe failed: ${result.probeError}`);342    }343344    if (result.context?.exportFunctionType !== "function" || result.context?.cloneIntoType !== "function") {345      failures.push(`Firefox privileged bridge APIs were unavailable: ${JSON.stringify(result.context)}`);346    }347348    if (result.context?.installGuard !== true) {349      failures.push(`Firefox passkey bridge did not install: ${JSON.stringify(result.context)}`);350    }351352    // No PEACH_PASSKEY_REQUEST in page source.353    if (result.pageSourceHasRelay) {354      failures.push("PEACH_PASSKEY_REQUEST found in page source — the old relay may still be present");355    }356357    if (result.createResult?.id !== CREATE_CREDENTIAL_ID || result.createResult?.type !== "public-key") {358      failures.push(`navigator.credentials.create did not return the extension credential: ${JSON.stringify({ result: result.createResult, error: result.createError })}`);359    }360361    if (result.getResult?.id !== GET_CREDENTIAL_ID || result.getResult?.type !== "public-key") {362      failures.push(`navigator.credentials.get did not return the extension credential: ${JSON.stringify({ result: result.getResult, error: result.getError })}`);363    }364365    if (result.passkeyMessages?.length > 0) {366      failures.push(`page observed passkey postMessage traffic: ${JSON.stringify(result.passkeyMessages)}`);367    }368369    if (failures.length > 0) {370      throw new Error(`Firefox passkey matrix failures:\n  - ${failures.join("\n  - ")}\n  Context: ${JSON.stringify(result.context)}\n  Add-on: ${JSON.stringify(addonInfo)}`);371    }372373    console.log("Firefox passkey matrix passed: exportFunction bridge returned extension credentials and emitted no forgeable postMessage traffic.");374  } finally {375    if (driver && sessionId) {376      try {377        await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });378      } catch {379        // Browser may already have exited.380      }381    }382    if (fixtureServer) {383      await new Promise((resolveClose) => fixtureServer.close(resolveClose));384    }385    driver?.kill("SIGTERM");386    await rm(tempRoot, { recursive: true, force: true });387  }388}389390run().catch((error) => {391  console.error(error instanceof Error ? error.message : error);392  process.exitCode = 1;393});