69251a167183

preserve page-realm WebAuthn bytes and credential results in Firefox (v5.3.4)

time
2026-10-08 15:46 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
gpt-6
session
repair firefox passkey sign-in
parent
070c26097d42
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1scripts/test-firefox-passkeys.mjs+38 −7src/content/passkeys-firefox.test.ts+89 −2src/content/passkeys-firefox.ts+13 −14tests/fixtures/firefox-passkey-background.js+9 −3
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.3.3",4+  "version": "5.3.4",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.3.3",5+  "version": "5.3.4",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.3.3",3+  "version": "5.3.4",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

scripts/test-firefox-passkeys.mjs+38 −7
@@ -87,9 +87,11 @@8787       fetch(url, { ...options, signal: controller.signal }),8888       timeout,8989     ]);90+  } catch (error) {91+    controller.abort();92+    throw error;9093   } finally {9194     clearTimeout(timeoutId);92-    controller.abort();9395   }9496 }9597 @@ -211,12 +213,20 @@211213       signal: createAbort.signal,212214       publicKey: {213215         rp: { id: "127.0.0.1", name: "Test" },214-        user: { id: new Uint8Array(16), name: "test", displayName: "Test" },215-        challenge: new Uint8Array(16),216+        user: { id: new Uint8Array([5, 6, 7, 8]).buffer, name: "test", displayName: "Test" },217+        challenge: new Uint8Array([1, 2, 3, 4]).buffer,216218         pubKeyCredParams: [{ alg: -7, type: "public-key" }],217219       },218220     });219-    createResult = credential ? { id: credential.id, type: credential.type } : null;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;220230   } catch (e) {221231     createError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null };222232   } finally {@@ -232,11 +242,17 @@232242       signal: getAbort.signal,233243       publicKey: {234244         rpId: "127.0.0.1",235-        challenge: new Uint8Array(16),236-        allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }],245+        challenge: new Uint8Array([1, 2, 3, 4]).buffer,246+        allowCredentials: [{ id: new Uint8Array([5, 6, 7, 8]).buffer, type: "public-key" }],237247       },238248     });239-    getResult = credential ? { id: credential.id, type: credential.type } : null;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;240256   } catch (e) {241257     getError = { name: e?.name ?? "Error", message: e?.message ?? String(e), stack: e?.stack ?? null };242258   } finally {@@ -431,6 +447,21 @@431447 432448     if (result.getResult?.id !== GET_CREDENTIAL_ID || result.getResult?.type !== "public-key") {433449       failures.push(`navigator.credentials.get did not return the extension credential: ${JSON.stringify({ result: result.getResult, error: result.getError })}`);450+    }451+452+    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)}`);434465     }435466 436467     if (result.passkeyMessages?.length > 0) {

view file

src/content/passkeys-firefox.test.ts+89 −2
@@ -37,8 +37,31 @@3737   return func;3838 }3939 40-function stubCloneInto<T>(obj: T, _targetScope: any, _options?: { cloneFunctions?: boolean }): T {41-  return obj;40+let pageScopeValues = new WeakSet<object>();41+42+function stubCloneInto<T>(obj: T, _targetScope: unknown, _options?: { cloneFunctions?: boolean }): T {43+  function clone(value: unknown): unknown {44+    if (typeof value === "function") {45+      return (...args: unknown[]) => {46+        const result: unknown = Reflect.apply(value, undefined, args);47+        if (result && typeof result === "object" && !pageScopeValues.has(result)) {48+          return new Proxy(result, {49+            get() { throw new Error("Permission denied to access privileged object"); },50+            has() { throw new Error("Permission denied to access privileged object"); },51+            ownKeys() { throw new Error("Permission denied to access privileged object"); },52+          });53+        }54+        return result;55+      };56+    }57+    if (!value || typeof value !== "object") return value;58+    const copied = value instanceof ArrayBuffer ? value.slice(0) : Array.isArray(value)59+      ? value.map(clone)60+      : Object.fromEntries(Object.entries(value).map(([key, child]) => [key, clone(child)]));61+    pageScopeValues.add(copied);62+    return copied;63+  }64+  return clone(obj) as T;4265 }4366 4467 // ── chrome mock ────────────────────────────────────────────────────────────@@ -115,6 +138,7 @@115138     vi.resetModules();116139     setupChromeMock();117140     exportedFns.clear();141+    pageScopeValues = new WeakSet<object>();118142 119143     // Stub Firefox privileged APIs120144     vi.stubGlobal("exportFunction", stubExportFunction);@@ -304,6 +328,69 @@304328   });305329 306330   describe("valid responses produce credentials", () => {331+    it.each([332+      "new Uint8Array([1, 2, 3, 4]).buffer",333+      "new Uint8Array(new Uint8Array([9, 1, 2, 3, 4, 9]).buffer, 1, 4)",334+    ])("get: preserves page-realm buffers and view offsets (%s)", async (expression) => {335+      const { runInNewContext } = await import("node:vm");336+      const challenge: BufferSource = runInNewContext(expression);337+      const credentialId: ArrayBuffer = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");338+      expect(challenge instanceof ArrayBuffer).toBe(false);339+      mockSendMessage340+        .mockResolvedValueOnce({ ok: true, hasCredentials: true })341+        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });342+      await navigator.credentials.get({343+        publicKey: { challenge, rpId: "github.com", allowCredentials: [{ id: credentialId, type: "public-key" }] },344+      });345+      const request = mockSendMessage.mock.calls.find(([message]) => message.type === "PASSKEY_GET")?.[0] as {346+        payload: { challenge: string; clientDataJSON: number[]; allowCredentials: { id: string }[] };347+      };348+      expect(request.payload.challenge).toBe("AQIDBA");349+      expect(request.payload.allowCredentials).toEqual([{ id: "BQYHCA", type: "public-key" }]);350+      expect(JSON.parse(new TextDecoder().decode(new Uint8Array(request.payload.clientDataJSON))).challenge).toBe("AQIDBA");351+    });352+353+    it("create: preserves the challenge and user ID from the page's ArrayBuffer realm", async () => {354+      const { runInNewContext } = await import("node:vm");355+      const options = makeCreateOptions();356+      options.publicKey!.challenge = runInNewContext("new Uint8Array([1, 2, 3, 4]).buffer");357+      options.publicKey!.user.id = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");358+      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });359+      await navigator.credentials.create(options);360+      const request = mockSendMessage.mock.calls[0]![0] as { payload: { challenge: string; userId: string } };361+      expect(request.payload.challenge).toBe("AQIDBA");362+      expect(request.payload.userId).toBe("BQYHCA");363+    });364+365+    it("get: exposes method results to a page serializing the assertion", async () => {366+      const expected = validGetCredential();367+      mockSendMessage368+        .mockResolvedValueOnce({ ok: true, hasCredentials: true })369+        .mockResolvedValueOnce({ ok: true, credential: expected });370+      const credential = await navigator.credentials.get(makeGetOptions()) as PublicKeyCredential;371+      expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);372+      expect(JSON.parse(JSON.stringify(credential))).toMatchObject({373+        id: expected.id, response: { signature: expected.signature, userHandle: expected.userHandle },374+        clientExtensionResults: {},375+      });376+    });377+378+    it("create: exposes binary getters and method results to the page", async () => {379+      const expected = validCreateCredential();380+      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: expected });381+      const credential = await navigator.credentials.create(makeCreateOptions()) as PublicKeyCredential;382+      const response = credential.response as AuthenticatorAttestationResponse;383+      expect(response.getTransports()).toEqual(["internal"]);384+      expect(new Uint8Array(response.getAuthenticatorData())).toHaveLength(37);385+      expect(new Uint8Array(response.getPublicKey()!)).toHaveLength(64);386+      expect(response.getPublicKeyAlgorithm()).toBe(-7);387+      expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);388+      expect(JSON.parse(JSON.stringify(credential))).toMatchObject({389+        id: expected.id, response: { attestationObject: expected.attestationObject },390+        clientExtensionResults: {},391+      });392+    });393+307394     it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => {308395       mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });309396 

view file

src/content/passkeys-firefox.ts+13 −14
@@ -302,11 +302,10 @@302302   }303303 304304   function toArrayBuffer(view: BufferSource): ArrayBuffer {305-    if (view instanceof ArrayBuffer) return view;306-    const u8 = view as ArrayBufferView;307-    const out = new Uint8Array(u8.byteLength);308-    out.set(new Uint8Array(u8.buffer, u8.byteOffset, u8.byteLength));309-    return out.buffer;305+    const bytes = ArrayBuffer.isView(view)306+      ? new Uint8Array(view.buffer, view.byteOffset, view.byteLength)307+      : new Uint8Array(view);308+    return new Uint8Array(bytes).buffer;310309   }311310 312311   function bufferToBase64url(buffer: ArrayBuffer): string {@@ -358,13 +357,13 @@358357         response: {359358           clientDataJSON,360359           attestationObject,361-          getTransports: () => ["internal"],362-          getAuthenticatorData: () => authenticatorData,363-          getPublicKey: () => publicKey,360+          getTransports: () => cloneInto(["internal"], window),361+          getAuthenticatorData: () => cloneInto(authenticatorData, window),362+          getPublicKey: () => cloneInto(publicKey, window),364363           getPublicKeyAlgorithm: () => cred.algorithm ?? -7,365364         } as AuthenticatorAttestationResponse,366-        getClientExtensionResults: () => ({}),367-        toJSON: () => ({365+        getClientExtensionResults: () => cloneInto({}, window),366+        toJSON: () => cloneInto({368367           id: cred.id,369368           rawId: cred.id,370369           type: "public-key" as const,@@ -378,7 +377,7 @@378377           },379378           authenticatorAttachment: "platform" as const,380379           clientExtensionResults: {},381-        }),380+        }, window),382381       } as unknown as PublicKeyCredential;383382       return cloneInto(obj, window, { cloneFunctions: true });384383     }@@ -397,8 +396,8 @@397396         signature,398397         userHandle,399398       } as AuthenticatorAssertionResponse,400-      getClientExtensionResults: () => ({}),401-      toJSON: () => ({399+      getClientExtensionResults: () => cloneInto({}, window),400+      toJSON: () => cloneInto({402401         id: cred.id,403402         rawId: cred.id,404403         type: "public-key" as const,@@ -410,7 +409,7 @@410409         },411410         authenticatorAttachment: "platform" as const,412411         clientExtensionResults: {},413-      }),412+      }, window),414413     } as unknown as PublicKeyCredential;415414     return cloneInto(obj, window, { cloneFunctions: true });416415   }

view file

tests/fixtures/firefox-passkey-background.js+9 −3
@@ -3,15 +3,18 @@33 44 browser.runtime.onMessage.addListener((message) => {55   if (message?.type === "PASSKEY_HAS_CREDENTIALS") {6-    return Promise.resolve({ ok: true, hasCredentials: true });6+    return Promise.resolve({ ok: true, hasCredentials: message.payload.allowCredentials[0]?.id === GET_CREDENTIAL_ID });77   }88   if (message?.type === "PASSKEY_CREATE") {9+    if (message.payload.challenge !== "AQIDBA" || message.payload.userId !== "BQYHCA") {10+      return Promise.resolve({ ok: false, error: "corrupted_page_buffer" });11+    }912     return Promise.resolve({1013       ok: true,1114       credential: {1215         type: "create",1316         id: CREATE_CREDENTIAL_ID,14-        clientDataJSON: [1],17+        clientDataJSON: message.payload.clientDataJSON,1518         attestationObject: "AQ",1619         authenticatorData: "Ag",1720         publicKey: "Aw",@@ -20,12 +23,15 @@2023     });2124   }2225   if (message?.type === "PASSKEY_GET") {26+    if (message.payload.challenge !== "AQIDBA" || message.payload.allowCredentials[0]?.id !== GET_CREDENTIAL_ID) {27+      return Promise.resolve({ ok: false, error: "corrupted_page_buffer" });28+    }2329     return Promise.resolve({2430       ok: true,2531       credential: {2632         type: "get",2733         id: GET_CREDENTIAL_ID,28-        clientDataJSON: [1],34+        clientDataJSON: message.payload.clientDataJSON,2935         authenticatorData: "Ag",3036         signature: "Aw",3137         userHandle: "BA",

view file