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
9 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 −0
manifest.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": {

view file

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",

view file

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",

view file

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 });

view file

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 });

view file

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.

view file

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" },

view file

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+});

view file

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+}

view file