#!/usr/bin/env node // PB-26-210: mandatory Firefox release browser gate. // Tests the PB-26-209 exportFunction privileged passkey bridge in real Firefox. // // If Firefox/Marionette cannot launch, this script exits non-zero — the gate // FAILS rather than silently skipping. // // Security properties verified: // 1. navigator.credentials.create is wrapped by exportFunction (not native). // 2. navigator.credentials.get is wrapped by exportFunction (not native). // 3. No forgeable PEACH_PASSKEY_RESPONSE postMessage channel exists. // 4. The bridge falls through to native when vault is locked (no crash/hang). // 5. The page cannot forge the extension's response via postMessage. import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import net from "node:net"; import { start } from "geckodriver"; const execFileAsync = promisify(execFile); const ROOT = resolve(new URL("..", import.meta.url).pathname); const DIST = join(ROOT, "dist-firefox"); const WEB_EXT = join(ROOT, "node_modules/.bin/web-ext"); function findFirefoxBinary() { const candidates = [ process.env.PEACH_FIREFOX_BINARY, process.env.FIREFOX_BIN, "/usr/bin/firefox", "/usr/local/bin/firefox", "/Applications/Firefox.app/Contents/MacOS/firefox", "/Applications/Firefox Nightly.app/Contents/MacOS/firefox", ].filter(Boolean); return candidates.find((c) => existsSync(c)); } function findFreePort() { return new Promise((resolvePort, reject) => { const server = net.createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(); reject(new Error("Could not allocate a WebDriver port")); return; } server.close(() => resolvePort(address.port)); }); }); } async function waitForDriver(port) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { try { const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 500); if (response.ok && (await response.json()).value?.ready) return; } catch { // geckodriver is still starting. } await new Promise((r) => setTimeout(r, 250)); } throw new Error("geckodriver did not become ready within 30 seconds"); } async function fetchWithTimeout(url, options, timeoutMs) { const controller = new AbortController(); let timeoutId; const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs); }); try { return await Promise.race([ fetch(url, { ...options, signal: controller.signal }), timeout, ]); } finally { clearTimeout(timeoutId); controller.abort(); } } async function webdriverRequest(port, path, options = {}) { const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, { ...options, headers: { "content-type": "application/json", ...(options.headers ?? {}) }, }, 10_000); const body = await response.json(); if (!response.ok || body.value?.error) { throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`); } return body.value; } async function buildTestExtension(tempRoot) { const sourceDir = join(tempRoot, "extension"); const artifactsDir = join(tempRoot, "artifacts"); await cp(DIST, sourceDir, { recursive: true }); await mkdir(artifactsDir, { recursive: true }); await execFileAsync(WEB_EXT, [ "build", "--source-dir", sourceDir, "--artifacts-dir", artifactsDir, "--filename", "peach-firefox-passkey-matrix.xpi", "--overwrite-dest", "--no-input", ], { cwd: ROOT }); return join(artifactsDir, "peach-firefox-passkey-matrix.xpi"); } const PROBE_SCRIPT = ` const done = arguments[arguments.length - 1]; // 1. Check if navigator.credentials.create is wrapped by exportFunction. // The wrapped function is from the page world via exportFunction, so // it will NOT be the native function. We check by calling toString() // — a native function returns "function create() { [native code] }" // while an exportFunction wrapper returns something else. const createStr = navigator.credentials.create.toString(); const getStr = navigator.credentials.get.toString(); const createIsNative = createStr.includes("[native code]"); const getIsNative = getStr.includes("[native code]"); // 2. Check no PEACH_PASSKEY_RESPONSE postMessage listener exists. // Dispatch a forged response and verify no handler picks it up. let forgedResponseReceived = false; const forgedListener = (event) => { if (event.data?.type === "PEACH_PASSKEY_RESPONSE") { forgedResponseReceived = true; } }; window.addEventListener("message", forgedListener); window.postMessage( { type: "PEACH_PASSKEY_RESPONSE", id: "forged-uuid-test", payload: { ok: true } }, window.location.origin, ); await new Promise((r) => setTimeout(r, 200)); window.removeEventListener("message", forgedListener); // 3. Verify no PEACH_PASSKEY_REQUEST postMessage is used by the bridge. // We check the page for any existing listeners that post PEACH_PASSKEY_REQUEST. const pageSourceHasRelay = document.documentElement.innerHTML.includes("PEACH_PASSKEY_REQUEST"); // 4. Call navigator.credentials.create with a publicKey request. // With a locked vault, the bridge should fall through to native // (return null in headless Firefox without a real authenticator). let createResult = null; let createError = null; try { createResult = await navigator.credentials.create({ publicKey: { rp: { id: "example.com", name: "Test" }, user: { id: new Uint8Array(16), name: "test", displayName: "Test" }, challenge: new Uint8Array(16), pubKeyCredParams: [{ alg: -7, type: "public-key" }], }, }); } catch (e) { createError = e?.name ?? String(e); } // 5. Call navigator.credentials.get — should also fall through. let getResult = null; let getError = null; try { getResult = await navigator.credentials.get({ publicKey: { rpId: "example.com", challenge: new Uint8Array(16), allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }], }, }); } catch (e) { getError = e?.name ?? String(e); } done({ pageOrigin: location.origin, createIsNative, getIsNative, forgedResponseReceived, pageSourceHasRelay, createResult, createError, getResult, getError, }); `; async function run() { if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first"); if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first"); const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-passkey-")); let driver; let driverPort; let sessionId; try { const xpiPath = await buildTestExtension(tempRoot); const firefoxBinary = findFirefoxBinary(); const port = await findFreePort(); driverPort = port; driver = await start({ host: "127.0.0.1", port, geckoDriverVersion: "0.37.1", ...(firefoxBinary ? { binary: firefoxBinary } : {}), log: "error", }); await waitForDriver(port); const capabilities = { browserName: "firefox", acceptInsecureCerts: true, "moz:firefoxOptions": { args: ["-headless"], ...(firefoxBinary ? { binary: firefoxBinary } : {}), prefs: { "browser.shell.checkDefaultBrowser": false, "browser.startup.page": 0, "browser.startup.homepage": "about:blank", }, }, }; const session = await webdriverRequest(port, "/session", { method: "POST", body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }), }); sessionId = session.sessionId; // Install the temporary add-on. await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, { method: "POST", body: JSON.stringify({ addon: (await readFile(xpiPath)).toString("base64"), temporary: true, }), }); // Navigate to a test page and wait for the content script to install. await webdriverRequest(port, `/session/${sessionId}/url`, { method: "POST", body: JSON.stringify({ url: "https://example.com/" }), }); // Give the content script time to run at document_start. await new Promise((r) => setTimeout(r, 2000)); const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, { method: "POST", body: JSON.stringify({ script: PROBE_SCRIPT, args: [] }), }); if (result.pageOrigin !== "https://example.com") { throw new Error(`Unexpected page origin: ${result.pageOrigin}`); } const failures = []; // 1. navigator.credentials.create must be wrapped (not native). if (result.createIsNative) { failures.push("navigator.credentials.create is not wrapped by exportFunction — the privileged bridge was not installed"); } // 2. navigator.credentials.get must be wrapped (not native). if (result.getIsNative) { failures.push("navigator.credentials.get is not wrapped by exportFunction — the privileged bridge was not installed"); } // 3. No forgeable PEACH_PASSKEY_RESPONSE channel. if (result.forgedResponseReceived) { failures.push("a PEACH_PASSKEY_RESPONSE postMessage listener exists — the forgeable relay was not removed"); } // 4. No PEACH_PASSKEY_REQUEST in page source. if (result.pageSourceHasRelay) { failures.push("PEACH_PASSKEY_REQUEST found in page source — the old relay may still be present"); } // 5. Create with locked vault should fall through (null or NotAllowedError // from native authenticator, not a crash/hang). if (result.createError && result.createError !== "NotAllowedError" && result.createError !== "AbortError") { failures.push(`navigator.credentials.create threw unexpected error: ${result.createError}`); } // 6. Get with locked vault should fall through. if (result.getError && result.getError !== "NotAllowedError" && result.getError !== "AbortError") { failures.push(`navigator.credentials.get threw unexpected error: ${result.getError}`); } if (failures.length > 0) { throw new Error(`Firefox passkey matrix failures:\n - ${failures.join("\n - ")}`); } console.log("Firefox passkey matrix passed: exportFunction bridge installed, no forgeable relay, bridge falls through with locked vault."); } finally { if (driver && sessionId) { try { await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" }); } catch { // Browser may already have exited. } } driver?.kill("SIGTERM"); await rm(tempRoot, { recursive: true, force: true }); } } run().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });