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
↓ peach-browser-69251a16.tar.gzatlas at this save
files changed7 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 −3manifest.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": {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",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",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) {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 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 }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",