#!/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 { createServer as createHttpServer } from "node:http"; 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"); const TEST_BACKGROUND = join(ROOT, "tests/fixtures/firefox-passkey-background.js"); const CONTEXT_PROBE = join(ROOT, "tests/fixtures/firefox-passkey-context-probe.js"); const SENDER_PROBE = join(ROOT, "tests/fixtures/firefox-sender-probe.js"); const CREATE_CREDENTIAL_ID = "AQIDBA"; const GET_CREDENTIAL_ID = "BQYHCA"; const EXTENSION_ID = "peach@peachpasswords.com"; 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`, {}, 5000); 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 stopDriver(driver) { if (!driver || driver.exitCode !== null || driver.signalCode !== null) return; const exited = new Promise((resolveExit) => driver.once("exit", resolveExit)); driver.kill("SIGTERM"); let timeoutId; const stopped = await Promise.race([ exited.then(() => true), new Promise((resolveTimeout) => { timeoutId = setTimeout(() => resolveTimeout(false), 5000); }), ]); clearTimeout(timeoutId); if (!stopped && driver.exitCode === null && driver.signalCode === null) { driver.kill("SIGKILL"); await exited; } } 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 cp(TEST_BACKGROUND, join(sourceDir, "firefox-passkey-background.js")); await cp(CONTEXT_PROBE, join(sourceDir, "firefox-passkey-context-probe.js")); await cp(SENDER_PROBE, join(sourceDir, "firefox-sender-probe.js")); const manifestPath = join(sourceDir, "manifest.json"); const manifest = JSON.parse(await readFile(manifestPath, "utf8")); manifest.background.scripts = [ "firefox-passkey-background.js", ...manifest.background.scripts, ]; manifest.content_scripts.push({ matches: [""], js: ["firefox-passkey-context-probe.js"], run_at: "document_start", }); manifest.content_scripts.push({ matches: [""], js: ["firefox-sender-probe.js"], run_at: "document_start", all_frames: false, }); await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); 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]; void (async () => { // 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"); const passkeyMessages = []; window.addEventListener("message", (event) => { if (event.data?.type === "PEACH_PASSKEY_REQUEST" || event.data?.type === "PEACH_PASSKEY_RESPONSE") { passkeyMessages.push(event.data.type); } }); const senderSource = "peach-firefox-sender-probe"; const sendSenderMessage = (message) => new Promise((resolve) => { const id = crypto.randomUUID(); const timer = setTimeout(() => { window.removeEventListener("message", onResponse); resolve({ ok: false, error: "probe_timeout" }); }, 5000); function onResponse(event) { let response; try { response = JSON.parse(event.data); } catch { return; } if (response?.source !== senderSource + ":response" || response.id !== id) return; clearTimeout(timer); window.removeEventListener("message", onResponse); resolve(response.response); } window.addEventListener("message", onResponse); window.postMessage(JSON.stringify({ source: senderSource, id, message }), "*"); }); // 4. Exercise both wrappers against the test-only background responder. // Abort quickly if the native authenticator receives either request. const createAbort = new AbortController(); const createTimer = setTimeout(() => createAbort.abort(), 2000); let createResult = null; let createError = null; try { const credential = await navigator.credentials.create({ signal: createAbort.signal, publicKey: { rp: { id: "127.0.0.1", name: "Test" }, user: { id: new Uint8Array(16), name: "test", displayName: "Test" }, challenge: new Uint8Array(16), pubKeyCredParams: [{ alg: -7, type: "public-key" }], }, }); createResult = credential ? { id: credential.id, type: credential.type } : null; } catch (e) { createError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null }; } finally { clearTimeout(createTimer); } const getAbort = new AbortController(); const getTimer = setTimeout(() => getAbort.abort(), 2000); let getResult = null; let getError = null; try { const credential = await navigator.credentials.get({ signal: getAbort.signal, publicKey: { rpId: "127.0.0.1", challenge: new Uint8Array(16), allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }], }, }); getResult = credential ? { id: credential.id, type: credential.type } : null; } catch (e) { getError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null }; } finally { clearTimeout(getTimer); } const [allowedSender, entriesSender, recoverySender, storageSender] = await Promise.all([ sendSenderMessage({ type: "GET_SETTINGS" }), sendSenderMessage({ type: "GET_ENTRIES" }), sendSenderMessage({ type: "GET_RECOVERY_WORDS" }), sendSenderMessage({ type: "STORAGE_SESSION" }), ]); done({ pageOrigin: location.origin, context: JSON.parse(document.documentElement.dataset.peachFirefoxPasskeyContext ?? "null"), pageSourceHasRelay, createResult, createError, getResult, getError, passkeyMessages, senderProbeReady: document.documentElement.dataset.peachFirefoxSenderProbe ?? null, allowedSender, entriesSender, recoverySender, storageSender, }); })().catch((error) => done({ probeError: error?.stack ?? error?.message ?? String(error), })); `; 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; let fixtureServer; try { const xpiPath = await buildTestExtension(tempRoot); const firefoxBinary = findFirefoxBinary(); // Firefox 151 requires these headless fallbacks before Marionette starts; // geckodriver's allowSystemAccess flag alone does not configure them. Object.assign(process.env, { MOZ_HEADLESS: "1", MOZ_DISABLE_CONTENT_SANDBOX: "1", MOZ_DISABLE_RDD_SANDBOX: "1", LIBGL_ALWAYS_SOFTWARE: "1", }); const port = await findFreePort(); driverPort = port; driver = await start({ host: "127.0.0.1", port, geckoDriverVersion: "0.37.1", ...(firefoxBinary ? { binary: firefoxBinary } : {}), log: "error", allowSystemAccess: true, }); 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", "remote.system-access-check.enabled": false, }, }, }; const session = await webdriverRequest(port, "/session", { method: "POST", body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }), }); sessionId = session.sessionId; fixtureServer = createHttpServer((_request, response) => { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end("Peach passkey test"); }); await new Promise((resolveListen, rejectListen) => { fixtureServer.once("error", rejectListen); fixtureServer.listen(0, "127.0.0.1", resolveListen); }); const fixtureAddress = fixtureServer.address(); if (!fixtureAddress || typeof fixtureAddress === "string") { throw new Error("Could not allocate the Firefox fixture server port"); } const fixtureOrigin = `http://127.0.0.1:${fixtureAddress.port}`; // Install the temporary add-on. const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, { method: "POST", body: JSON.stringify({ path: xpiPath, temporary: true, }), }); if (install?.id && install.id !== EXTENSION_ID) { throw new Error(`Unexpected Firefox add-on id: ${install.id}`); } await webdriverRequest(port, `/session/${sessionId}/moz/context`, { method: "POST", body: JSON.stringify({ context: "chrome" }), }); const addonInfo = await webdriverRequest(port, `/session/${sessionId}/execute/async`, { method: "POST", body: JSON.stringify({ script: ` const done = arguments[arguments.length - 1]; const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); const { ExtensionPermissions } = ChromeUtils.importESModule("resource://gre/modules/ExtensionPermissions.sys.mjs"); AddonManager.getAddonByID(arguments[0]).then(async (addon) => { if (!addon) return null; const policy = WebExtensionPolicy.getByID(addon.id); await ExtensionPermissions.add( addon.id, { permissions: [], origins: [""] }, policy.extension, ); await addon.reload(); const reloadedAddon = await AddonManager.getAddonByID(addon.id); const reloadedPolicy = WebExtensionPolicy.getByID(addon.id); return { id: reloadedAddon.id, active: reloadedAddon.isActive, permissions: await ExtensionPermissions.get(addon.id), allowedOrigins: reloadedPolicy.allowedOrigins.patterns.map((pattern) => pattern.pattern), }; }).then(done, (error) => done({ error: String(error), stack: error?.stack })); `, args: [EXTENSION_ID], }), }); await webdriverRequest(port, `/session/${sessionId}/moz/context`, { method: "POST", body: JSON.stringify({ context: "content" }), }); if (!addonInfo?.active || !addonInfo.permissions?.origins?.includes("")) { throw new Error(`Firefox add-on host permission was not granted: ${JSON.stringify(addonInfo)}`); } // 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: `${fixtureOrigin}/` }), }); // 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 !== fixtureOrigin) { throw new Error(`Unexpected page origin: ${result.pageOrigin}`); } const failures = []; if (result.probeError) { failures.push(`page probe failed: ${result.probeError}`); } if (result.context?.exportFunctionType !== "function" || result.context?.cloneIntoType !== "function") { failures.push(`Firefox privileged bridge APIs were unavailable: ${JSON.stringify(result.context)}`); } if (result.context?.installGuard !== true) { failures.push(`Firefox passkey bridge did not install: ${JSON.stringify(result.context)}`); } // 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"); } if (result.createResult?.id !== CREATE_CREDENTIAL_ID || result.createResult?.type !== "public-key") { failures.push(`navigator.credentials.create did not return the extension credential: ${JSON.stringify({ result: result.createResult, error: result.createError })}`); } if (result.getResult?.id !== GET_CREDENTIAL_ID || result.getResult?.type !== "public-key") { failures.push(`navigator.credentials.get did not return the extension credential: ${JSON.stringify({ result: result.getResult, error: result.getError })}`); } if (result.passkeyMessages?.length > 0) { failures.push(`page observed passkey postMessage traffic: ${JSON.stringify(result.passkeyMessages)}`); } if (result.senderProbeReady !== "ready") { failures.push(`Firefox sender-context probe did not execute: ${JSON.stringify(result)}`); } if (Object.hasOwn(result.storageSender ?? {}, "peach_session_dk")) { failures.push(`Firefox content script accessed the session escrow key: ${JSON.stringify(result.storageSender)}`); } if (result.allowedSender?.error === "invalid_sender") { failures.push("Content-script-allowed GET_SETTINGS was rejected"); } for (const [name, response] of [ ["GET_ENTRIES", result.entriesSender], ["GET_RECOVERY_WORDS", result.recoverySender], ]) { if (response?.error !== "invalid_sender") { failures.push(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify(response)}`); } } if (failures.length > 0) { throw new Error(`Firefox passkey matrix failures:\n - ${failures.join("\n - ")}\n Context: ${JSON.stringify(result.context)}\n Add-on: ${JSON.stringify(addonInfo)}`); } console.log("Firefox security matrix passed: sender authorization and storage isolation held; exportFunction returned extension credentials with no forgeable passkey postMessage traffic."); } finally { if (driver && sessionId) { try { await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" }); } catch { // Browser may already have exited. } } if (fixtureServer) { await new Promise((resolveClose) => fixtureServer.close(resolveClose)); } await stopDriver(driver); await rm(tempRoot, { recursive: true, force: true }); } } run().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });