12345678910111213141516import { 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 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 302 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 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 409 await webdriverRequest(port, `/session/${sessionId}/url`, {410 method: "POST",411 body: JSON.stringify({ url: `${fixtureOrigin}/` }),412 });413 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 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 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});