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 } finally {91 clearTimeout(timeoutId);92 controller.abort();93 }94}9596async function webdriverRequest(port, path, options = {}) {97 const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {98 ...options,99 headers: { "content-type": "application/json", ...(options.headers ?? {}) },100 }, 10_000);101 const body = await response.json();102 if (!response.ok || body.value?.error) {103 throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);104 }105 return body.value;106}107108async function stopDriver(driver) {109 if (!driver || driver.exitCode !== null || driver.signalCode !== null) return;110 const exited = new Promise((resolveExit) => driver.once("exit", resolveExit));111 driver.kill("SIGTERM");112 let timeoutId;113 const stopped = await Promise.race([114 exited.then(() => true),115 new Promise((resolveTimeout) => {116 timeoutId = setTimeout(() => resolveTimeout(false), 5000);117 }),118 ]);119 clearTimeout(timeoutId);120 if (!stopped && driver.exitCode === null && driver.signalCode === null) {121 driver.kill("SIGKILL");122 await exited;123 }124}125126async function buildTestExtension(tempRoot) {127 const sourceDir = join(tempRoot, "extension");128 const artifactsDir = join(tempRoot, "artifacts");129 await cp(DIST, sourceDir, { recursive: true });130 await mkdir(artifactsDir, { recursive: true });131 await cp(TEST_BACKGROUND, join(sourceDir, "firefox-passkey-background.js"));132 await cp(CONTEXT_PROBE, join(sourceDir, "firefox-passkey-context-probe.js"));133 await cp(SENDER_PROBE, join(sourceDir, "firefox-sender-probe.js"));134135 const manifestPath = join(sourceDir, "manifest.json");136 const manifest = JSON.parse(await readFile(manifestPath, "utf8"));137 manifest.background.scripts = [138 "firefox-passkey-background.js",139 ...manifest.background.scripts,140 ];141 manifest.content_scripts.push({142 matches: ["<all_urls>"],143 js: ["firefox-passkey-context-probe.js"],144 run_at: "document_start",145 });146 manifest.content_scripts.push({147 matches: ["<all_urls>"],148 js: ["firefox-sender-probe.js"],149 run_at: "document_start",150 all_frames: false,151 });152 await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);153154 await execFileAsync(WEB_EXT, [155 "build",156 "--source-dir", sourceDir,157 "--artifacts-dir", artifactsDir,158 "--filename", "peach-firefox-passkey-matrix.xpi",159 "--overwrite-dest",160 "--no-input",161 ], { cwd: ROOT });162 return join(artifactsDir, "peach-firefox-passkey-matrix.xpi");163}164165const PROBE_SCRIPT = `166 const done = arguments[arguments.length - 1];167 void (async () => {168169 // Verify no PEACH_PASSKEY_REQUEST postMessage is used by the bridge.170 // We check the page for any existing listeners that post PEACH_PASSKEY_REQUEST.171 const pageSourceHasRelay = document.documentElement.innerHTML.includes("PEACH_PASSKEY_REQUEST");172173 const passkeyMessages = [];174 window.addEventListener("message", (event) => {175 if (event.data?.type === "PEACH_PASSKEY_REQUEST" || event.data?.type === "PEACH_PASSKEY_RESPONSE") {176 passkeyMessages.push(event.data.type);177 }178 });179180 const senderSource = "peach-firefox-sender-probe";181 const sendSenderMessage = (message) => new Promise((resolve) => {182 const id = crypto.randomUUID();183 const timer = setTimeout(() => {184 window.removeEventListener("message", onResponse);185 resolve({ ok: false, error: "probe_timeout" });186 }, 5000);187 function onResponse(event) {188 let response;189 try {190 response = JSON.parse(event.data);191 } catch {192 return;193 }194 if (response?.source !== senderSource + ":response" || response.id !== id) return;195 clearTimeout(timer);196 window.removeEventListener("message", onResponse);197 resolve(response.response);198 }199 window.addEventListener("message", onResponse);200 window.postMessage(JSON.stringify({ source: senderSource, id, message }), "*");201 });202203 // 4. Exercise both wrappers against the test-only background responder.204 // Abort quickly if the native authenticator receives either request.205 const createAbort = new AbortController();206 const createTimer = setTimeout(() => createAbort.abort(), 2000);207 let createResult = null;208 let createError = null;209 try {210 const credential = await navigator.credentials.create({211 signal: createAbort.signal,212 publicKey: {213 rp: { id: "127.0.0.1", name: "Test" },214 user: { id: new Uint8Array(16), name: "test", displayName: "Test" },215 challenge: new Uint8Array(16),216 pubKeyCredParams: [{ alg: -7, type: "public-key" }],217 },218 });219 createResult = credential ? { id: credential.id, type: credential.type } : null;220 } catch (e) {221 createError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null };222 } finally {223 clearTimeout(createTimer);224 }225226 const getAbort = new AbortController();227 const getTimer = setTimeout(() => getAbort.abort(), 2000);228 let getResult = null;229 let getError = null;230 try {231 const credential = await navigator.credentials.get({232 signal: getAbort.signal,233 publicKey: {234 rpId: "127.0.0.1",235 challenge: new Uint8Array(16),236 allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }],237 },238 });239 getResult = credential ? { id: credential.id, type: credential.type } : null;240 } catch (e) {241 getError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null };242 } finally {243 clearTimeout(getTimer);244 }245246 const [allowedSender, entriesSender, recoverySender, storageSender] = await Promise.all([247 sendSenderMessage({ type: "GET_SETTINGS" }),248 sendSenderMessage({ type: "GET_ENTRIES" }),249 sendSenderMessage({ type: "GET_RECOVERY_WORDS" }),250 sendSenderMessage({ type: "STORAGE_SESSION" }),251 ]);252253 done({254 pageOrigin: location.origin,255 context: JSON.parse(document.documentElement.dataset.peachFirefoxPasskeyContext ?? "null"),256 pageSourceHasRelay,257 createResult,258 createError,259 getResult,260 getError,261 passkeyMessages,262 senderProbeReady: document.documentElement.dataset.peachFirefoxSenderProbe ?? null,263 allowedSender,264 entriesSender,265 recoverySender,266 storageSender,267 });268 })().catch((error) => done({269 probeError: error?.stack ?? error?.message ?? String(error),270 }));271`;272273async function run() {274 if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");275 if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");276277 const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-passkey-"));278 let driver;279 let driverPort;280 let sessionId;281 let fixtureServer;282 try {283 const xpiPath = await buildTestExtension(tempRoot);284 const firefoxBinary = findFirefoxBinary();285 286 287 Object.assign(process.env, {288 MOZ_HEADLESS: "1",289 MOZ_DISABLE_CONTENT_SANDBOX: "1",290 MOZ_DISABLE_RDD_SANDBOX: "1",291 LIBGL_ALWAYS_SOFTWARE: "1",292 });293 const port = await findFreePort();294 driverPort = port;295 driver = await start({296 host: "127.0.0.1",297 port,298 geckoDriverVersion: "0.37.1",299 ...(firefoxBinary ? { binary: firefoxBinary } : {}),300 log: "error",301 allowSystemAccess: true,302 });303 await waitForDriver(port);304305 const capabilities = {306 browserName: "firefox",307 acceptInsecureCerts: true,308 "moz:firefoxOptions": {309 args: ["-headless"],310 ...(firefoxBinary ? { binary: firefoxBinary } : {}),311 prefs: {312 "browser.shell.checkDefaultBrowser": false,313 "browser.startup.page": 0,314 "browser.startup.homepage": "about:blank",315 "remote.system-access-check.enabled": false,316 },317 },318 };319 const session = await webdriverRequest(port, "/session", {320 method: "POST",321 body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),322 });323 sessionId = session.sessionId;324325 fixtureServer = createHttpServer((_request, response) => {326 response.writeHead(200, { "content-type": "text/html; charset=utf-8" });327 response.end("<!doctype html><html><head><title>Peach passkey test</title></head><body></body></html>");328 });329 await new Promise((resolveListen, rejectListen) => {330 fixtureServer.once("error", rejectListen);331 fixtureServer.listen(0, "127.0.0.1", resolveListen);332 });333 const fixtureAddress = fixtureServer.address();334 if (!fixtureAddress || typeof fixtureAddress === "string") {335 throw new Error("Could not allocate the Firefox fixture server port");336 }337 const fixtureOrigin = `http://127.0.0.1:${fixtureAddress.port}`;338339 340 const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {341 method: "POST",342 body: JSON.stringify({343 path: xpiPath,344 temporary: true,345 }),346 });347 if (install?.id && install.id !== EXTENSION_ID) {348 throw new Error(`Unexpected Firefox add-on id: ${install.id}`);349 }350351 await webdriverRequest(port, `/session/${sessionId}/moz/context`, {352 method: "POST",353 body: JSON.stringify({ context: "chrome" }),354 });355 const addonInfo = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {356 method: "POST",357 body: JSON.stringify({358 script: `359 const done = arguments[arguments.length - 1];360 const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");361 const { ExtensionPermissions } = ChromeUtils.importESModule("resource://gre/modules/ExtensionPermissions.sys.mjs");362 AddonManager.getAddonByID(arguments[0]).then(async (addon) => {363 if (!addon) return null;364 const policy = WebExtensionPolicy.getByID(addon.id);365 await ExtensionPermissions.add(366 addon.id,367 { permissions: [], origins: ["<all_urls>"] },368 policy.extension,369 );370 await addon.reload();371 const reloadedAddon = await AddonManager.getAddonByID(addon.id);372 const reloadedPolicy = WebExtensionPolicy.getByID(addon.id);373 return {374 id: reloadedAddon.id,375 active: reloadedAddon.isActive,376 permissions: await ExtensionPermissions.get(addon.id),377 allowedOrigins: reloadedPolicy.allowedOrigins.patterns.map((pattern) => pattern.pattern),378 };379 }).then(done, (error) => done({ error: String(error), stack: error?.stack }));380 `,381 args: [EXTENSION_ID],382 }),383 });384 await webdriverRequest(port, `/session/${sessionId}/moz/context`, {385 method: "POST",386 body: JSON.stringify({ context: "content" }),387 });388 if (!addonInfo?.active || !addonInfo.permissions?.origins?.includes("<all_urls>")) {389 throw new Error(`Firefox add-on host permission was not granted: ${JSON.stringify(addonInfo)}`);390 }391392 393 await webdriverRequest(port, `/session/${sessionId}/url`, {394 method: "POST",395 body: JSON.stringify({ url: `${fixtureOrigin}/` }),396 });397 398 await new Promise((r) => setTimeout(r, 2000));399400 const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {401 method: "POST",402 body: JSON.stringify({ script: PROBE_SCRIPT, args: [] }),403 });404405 if (result.pageOrigin !== fixtureOrigin) {406 throw new Error(`Unexpected page origin: ${result.pageOrigin}`);407 }408409 const failures = [];410411 if (result.probeError) {412 failures.push(`page probe failed: ${result.probeError}`);413 }414415 if (result.context?.exportFunctionType !== "function" || result.context?.cloneIntoType !== "function") {416 failures.push(`Firefox privileged bridge APIs were unavailable: ${JSON.stringify(result.context)}`);417 }418419 if (result.context?.installGuard !== true) {420 failures.push(`Firefox passkey bridge did not install: ${JSON.stringify(result.context)}`);421 }422423 424 if (result.pageSourceHasRelay) {425 failures.push("PEACH_PASSKEY_REQUEST found in page source — the old relay may still be present");426 }427428 if (result.createResult?.id !== CREATE_CREDENTIAL_ID || result.createResult?.type !== "public-key") {429 failures.push(`navigator.credentials.create did not return the extension credential: ${JSON.stringify({ result: result.createResult, error: result.createError })}`);430 }431432 if (result.getResult?.id !== GET_CREDENTIAL_ID || result.getResult?.type !== "public-key") {433 failures.push(`navigator.credentials.get did not return the extension credential: ${JSON.stringify({ result: result.getResult, error: result.getError })}`);434 }435436 if (result.passkeyMessages?.length > 0) {437 failures.push(`page observed passkey postMessage traffic: ${JSON.stringify(result.passkeyMessages)}`);438 }439440 if (result.senderProbeReady !== "ready") {441 failures.push(`Firefox sender-context probe did not execute: ${JSON.stringify(result)}`);442 }443 if (Object.hasOwn(result.storageSender ?? {}, "peach_session_dk")) {444 failures.push(`Firefox content script accessed the session escrow key: ${JSON.stringify(result.storageSender)}`);445 }446 if (result.allowedSender?.error === "invalid_sender") {447 failures.push("Content-script-allowed GET_SETTINGS was rejected");448 }449 for (const [name, response] of [450 ["GET_ENTRIES", result.entriesSender],451 ["GET_RECOVERY_WORDS", result.recoverySender],452 ]) {453 if (response?.error !== "invalid_sender") {454 failures.push(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify(response)}`);455 }456 }457458 if (failures.length > 0) {459 throw new Error(`Firefox passkey matrix failures:\n - ${failures.join("\n - ")}\n Context: ${JSON.stringify(result.context)}\n Add-on: ${JSON.stringify(addonInfo)}`);460 }461462 console.log("Firefox security matrix passed: sender authorization and storage isolation held; exportFunction returned extension credentials with no forgeable passkey postMessage traffic.");463 } finally {464 if (driver && sessionId) {465 try {466 await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });467 } catch {468 469 }470 }471 if (fixtureServer) {472 await new Promise((resolveClose) => fixtureServer.close(resolveClose));473 }474 await stopDriver(driver);475 await rm(tempRoot, { recursive: true, force: true });476 }477}478479run().catch((error) => {480 console.error(error instanceof Error ? error.message : error);481 process.exitCode = 1;482});