5d2f2da905fc
fix: return Firefox passkey bridge promises to page scope (v5.0.92)
- time
- 2026-09-04 16:16 UTC · 1 month ago
- machine
- koh2
- agent
- gpt-5.6-sol
- session
- PB-26-209 Firefox passkey browser evidence
- parent
- 7ccc4df96a51
↓ peach-browser-5d2f2da9.tar.gzatlas at this save
files changed9 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1scripts/release-gate.test.ts+10 −9scripts/test-firefox-passkeys.mjs+140 −43src/content/passkeys-firefox.test.ts+5 −0src/content/passkeys-firefox.ts+114 −104tests/fixtures/firefox-passkey-background.js+36 −0tests/fixtures/firefox-passkey-context-probe.js+19 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.91",4+ "version": "5.0.92",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.0.91",5+ "version": "5.0.92",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.0.91",3+ "version": "5.0.92",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",scripts/release-gate.test.ts+10 −9
@@ -16,15 +16,6 @@1616 return { name, totalTests: 5, passed: 5, skipped: 0, flaky: 0, ...overrides };1717 }1818 19-function passingGate(): GateResult {20- return {21- projects: [22- project("chrome-release"),23- project("firefox-release"),24- ],25- };26-}27-2819 describe("PB-26-210: gate validation — fail-closed conditions", () => {2920 it("passes a clean result with the required project and no skips", () => {3021 expect(validateGateResult({ projects: [project("chrome-release"), project("firefox-release")] })).toEqual([]);@@ -144,5 +135,15 @@144135 const source = readFileSync(join(ROOT, "scripts/ci.ts"), "utf8");145136 expect(source).toContain("run-release-browser-gate");146137 expect(source).toContain("validateGateResult");138+ });139+});140+141+describe("PB-26-209: Firefox passkey browser probe", () => {142+ it("is a valid WebDriver async-script function body", () => {143+ const source = readFileSync(join(ROOT, "scripts/test-firefox-passkeys.mjs"), "utf8");144+ const probe = source.match(/const PROBE_SCRIPT = `([\s\S]*?)`;\n/);145+146+ expect(probe?.[1]).toBeDefined();147+ expect(() => new Function(probe![1]!)).not.toThrow();147148 });148149 });scripts/test-firefox-passkeys.mjs+140 −43
@@ -17,6 +17,7 @@1717 import { promisify } from "node:util";1818 import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";1919 import { existsSync } from "node:fs";20+import { createServer as createHttpServer } from "node:http";2021 import { tmpdir } from "node:os";2122 import { join, resolve } from "node:path";2223 import net from "node:net";@@ -26,6 +27,11 @@2627 const ROOT = resolve(new URL("..", import.meta.url).pathname);2728 const DIST = join(ROOT, "dist-firefox");2829 const WEB_EXT = join(ROOT, "node_modules/.bin/web-ext");30+const TEST_BACKGROUND = join(ROOT, "tests/fixtures/firefox-passkey-background.js");31+const CONTEXT_PROBE = join(ROOT, "tests/fixtures/firefox-passkey-context-probe.js");32+const CREATE_CREDENTIAL_ID = "AQIDBA";33+const GET_CREDENTIAL_ID = "BQYHCA";34+const EXTENSION_ID = "[email protected]";2935 3036 function findFirefoxBinary() {3137 const candidates = [@@ -103,7 +109,21 @@103109 const artifactsDir = join(tempRoot, "artifacts");104110 await cp(DIST, sourceDir, { recursive: true });105111 await mkdir(artifactsDir, { recursive: true });112+ await cp(TEST_BACKGROUND, join(sourceDir, "firefox-passkey-background.js"));113+ await cp(CONTEXT_PROBE, join(sourceDir, "firefox-passkey-context-probe.js"));106114 115+ const manifestPath = join(sourceDir, "manifest.json");116+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));117+ manifest.background = {118+ scripts: ["firefox-passkey-background.js"],119+ };120+ manifest.content_scripts.push({121+ matches: ["<all_urls>"],122+ js: ["firefox-passkey-context-probe.js"],123+ run_at: "document_start",124+ });125+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);126+107127 await execFileAsync(WEB_EXT, [108128 "build",109129 "--source-dir", sourceDir,@@ -117,64 +137,75 @@117137 118138 const PROBE_SCRIPT = `119139 const done = arguments[arguments.length - 1];140+ void (async () => {120141 121- // 1. Check if navigator.credentials.create is wrapped by exportFunction.122- // The wrapped function is from the page world via exportFunction, so123- // it will NOT be the native function. We check by calling toString()124- // — a native function returns "function create() { [native code] }"125- // while an exportFunction wrapper returns something else.126- const createStr = navigator.credentials.create.toString();127- const getStr = navigator.credentials.get.toString();128- const createIsNative = createStr.includes("[native code]");129- const getIsNative = getStr.includes("[native code]");130-131- // 2. Verify no PEACH_PASSKEY_REQUEST postMessage is used by the bridge.142+ // Verify no PEACH_PASSKEY_REQUEST postMessage is used by the bridge.132143 // We check the page for any existing listeners that post PEACH_PASSKEY_REQUEST.133144 const pageSourceHasRelay = document.documentElement.innerHTML.includes("PEACH_PASSKEY_REQUEST");134145 135- // 4. Call navigator.credentials.create with a publicKey request.136- // With a locked vault, the bridge should fall through to native137- // (return null in headless Firefox without a real authenticator).146+ const passkeyMessages = [];147+ window.addEventListener("message", (event) => {148+ if (event.data?.type === "PEACH_PASSKEY_REQUEST" || event.data?.type === "PEACH_PASSKEY_RESPONSE") {149+ passkeyMessages.push(event.data.type);150+ }151+ });152+153+ // 4. Exercise both wrappers against the test-only background responder.154+ // Abort quickly if the native authenticator receives either request.155+ const createAbort = new AbortController();156+ const createTimer = setTimeout(() => createAbort.abort(), 2000);138157 let createResult = null;139158 let createError = null;140159 try {141- createResult = await navigator.credentials.create({160+ const credential = await navigator.credentials.create({161+ signal: createAbort.signal,142162 publicKey: {143- rp: { id: "example.com", name: "Test" },163+ rp: { id: "127.0.0.1", name: "Test" },144164 user: { id: new Uint8Array(16), name: "test", displayName: "Test" },145165 challenge: new Uint8Array(16),146166 pubKeyCredParams: [{ alg: -7, type: "public-key" }],147167 },148168 });169+ createResult = credential ? { id: credential.id, type: credential.type } : null;149170 } catch (e) {150- createError = e?.name ?? String(e);171+ createError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null };172+ } finally {173+ clearTimeout(createTimer);151174 }152175 153- // 5. Call navigator.credentials.get — should also fall through.176+ const getAbort = new AbortController();177+ const getTimer = setTimeout(() => getAbort.abort(), 2000);154178 let getResult = null;155179 let getError = null;156180 try {157- getResult = await navigator.credentials.get({181+ const credential = await navigator.credentials.get({182+ signal: getAbort.signal,158183 publicKey: {159- rpId: "example.com",184+ rpId: "127.0.0.1",160185 challenge: new Uint8Array(16),161186 allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }],162187 },163188 });189+ getResult = credential ? { id: credential.id, type: credential.type } : null;164190 } catch (e) {165- getError = e?.name ?? String(e);191+ getError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null };192+ } finally {193+ clearTimeout(getTimer);166194 }167195 168196 done({169197 pageOrigin: location.origin,170- createIsNative,171- getIsNative,198+ context: JSON.parse(document.documentElement.dataset.peachFirefoxPasskeyContext ?? "null"),172199 pageSourceHasRelay,173200 createResult,174201 createError,175202 getResult,176203 getError,204+ passkeyMessages,177205 });206+ })().catch((error) => done({207+ probeError: error?.stack ?? error?.message ?? String(error),208+ }));178209 `;179210 180211 async function run() {@@ -185,6 +216,7 @@185216 let driver;186217 let driverPort;187218 let sessionId;219+ let fixtureServer;188220 try {189221 const xpiPath = await buildTestExtension(tempRoot);190222 const firefoxBinary = findFirefoxBinary();@@ -209,6 +241,7 @@209241 "browser.shell.checkDefaultBrowser": false,210242 "browser.startup.page": 0,211243 "browser.startup.homepage": "about:blank",244+ "remote.system-access-check.enabled": false,212245 },213246 },214247 };@@ -218,19 +251,77 @@218251 });219252 sessionId = session.sessionId;220253 254+ fixtureServer = createHttpServer((_request, response) => {255+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });256+ response.end("<!doctype html><html><head><title>Peach passkey test</title></head><body></body></html>");257+ });258+ await new Promise((resolveListen, rejectListen) => {259+ fixtureServer.once("error", rejectListen);260+ fixtureServer.listen(0, "127.0.0.1", resolveListen);261+ });262+ const fixtureAddress = fixtureServer.address();263+ if (!fixtureAddress || typeof fixtureAddress === "string") {264+ throw new Error("Could not allocate the Firefox fixture server port");265+ }266+ const fixtureOrigin = `http://127.0.0.1:${fixtureAddress.port}`;267+221268 // Install the temporary add-on.222- await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {269+ const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {223270 method: "POST",224271 body: JSON.stringify({225- addon: (await readFile(xpiPath)).toString("base64"),272+ path: xpiPath,226273 temporary: true,227274 }),228275 });276+ if (install?.id && install.id !== EXTENSION_ID) {277+ throw new Error(`Unexpected Firefox add-on id: ${install.id}`);278+ }229279 280+ await webdriverRequest(port, `/session/${sessionId}/moz/context`, {281+ method: "POST",282+ body: JSON.stringify({ context: "chrome" }),283+ });284+ const addonInfo = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {285+ method: "POST",286+ body: JSON.stringify({287+ script: `288+ const done = arguments[arguments.length - 1];289+ const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");290+ const { ExtensionPermissions } = ChromeUtils.importESModule("resource://gre/modules/ExtensionPermissions.sys.mjs");291+ AddonManager.getAddonByID(arguments[0]).then(async (addon) => {292+ if (!addon) return null;293+ const policy = WebExtensionPolicy.getByID(addon.id);294+ await ExtensionPermissions.add(295+ addon.id,296+ { permissions: [], origins: ["<all_urls>"] },297+ policy.extension,298+ );299+ await addon.reload();300+ const reloadedAddon = await AddonManager.getAddonByID(addon.id);301+ const reloadedPolicy = WebExtensionPolicy.getByID(addon.id);302+ return {303+ id: reloadedAddon.id,304+ active: reloadedAddon.isActive,305+ permissions: await ExtensionPermissions.get(addon.id),306+ allowedOrigins: reloadedPolicy.allowedOrigins.patterns.map((pattern) => pattern.pattern),307+ };308+ }).then(done, (error) => done({ error: String(error), stack: error?.stack }));309+ `,310+ args: [EXTENSION_ID],311+ }),312+ });313+ await webdriverRequest(port, `/session/${sessionId}/moz/context`, {314+ method: "POST",315+ body: JSON.stringify({ context: "content" }),316+ });317+ if (!addonInfo?.active || !addonInfo.permissions?.origins?.includes("<all_urls>")) {318+ throw new Error(`Firefox add-on host permission was not granted: ${JSON.stringify(addonInfo)}`);319+ }320+230321 // Navigate to a test page and wait for the content script to install.231322 await webdriverRequest(port, `/session/${sessionId}/url`, {232323 method: "POST",233- body: JSON.stringify({ url: "https://example.com/" }),324+ body: JSON.stringify({ url: `${fixtureOrigin}/` }),234325 });235326 // Give the content script time to run at document_start.236327 await new Promise((r) => setTimeout(r, 2000));@@ -240,43 +331,46 @@240331 body: JSON.stringify({ script: PROBE_SCRIPT, args: [] }),241332 });242333 243- if (result.pageOrigin !== "https://example.com") {334+ if (result.pageOrigin !== fixtureOrigin) {244335 throw new Error(`Unexpected page origin: ${result.pageOrigin}`);245336 }246337 247338 const failures = [];248339 249- // 1. navigator.credentials.create must be wrapped (not native).250- if (result.createIsNative) {251- failures.push("navigator.credentials.create is not wrapped by exportFunction — the privileged bridge was not installed");340+ if (result.probeError) {341+ failures.push(`page probe failed: ${result.probeError}`);252342 }253343 254- // 2. navigator.credentials.get must be wrapped (not native).255- if (result.getIsNative) {256- failures.push("navigator.credentials.get is not wrapped by exportFunction — the privileged bridge was not installed");344+ if (result.context?.exportFunctionType !== "function" || result.context?.cloneIntoType !== "function") {345+ failures.push(`Firefox privileged bridge APIs were unavailable: ${JSON.stringify(result.context)}`);257346 }258347 259- // 4. No PEACH_PASSKEY_REQUEST in page source.348+ if (result.context?.installGuard !== true) {349+ failures.push(`Firefox passkey bridge did not install: ${JSON.stringify(result.context)}`);350+ }351+352+ // No PEACH_PASSKEY_REQUEST in page source.260353 if (result.pageSourceHasRelay) {261354 failures.push("PEACH_PASSKEY_REQUEST found in page source — the old relay may still be present");262355 }263356 264- // 5. Create with locked vault should fall through (null or NotAllowedError265- // from native authenticator, not a crash/hang).266- if (result.createError && result.createError !== "NotAllowedError" && result.createError !== "AbortError") {267- failures.push(`navigator.credentials.create threw unexpected error: ${result.createError}`);357+ if (result.createResult?.id !== CREATE_CREDENTIAL_ID || result.createResult?.type !== "public-key") {358+ failures.push(`navigator.credentials.create did not return the extension credential: ${JSON.stringify({ result: result.createResult, error: result.createError })}`);268359 }269360 270- // 6. Get with locked vault should fall through.271- if (result.getError && result.getError !== "NotAllowedError" && result.getError !== "AbortError") {272- failures.push(`navigator.credentials.get threw unexpected error: ${result.getError}`);361+ if (result.getResult?.id !== GET_CREDENTIAL_ID || result.getResult?.type !== "public-key") {362+ failures.push(`navigator.credentials.get did not return the extension credential: ${JSON.stringify({ result: result.getResult, error: result.getError })}`);273363 }274364 365+ if (result.passkeyMessages?.length > 0) {366+ failures.push(`page observed passkey postMessage traffic: ${JSON.stringify(result.passkeyMessages)}`);367+ }368+275369 if (failures.length > 0) {276- throw new Error(`Firefox passkey matrix failures:\n - ${failures.join("\n - ")}`);370+ throw new Error(`Firefox passkey matrix failures:\n - ${failures.join("\n - ")}\n Context: ${JSON.stringify(result.context)}\n Add-on: ${JSON.stringify(addonInfo)}`);277371 }278372 279- console.log("Firefox passkey matrix passed: exportFunction bridge installed, no forgeable relay, bridge falls through with locked vault.");373+ console.log("Firefox passkey matrix passed: exportFunction bridge returned extension credentials and emitted no forgeable postMessage traffic.");280374 } finally {281375 if (driver && sessionId) {282376 try {@@ -284,6 +378,9 @@284378 } catch {285379 // Browser may already have exited.286380 }381+ }382+ if (fixtureServer) {383+ await new Promise((resolveClose) => fixtureServer.close(resolveClose));287384 }288385 driver?.kill("SIGTERM");289386 await rm(tempRoot, { recursive: true, force: true });src/content/passkeys-firefox.test.ts+5 −0
@@ -140,6 +140,11 @@140140 });141141 142142 describe("no forgeable response channel", () => {143+ it("exports non-async wrappers so Firefox can return page-scope promises", () => {144+ expect(exportedFns.get("create")?.constructor.name).toBe("Function");145+ expect(exportedFns.get("get")?.constructor.name).toBe("Function");146+ });147+143148 it("a forged PEACH_PASSKEY_RESPONSE with a correct-former UUID has no listener and no effect", async () => {144149 // The old relay listened for window "message" events of type145150 // PEACH_PASSKEY_RESPONSE. The new bridge must not.src/content/passkeys-firefox.ts+114 −104
@@ -86,135 +86,145 @@8686 const originalCreate = credentials.create.bind(credentials);8787 const originalGet = credentials.get.bind(credentials);8888 89+ function pagePromise<T>(task: () => Promise<T>): Promise<T> {90+ return new window.Promise<T>((resolve, reject) => {91+ task().then(resolve, reject);92+ });93+ }94+8995 exportFunction(90- async function patchedCreate(options?: CredentialCreationOptions): Promise<Credential | null> {91- if (!options?.publicKey) return originalCreate(options);96+ function patchedCreate(options?: CredentialCreationOptions): Promise<Credential | null> {97+ return pagePromise(async () => {98+ if (!options?.publicKey) return originalCreate(options);9299 93- try {94- const publicKey = options.publicKey;95- const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));96- const clientDataJSON = encodeJsonBytes({97- type: "webauthn.create",98- challenge,99- origin: window.location.origin,100- crossOrigin: false,101- });102-103- const userId = bufferToBase64url(toArrayBuffer(publicKey.user.id));104-105- const response = await sendMessage({106- type: "PASSKEY_CREATE",107- payload: {108- rpId: publicKey.rp.id ?? window.location.hostname,109- rpName: publicKey.rp.name,110- userId,111- userName: publicKey.user.name,112- userDisplayName: publicKey.user.displayName,100+ try {101+ const publicKey = options.publicKey;102+ const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));103+ const clientDataJSON = encodeJsonBytes({104+ type: "webauthn.create",113105 challenge,114- clientDataJSON: Array.from(clientDataJSON),115106 origin: window.location.origin,116- pubKeyCredParams: publicKey.pubKeyCredParams,117- authenticatorSelection: publicKey.authenticatorSelection,118- timeout: publicKey.timeout,119- attestation: publicKey.attestation,120- },121- });107+ crossOrigin: false,108+ });122109 123- if (!response?.ok) {124- const error = response?.error;125- if (error === "user_cancelled") {126- throw new DOMException(127- "The operation either timed out or was not allowed.",128- "NotAllowedError",129- );130- }131- if (error === "pro_required") {132- window.dispatchEvent(133- new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),134- );110+ const userId = bufferToBase64url(toArrayBuffer(publicKey.user.id));111+112+ const response = await sendMessage({113+ type: "PASSKEY_CREATE",114+ payload: {115+ rpId: publicKey.rp.id ?? window.location.hostname,116+ rpName: publicKey.rp.name,117+ userId,118+ userName: publicKey.user.name,119+ userDisplayName: publicKey.user.displayName,120+ challenge,121+ clientDataJSON: Array.from(clientDataJSON),122+ origin: window.location.origin,123+ pubKeyCredParams: publicKey.pubKeyCredParams,124+ authenticatorSelection: publicKey.authenticatorSelection,125+ timeout: publicKey.timeout,126+ attestation: publicKey.attestation,127+ },128+ });129+130+ if (!response?.ok) {131+ const error = response?.error;132+ if (error === "user_cancelled") {133+ throw new window.DOMException(134+ "The operation either timed out or was not allowed.",135+ "NotAllowedError",136+ );137+ }138+ if (error === "pro_required") {139+ window.dispatchEvent(140+ new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),141+ );142+ return originalCreate(options);143+ }144+ // vault_locked / no_credentials / not_applicable / timeout / unknown:145+ // fall through to the platform authenticator.135146 return originalCreate(options);136147 }137- // vault_locked / no_credentials / not_applicable / timeout / unknown:138- // fall through to the platform authenticator.148+149+ const cred = validateCreateCredential(response.credential);150+ return buildPublicKeyCredential(cred);151+ } catch (err) {152+ if (err instanceof DOMException && err.name === "NotAllowedError") throw err;153+ // Validation failure or unexpected error — fail closed, fall through.139154 return originalCreate(options);140155 }141-142- const cred = validateCreateCredential(response.credential);143- return buildPublicKeyCredential(cred);144- } catch (err) {145- if (err instanceof DOMException && err.name === "NotAllowedError") throw err;146- // Validation failure or unexpected error — fail closed, fall through.147- return originalCreate(options);148- }156+ });149157 },150158 pageCredentials,151159 { defineAs: "create" },152160 );153161 154162 exportFunction(155- async function patchedGet(options?: CredentialRequestOptions): Promise<Credential | null> {156- if (!options?.publicKey) return originalGet(options);163+ function patchedGet(options?: CredentialRequestOptions): Promise<Credential | null> {164+ return pagePromise(async () => {165+ if (!options?.publicKey) return originalGet(options);157166 158- try {159- const publicKey = options.publicKey;160- const rpId = publicKey.rpId ?? window.location.hostname;161- const allowCredentials = (publicKey.allowCredentials ?? []).map((c) => ({162- id: bufferToBase64url(toArrayBuffer(c.id)),163- type: c.type,164- }));167+ try {168+ const publicKey = options.publicKey;169+ const rpId = publicKey.rpId ?? window.location.hostname;170+ const allowCredentials = (publicKey.allowCredentials ?? []).map((c) => ({171+ id: bufferToBase64url(toArrayBuffer(c.id)),172+ type: c.type,173+ }));165174 166- // Cheap pre-check before showing any Peach UI.167- const has = await sendMessage({168- type: "PASSKEY_HAS_CREDENTIALS",169- payload: { rpId, allowCredentials },170- });171- if (!has?.ok || !has.hasCredentials) {172- return originalGet(options);173- }175+ // Cheap pre-check before showing any Peach UI.176+ const has = await sendMessage({177+ type: "PASSKEY_HAS_CREDENTIALS",178+ payload: { rpId, allowCredentials },179+ });180+ if (!has?.ok || !has.hasCredentials) {181+ return originalGet(options);182+ }174183 175- const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));176- const clientDataJSON = encodeJsonBytes({177- type: "webauthn.get",178- challenge,179- origin: window.location.origin,180- crossOrigin: false,181- });182-183- const response = await sendMessage({184- type: "PASSKEY_GET",185- payload: {186- rpId,184+ const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));185+ const clientDataJSON = encodeJsonBytes({186+ type: "webauthn.get",187187 challenge,188- clientDataJSON: Array.from(clientDataJSON),189188 origin: window.location.origin,190- allowCredentials,191- userVerification: publicKey.userVerification,192- },193- });189+ crossOrigin: false,190+ });194191 195- if (!response?.ok) {196- const error = response?.error;197- if (error === "user_cancelled") {198- throw new DOMException(199- "The operation either timed out or was not allowed.",200- "NotAllowedError",201- );202- }203- if (error === "pro_required") {204- window.dispatchEvent(205- new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),206- );192+ const response = await sendMessage({193+ type: "PASSKEY_GET",194+ payload: {195+ rpId,196+ challenge,197+ clientDataJSON: Array.from(clientDataJSON),198+ origin: window.location.origin,199+ allowCredentials,200+ userVerification: publicKey.userVerification,201+ },202+ });203+204+ if (!response?.ok) {205+ const error = response?.error;206+ if (error === "user_cancelled") {207+ throw new window.DOMException(208+ "The operation either timed out or was not allowed.",209+ "NotAllowedError",210+ );211+ }212+ if (error === "pro_required") {213+ window.dispatchEvent(214+ new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),215+ );216+ return originalGet(options);217+ }207218 return originalGet(options);208219 }220+221+ const cred = validateGetCredential(response.credential);222+ return buildPublicKeyCredential(cred);223+ } catch (err) {224+ if (err instanceof DOMException && err.name === "NotAllowedError") throw err;209225 return originalGet(options);210226 }211-212- const cred = validateGetCredential(response.credential);213- return buildPublicKeyCredential(cred);214- } catch (err) {215- if (err instanceof DOMException && err.name === "NotAllowedError") throw err;216- return originalGet(options);217- }227+ });218228 },219229 pageCredentials,220230 { defineAs: "get" },tests/fixtures/firefox-passkey-background.js+36 −0
@@ -0,0 +1,36 @@1+const CREATE_CREDENTIAL_ID = "AQIDBA";2+const GET_CREDENTIAL_ID = "BQYHCA";3+4+browser.runtime.onMessage.addListener((message) => {5+ if (message?.type === "PASSKEY_HAS_CREDENTIALS") {6+ return Promise.resolve({ ok: true, hasCredentials: true });7+ }8+ if (message?.type === "PASSKEY_CREATE") {9+ return Promise.resolve({10+ ok: true,11+ credential: {12+ type: "create",13+ id: CREATE_CREDENTIAL_ID,14+ clientDataJSON: [1],15+ attestationObject: "AQ",16+ authenticatorData: "Ag",17+ publicKey: "Aw",18+ algorithm: -7,19+ },20+ });21+ }22+ if (message?.type === "PASSKEY_GET") {23+ return Promise.resolve({24+ ok: true,25+ credential: {26+ type: "get",27+ id: GET_CREDENTIAL_ID,28+ clientDataJSON: [1],29+ authenticatorData: "Ag",30+ signature: "Aw",31+ userHandle: "BA",32+ },33+ });34+ }35+ return undefined;36+});tests/fixtures/firefox-passkey-context-probe.js+19 −0
@@ -0,0 +1,19 @@1+function recordPasskeyContext() {2+ const credentials = navigator.credentials;3+ document.documentElement.dataset.peachFirefoxPasskeyContext = JSON.stringify({4+ exportFunctionType: typeof exportFunction,5+ cloneIntoType: typeof cloneInto,6+ credentialsType: typeof credentials,7+ createType: typeof credentials?.create,8+ getType: typeof credentials?.get,9+ credentialsHasWrappedObject: Boolean(credentials?.wrappedJSObject),10+ windowHasWrappedObject: Boolean(window.wrappedJSObject),11+ installGuard: window.__peachPasskeyProxyInstalled ?? null,12+ });13+}14+15+if (document.documentElement) {16+ recordPasskeyContext();17+} else {18+ document.addEventListener("DOMContentLoaded", recordPasskeyContext, { once: true });19+}