test-firefox-passkeys.mjs

19.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 SENDER_PROBE = join(ROOT, "tests/fixtures/firefox-sender-probe.js");33const CREATE_CREDENTIAL_ID = "AQIDBA";34const GET_CREDENTIAL_ID = "BQYHCA";35const EXTENSION_ID = "[email protected]";3637function findFirefoxBinary() {38  const candidates = [39    process.env.PEACH_FIREFOX_BINARY,40    process.env.FIREFOX_BIN,41    "/usr/bin/firefox",42    "/usr/local/bin/firefox",43    "/Applications/Firefox.app/Contents/MacOS/firefox",44    "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",45  ].filter(Boolean);46  return candidates.find((c) => existsSync(c));47}4849function findFreePort() {50  return new Promise((resolvePort, reject) => {51    const server = net.createServer();52    server.once("error", reject);53    server.listen(0, "127.0.0.1", () => {54      const address = server.address();55      if (!address || typeof address === "string") {56        server.close();57        reject(new Error("Could not allocate a WebDriver port"));58        return;59      }60      server.close(() => resolvePort(address.port));61    });62  });63}6465async function waitForDriver(port) {66  const deadline = Date.now() + 30_000;67  while (Date.now() < deadline) {68    try {69      const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 5000);70      if (response.ok && (await response.json()).value?.ready) return;71    } catch {72      // geckodriver is still starting.73    }74    await new Promise((r) => setTimeout(r, 250));75  }76  throw new Error("geckodriver did not become ready within 30 seconds");77}7879async function fetchWithTimeout(url, options, timeoutMs) {80  const controller = new AbortController();81  let timeoutId;82  const timeout = new Promise((_, reject) => {83    timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);84  });85  try {86    return await Promise.race([87      fetch(url, { ...options, signal: controller.signal }),88      timeout,89    ]);90  } catch (error) {91    controller.abort();92    throw error;93  } finally {94    clearTimeout(timeoutId);95  }96}9798async function webdriverRequest(port, path, options = {}) {99  const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {100    ...options,101    headers: { "content-type": "application/json", ...(options.headers ?? {}) },102  }, 10_000);103  const body = await response.json();104  if (!response.ok || body.value?.error) {105    throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);106  }107  return body.value;108}109110async function stopDriver(driver) {111  if (!driver || driver.exitCode !== null || driver.signalCode !== null) return;112  const exited = new Promise((resolveExit) => driver.once("exit", resolveExit));113  driver.kill("SIGTERM");114  let timeoutId;115  const stopped = await Promise.race([116    exited.then(() => true),117    new Promise((resolveTimeout) => {118      timeoutId = setTimeout(() => resolveTimeout(false), 5000);119    }),120  ]);121  clearTimeout(timeoutId);122  if (!stopped && driver.exitCode === null && driver.signalCode === null) {123    driver.kill("SIGKILL");124    await exited;125  }126}127128async function buildTestExtension(tempRoot) {129  const sourceDir = join(tempRoot, "extension");130  const artifactsDir = join(tempRoot, "artifacts");131  await cp(DIST, sourceDir, { recursive: true });132  await mkdir(artifactsDir, { recursive: true });133  await cp(TEST_BACKGROUND, join(sourceDir, "firefox-passkey-background.js"));134  await cp(CONTEXT_PROBE, join(sourceDir, "firefox-passkey-context-probe.js"));135  await cp(SENDER_PROBE, join(sourceDir, "firefox-sender-probe.js"));136137  const manifestPath = join(sourceDir, "manifest.json");138  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));139  manifest.background.scripts = [140    "firefox-passkey-background.js",141    ...manifest.background.scripts,142  ];143  manifest.content_scripts.push({144    matches: ["<all_urls>"],145    js: ["firefox-passkey-context-probe.js"],146    run_at: "document_start",147  });148  manifest.content_scripts.push({149    matches: ["<all_urls>"],150    js: ["firefox-sender-probe.js"],151    run_at: "document_start",152    all_frames: false,153  });154  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);155156  await execFileAsync(WEB_EXT, [157    "build",158    "--source-dir", sourceDir,159    "--artifacts-dir", artifactsDir,160    "--filename", "peach-firefox-passkey-matrix.xpi",161    "--overwrite-dest",162    "--no-input",163  ], { cwd: ROOT });164  return join(artifactsDir, "peach-firefox-passkey-matrix.xpi");165}166167const PROBE_SCRIPT = `168  const done = arguments[arguments.length - 1];169  void (async () => {170171  // Verify no PEACH_PASSKEY_REQUEST postMessage is used by the bridge.172  //    We check the page for any existing listeners that post PEACH_PASSKEY_REQUEST.173  const pageSourceHasRelay = document.documentElement.innerHTML.includes("PEACH_PASSKEY_REQUEST");174175  const passkeyMessages = [];176  window.addEventListener("message", (event) => {177    if (event.data?.type === "PEACH_PASSKEY_REQUEST" || event.data?.type === "PEACH_PASSKEY_RESPONSE") {178      passkeyMessages.push(event.data.type);179    }180  });181182  const senderSource = "peach-firefox-sender-probe";183  const sendSenderMessage = (message) => new Promise((resolve) => {184    const id = crypto.randomUUID();185    const timer = setTimeout(() => {186      window.removeEventListener("message", onResponse);187      resolve({ ok: false, error: "probe_timeout" });188    }, 5000);189    function onResponse(event) {190      let response;191      try {192        response = JSON.parse(event.data);193      } catch {194        return;195      }196      if (response?.source !== senderSource + ":response" || response.id !== id) return;197      clearTimeout(timer);198      window.removeEventListener("message", onResponse);199      resolve(response.response);200    }201    window.addEventListener("message", onResponse);202    window.postMessage(JSON.stringify({ source: senderSource, id, message }), "*");203  });204205  // 4. Exercise both wrappers against the test-only background responder.206  //    Abort quickly if the native authenticator receives either request.207  const createAbort = new AbortController();208  const createTimer = setTimeout(() => createAbort.abort(), 2000);209  let createResult = null;210  let createError = null;211  try {212    const credential = await navigator.credentials.create({213      signal: createAbort.signal,214      publicKey: {215        rp: { id: "127.0.0.1", name: "Test" },216        user: { id: new Uint8Array([5, 6, 7, 8]).buffer, name: "test", displayName: "Test" },217        challenge: new Uint8Array([1, 2, 3, 4]).buffer,218        pubKeyCredParams: [{ alg: -7, type: "public-key" }],219      },220    });221    createResult = credential ? {222      id: credential.id, type: credential.type,223      transports: credential.response.getTransports(),224      authenticatorData: Array.from(new Uint8Array(credential.response.getAuthenticatorData())),225      publicKey: Array.from(new Uint8Array(credential.response.getPublicKey())),226      algorithm: credential.response.getPublicKeyAlgorithm(),227      extensionKeys: Object.keys(credential.getClientExtensionResults()),228      json: JSON.parse(JSON.stringify(credential)),229    } : null;230  } catch (e) {231    createError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null };232  } finally {233    clearTimeout(createTimer);234  }235236  const getAbort = new AbortController();237  const getTimer = setTimeout(() => getAbort.abort(), 2000);238  let getResult = null;239  let getError = null;240  try {241    const credential = await navigator.credentials.get({242      signal: getAbort.signal,243      publicKey: {244        rpId: "127.0.0.1",245        challenge: new Uint8Array([1, 2, 3, 4]).buffer,246        allowCredentials: [{ id: new Uint8Array([5, 6, 7, 8]).buffer, type: "public-key" }],247      },248    });249    getResult = credential ? {250      id: credential.id, type: credential.type,251      rawId: Array.from(new Uint8Array(credential.rawId)),252      signature: Array.from(new Uint8Array(credential.response.signature)),253      extensionKeys: Object.keys(credential.getClientExtensionResults()),254      json: JSON.parse(JSON.stringify(credential)),255    } : null;256  } catch (e) {257    getError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null };258  } finally {259    clearTimeout(getTimer);260  }261262  const [allowedSender, entriesSender, recoverySender, storageSender] = await Promise.all([263    sendSenderMessage({ type: "GET_SETTINGS" }),264    sendSenderMessage({ type: "GET_ENTRIES" }),265    sendSenderMessage({ type: "GET_RECOVERY_WORDS" }),266    sendSenderMessage({ type: "STORAGE_SESSION" }),267  ]);268269  done({270    pageOrigin: location.origin,271    context: JSON.parse(document.documentElement.dataset.peachFirefoxPasskeyContext ?? "null"),272    pageSourceHasRelay,273    createResult,274    createError,275    getResult,276    getError,277    passkeyMessages,278    senderProbeReady: document.documentElement.dataset.peachFirefoxSenderProbe ?? null,279    allowedSender,280    entriesSender,281    recoverySender,282    storageSender,283  });284  })().catch((error) => done({285    probeError: error?.stack ?? error?.message ?? String(error),286  }));287`;288289async function run() {290  if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");291  if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");292293  const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-passkey-"));294  let driver;295  let driverPort;296  let sessionId;297  let fixtureServer;298  try {299    const xpiPath = await buildTestExtension(tempRoot);300    const firefoxBinary = findFirefoxBinary();301    // Firefox 151 requires these headless fallbacks before Marionette starts;302    // geckodriver's allowSystemAccess flag alone does not configure them.303    Object.assign(process.env, {304      MOZ_HEADLESS: "1",305      MOZ_DISABLE_CONTENT_SANDBOX: "1",306      MOZ_DISABLE_RDD_SANDBOX: "1",307      LIBGL_ALWAYS_SOFTWARE: "1",308    });309    const port = await findFreePort();310    driverPort = port;311    driver = await start({312      host: "127.0.0.1",313      port,314      geckoDriverVersion: "0.37.1",315      ...(firefoxBinary ? { binary: firefoxBinary } : {}),316      log: "error",317      allowSystemAccess: true,318    });319    await waitForDriver(port);320321    const capabilities = {322      browserName: "firefox",323      acceptInsecureCerts: true,324      "moz:firefoxOptions": {325        args: ["-headless"],326        ...(firefoxBinary ? { binary: firefoxBinary } : {}),327        prefs: {328          "browser.shell.checkDefaultBrowser": false,329          "browser.startup.page": 0,330          "browser.startup.homepage": "about:blank",331          "remote.system-access-check.enabled": false,332        },333      },334    };335    const session = await webdriverRequest(port, "/session", {336      method: "POST",337      body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),338    });339    sessionId = session.sessionId;340341    fixtureServer = createHttpServer((_request, response) => {342      response.writeHead(200, { "content-type": "text/html; charset=utf-8" });343      response.end("<!doctype html><html><head><title>Peach passkey test</title></head><body></body></html>");344    });345    await new Promise((resolveListen, rejectListen) => {346      fixtureServer.once("error", rejectListen);347      fixtureServer.listen(0, "127.0.0.1", resolveListen);348    });349    const fixtureAddress = fixtureServer.address();350    if (!fixtureAddress || typeof fixtureAddress === "string") {351      throw new Error("Could not allocate the Firefox fixture server port");352    }353    const fixtureOrigin = `http://127.0.0.1:${fixtureAddress.port}`;354355    // Install the temporary add-on.356    const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {357      method: "POST",358      body: JSON.stringify({359        path: xpiPath,360        temporary: true,361      }),362    });363    if (install?.id && install.id !== EXTENSION_ID) {364      throw new Error(`Unexpected Firefox add-on id: ${install.id}`);365    }366367    await webdriverRequest(port, `/session/${sessionId}/moz/context`, {368      method: "POST",369      body: JSON.stringify({ context: "chrome" }),370    });371    const addonInfo = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {372      method: "POST",373      body: JSON.stringify({374        script: `375          const done = arguments[arguments.length - 1];376          const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");377          const { ExtensionPermissions } = ChromeUtils.importESModule("resource://gre/modules/ExtensionPermissions.sys.mjs");378          AddonManager.getAddonByID(arguments[0]).then(async (addon) => {379            if (!addon) return null;380            const policy = WebExtensionPolicy.getByID(addon.id);381            await ExtensionPermissions.add(382              addon.id,383              { permissions: [], origins: ["<all_urls>"] },384              policy.extension,385            );386            await addon.reload();387            const reloadedAddon = await AddonManager.getAddonByID(addon.id);388            const reloadedPolicy = WebExtensionPolicy.getByID(addon.id);389            return {390              id: reloadedAddon.id,391              active: reloadedAddon.isActive,392              permissions: await ExtensionPermissions.get(addon.id),393              allowedOrigins: reloadedPolicy.allowedOrigins.patterns.map((pattern) => pattern.pattern),394            };395          }).then(done, (error) => done({ error: String(error), stack: error?.stack }));396        `,397        args: [EXTENSION_ID],398      }),399    });400    await webdriverRequest(port, `/session/${sessionId}/moz/context`, {401      method: "POST",402      body: JSON.stringify({ context: "content" }),403    });404    if (!addonInfo?.active || !addonInfo.permissions?.origins?.includes("<all_urls>")) {405      throw new Error(`Firefox add-on host permission was not granted: ${JSON.stringify(addonInfo)}`);406    }407408    // Navigate to a test page and wait for the content script to install.409    await webdriverRequest(port, `/session/${sessionId}/url`, {410      method: "POST",411      body: JSON.stringify({ url: `${fixtureOrigin}/` }),412    });413    // Give the content script time to run at document_start.414    await new Promise((r) => setTimeout(r, 2000));415416    const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {417      method: "POST",418      body: JSON.stringify({ script: PROBE_SCRIPT, args: [] }),419    });420421    if (result.pageOrigin !== fixtureOrigin) {422      throw new Error(`Unexpected page origin: ${result.pageOrigin}`);423    }424425    const failures = [];426427    if (result.probeError) {428      failures.push(`page probe failed: ${result.probeError}`);429    }430431    if (result.context?.exportFunctionType !== "function" || result.context?.cloneIntoType !== "function") {432      failures.push(`Firefox privileged bridge APIs were unavailable: ${JSON.stringify(result.context)}`);433    }434435    if (result.context?.installGuard !== true) {436      failures.push(`Firefox passkey bridge did not install: ${JSON.stringify(result.context)}`);437    }438439    // No PEACH_PASSKEY_REQUEST in page source.440    if (result.pageSourceHasRelay) {441      failures.push("PEACH_PASSKEY_REQUEST found in page source — the old relay may still be present");442    }443444    if (result.createResult?.id !== CREATE_CREDENTIAL_ID || result.createResult?.type !== "public-key") {445      failures.push(`navigator.credentials.create did not return the extension credential: ${JSON.stringify({ result: result.createResult, error: result.createError })}`);446    }447448    if (result.getResult?.id !== GET_CREDENTIAL_ID || result.getResult?.type !== "public-key") {449      failures.push(`navigator.credentials.get did not return the extension credential: ${JSON.stringify({ result: result.getResult, error: result.getError })}`);450    }451452    if (JSON.stringify(result.createResult?.transports) !== '["internal"]' ||453        JSON.stringify(result.createResult?.authenticatorData) !== '[2]' ||454        JSON.stringify(result.createResult?.publicKey) !== '[3]' ||455        result.createResult?.algorithm !== -7 ||456        JSON.stringify(result.createResult?.extensionKeys) !== '[]' ||457        result.createResult?.json?.response?.attestationObject !== "AQ") {458      failures.push(`registration methods were not readable by the page: ${JSON.stringify(result.createResult)}`);459    }460    if (JSON.stringify(result.getResult?.rawId) !== '[5,6,7,8]' ||461        JSON.stringify(result.getResult?.signature) !== '[3]' ||462        JSON.stringify(result.getResult?.extensionKeys) !== '[]' ||463        result.getResult?.json?.response?.signature !== "Aw") {464      failures.push(`assertion was not serializable by the page: ${JSON.stringify(result.getResult)}`);465    }466467    if (result.passkeyMessages?.length > 0) {468      failures.push(`page observed passkey postMessage traffic: ${JSON.stringify(result.passkeyMessages)}`);469    }470471    if (result.senderProbeReady !== "ready") {472      failures.push(`Firefox sender-context probe did not execute: ${JSON.stringify(result)}`);473    }474    if (Object.hasOwn(result.storageSender ?? {}, "peach_session_dk")) {475      failures.push(`Firefox content script accessed the session escrow key: ${JSON.stringify(result.storageSender)}`);476    }477    if (result.allowedSender?.error === "invalid_sender") {478      failures.push("Content-script-allowed GET_SETTINGS was rejected");479    }480    for (const [name, response] of [481      ["GET_ENTRIES", result.entriesSender],482      ["GET_RECOVERY_WORDS", result.recoverySender],483    ]) {484      if (response?.error !== "invalid_sender") {485        failures.push(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify(response)}`);486      }487    }488489    if (failures.length > 0) {490      throw new Error(`Firefox passkey matrix failures:\n  - ${failures.join("\n  - ")}\n  Context: ${JSON.stringify(result.context)}\n  Add-on: ${JSON.stringify(addonInfo)}`);491    }492493    console.log("Firefox security matrix passed: sender authorization and storage isolation held; exportFunction returned extension credentials with no forgeable passkey postMessage traffic.");494  } finally {495    if (driver && sessionId) {496      try {497        await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });498      } catch {499        // Browser may already have exited.500      }501    }502    if (fixtureServer) {503      await new Promise((resolveClose) => fixtureServer.close(resolveClose));504    }505    await stopDriver(driver);506    await rm(tempRoot, { recursive: true, force: true });507  }508}509510run().catch((error) => {511  console.error(error instanceof Error ? error.message : error);512  process.exitCode = 1;513});