123import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";45678910111213141516171819202122const exportedFns = new Map<string, (...args: any[]) => any>();2324function stubExportFunction(25 func: (...args: any[]) => any,26 _targetScope: any,27 options?: { defineAs?: string },28): (...args: any[]) => any {29 const name = options?.defineAs;30 if (name) {31 exportedFns.set(name, func);32 33 if (_targetScope && _targetScope !== globalThis) {34 _targetScope[name] = func;35 }36 }37 return func;38}3940let pageScopeValues = new WeakSet<object>();4142function 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;65}66676869let mockSendMessage: ReturnType<typeof vi.fn>;7071function setupChromeMock() {72 mockSendMessage = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });73 vi.stubGlobal("chrome", {74 runtime: {75 id: "peach-extension",76 sendMessage: mockSendMessage,77 getURL: vi.fn(() => "moz-extension://abcd/"),78 },79 });80}81828384function base64url(bytes: Uint8Array): string {85 let str = "";86 for (const b of bytes) str += String.fromCharCode(b);87 return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");88}8990function makeCreateOptions(rpId = "github.com"): CredentialCreationOptions {91 return {92 publicKey: {93 rp: { id: rpId, name: "GitHub" },94 user: { id: new Uint8Array(16), name: "june", displayName: "June" },95 challenge: new Uint8Array(16),96 pubKeyCredParams: [{ alg: -7, type: "public-key" }],97 },98 } as CredentialCreationOptions;99}100101function makeGetOptions(rpId = "github.com"): CredentialRequestOptions {102 return {103 publicKey: {104 rpId,105 challenge: new Uint8Array(16),106 allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }],107 },108 } as CredentialRequestOptions;109}110111function validCreateCredential() {112 return {113 type: "create",114 id: base64url(new Uint8Array(32)),115 clientDataJSON: Array.from(new TextEncoder().encode(JSON.stringify({ type: "webauthn.create", challenge: "x", origin: "https://github.com", crossOrigin: false }))),116 attestationObject: base64url(new Uint8Array(64)),117 authenticatorData: base64url(new Uint8Array(37)),118 publicKey: base64url(new Uint8Array(64)),119 algorithm: -7,120 };121}122123function validGetCredential() {124 return {125 type: "get",126 id: base64url(new Uint8Array(32)),127 clientDataJSON: Array.from(new TextEncoder().encode(JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false }))),128 authenticatorData: base64url(new Uint8Array(37)),129 signature: base64url(new Uint8Array(64)),130 userHandle: base64url(new Uint8Array(16)),131 };132}133134135136describe("PB-26-209: exportFunction privileged bridge", () => {137 beforeEach(async () => {138 vi.resetModules();139 setupChromeMock();140 exportedFns.clear();141 pageScopeValues = new WeakSet<object>();142143 144 vi.stubGlobal("exportFunction", stubExportFunction);145 vi.stubGlobal("cloneInto", stubCloneInto);146147 148 delete (window as any).__peachPasskeyProxyInstalled;149150 151 152 const nativeCreate = vi.fn(async () => null);153 const nativeGet = vi.fn(async () => null);154 Object.defineProperty(navigator, "credentials", {155 value: { create: nativeCreate, get: nativeGet },156 configurable: true,157 });158159 await import("./passkeys-firefox");160 });161162 afterEach(() => {163 vi.unstubAllGlobals();164 });165166 describe("no forgeable response channel", () => {167 it("exports non-async wrappers so Firefox can return page-scope promises", () => {168 expect(exportedFns.get("create")?.constructor.name).toBe("Function");169 expect(exportedFns.get("get")?.constructor.name).toBe("Function");170 });171172 it("a forged PEACH_PASSKEY_RESPONSE with a correct-former UUID has no listener and no effect", async () => {173 174 175 const postSpy = vi.spyOn(window, "postMessage");176177 178 const forged = new MessageEvent("message", {179 data: {180 type: "PEACH_PASSKEY_RESPONSE",181 id: "00000000-0000-0000-0000-000000000000",182 payload: { ok: true, credential: validCreateCredential() },183 },184 source: window,185 });186 window.dispatchEvent(forged);187188 189 190 191 const passkeyPostCalls = postSpy.mock.calls.filter(192 ([data]) => {193 const d = data as { type?: string };194 return d?.type === "PEACH_PASSKEY_REQUEST" || d?.type === "PEACH_PASSKEY_RESPONSE";195 },196 );197 expect(passkeyPostCalls).toHaveLength(0);198 postSpy.mockRestore();199 });200201 it("production code has no PEACH_PASSKEY_RESPONSE message listener", async () => {202 203 const fs = await import("node:fs");204 const path = await import("node:path");205 const source = fs.readFileSync(206 path.resolve(process.cwd(), "src/content/passkeys-firefox.ts"),207 "utf-8",208 );209 expect(source).not.toContain("PEACH_PASSKEY_RESPONSE");210 expect(source).not.toContain("PEACH_PASSKEY_REQUEST");211 212 expect(source).not.toMatch(/window\.postMessage\s*\(/);213 });214 });215216 describe("malformed responses fail closed", () => {217 it("create: falls through to original when credential.id is not base64url", async () => {218 mockSendMessage.mockResolvedValueOnce({219 ok: true,220 credential: { ...validCreateCredential(), id: "!!!not-base64url!!!" },221 });222223 const result = navigator.credentials.create(makeCreateOptions());224 225 226 await expect(result).resolves.toBeNull();227 });228229 it("create: falls through when credential type is wrong", async () => {230 mockSendMessage.mockResolvedValueOnce({231 ok: true,232 credential: { ...validCreateCredential(), type: "get" },233 });234235 await expect(236 navigator.credentials.create(makeCreateOptions()),237 ).resolves.toBeNull();238 });239240 it("create: falls through when clientDataJSON is missing", async () => {241 const cred = validCreateCredential();242 delete (cred as any).clientDataJSON;243 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: cred });244245 await expect(246 navigator.credentials.create(makeCreateOptions()),247 ).resolves.toBeNull();248 });249250 it("create: falls through when attestationObject is not base64url", async () => {251 mockSendMessage.mockResolvedValueOnce({252 ok: true,253 credential: { ...validCreateCredential(), attestationObject: "not valid!" },254 });255256 await expect(257 navigator.credentials.create(makeCreateOptions()),258 ).resolves.toBeNull();259 });260261 it("create: falls through when algorithm is unsupported", async () => {262 mockSendMessage.mockResolvedValueOnce({263 ok: true,264 credential: { ...validCreateCredential(), algorithm: -999 },265 });266267 await expect(268 navigator.credentials.create(makeCreateOptions()),269 ).resolves.toBeNull();270 });271272 it("get: falls through when signature is not base64url", async () => {273 274 mockSendMessage275 .mockResolvedValueOnce({ ok: true, hasCredentials: true })276 .mockResolvedValueOnce({277 ok: true,278 credential: { ...validGetCredential(), signature: "!!!invalid!!!" },279 });280281 await expect(282 navigator.credentials.get(makeGetOptions()),283 ).resolves.toBeNull();284 });285286 it("get: falls through when authenticatorData is not base64url", async () => {287 mockSendMessage288 .mockResolvedValueOnce({ ok: true, hasCredentials: true })289 .mockResolvedValueOnce({290 ok: true,291 credential: { ...validGetCredential(), authenticatorData: "" },292 });293294 await expect(295 navigator.credentials.get(makeGetOptions()),296 ).resolves.toBeNull();297 });298299 it("get: falls through when credential is null", async () => {300 mockSendMessage301 .mockResolvedValueOnce({ ok: true, hasCredentials: true })302 .mockResolvedValueOnce({ ok: true, credential: null });303304 await expect(305 navigator.credentials.get(makeGetOptions()),306 ).resolves.toBeNull();307 });308 });309310 describe("privileged bridge captures sendMessage at install time", () => {311 it("wrapper closure uses the captured sendMessage, not a re-lookup", async () => {312 313 314 315 const original = mockSendMessage;316 const replacement = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });317 vi.stubGlobal("chrome", {318 runtime: { id: "peach-extension", sendMessage: replacement, getURL: vi.fn(() => "moz-extension://abcd/") },319 });320321 322 mockSendMessage = original;323 await navigator.credentials.create(makeCreateOptions());324325 326 expect(replacement).not.toHaveBeenCalled();327 });328 });329330 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 });352353 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 });364365 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 });377378 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 });393394 it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => {395 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });396397 const cred = await navigator.credentials.create(makeCreateOptions());398 expect(cred).not.toBeNull();399 expect((cred as any).type).toBe("public-key");400 expect((cred as any).id).toBeDefined();401 expect((cred as any).rawId).toBeDefined();402 expect((cred as any).response).toBeDefined();403 expect((cred as any).response.attestationObject).toBeDefined();404 });405406 it("get: returns a PublicKeyCredential-shaped object for a valid response", async () => {407 mockSendMessage408 .mockResolvedValueOnce({ ok: true, hasCredentials: true })409 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });410411 const cred = await navigator.credentials.get(makeGetOptions());412 expect(cred).not.toBeNull();413 expect((cred as any).type).toBe("public-key");414 expect((cred as any).response.signature).toBeDefined();415 expect((cred as any).response.authenticatorData).toBeDefined();416 });417418 it("create: throws NotAllowedError on user_cancelled", async () => {419 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" });420421 await expect(422 navigator.credentials.create(makeCreateOptions()),423 ).rejects.toMatchObject({ name: "NotAllowedError" });424 });425426 it("get: throws NotAllowedError on user_cancelled", async () => {427 mockSendMessage428 .mockResolvedValueOnce({ ok: true, hasCredentials: true })429 .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });430431 await expect(432 navigator.credentials.get(makeGetOptions()),433 ).rejects.toMatchObject({ name: "NotAllowedError" });434 });435436 it("create: falls through on vault_locked", async () => {437 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });438 await expect(439 navigator.credentials.create(makeCreateOptions()),440 ).resolves.toBeNull();441 });442443 it("get: falls through on no_credentials after has-credentials pre-check", async () => {444 mockSendMessage445 .mockResolvedValueOnce({ ok: true, hasCredentials: true })446 .mockResolvedValueOnce({ ok: false, error: "no_credentials" });447 await expect(448 navigator.credentials.get(makeGetOptions()),449 ).resolves.toBeNull();450 });451452 it("get: falls through when has-credentials returns false", async () => {453 mockSendMessage.mockResolvedValueOnce({ ok: true, hasCredentials: false });454 455 await navigator.credentials.get(makeGetOptions());456 const calls = mockSendMessage.mock.calls.map((c) => (c[0] as { type: string }).type);457 expect(calls).not.toContain("PASSKEY_GET");458 });459 });460461 describe("origin handling", () => {462 it("create: sends window.location.origin in the request, not a page-supplied value", async () => {463 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });464465 await navigator.credentials.create(makeCreateOptions());466467 const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } };468 expect(call.payload.origin).toBe(window.location.origin);469470 471 const clientData = JSON.parse(new TextDecoder().decode(new Uint8Array(call.payload.clientDataJSON)));472 expect(clientData.origin).toBe(window.location.origin);473 });474475 it("get: sends window.location.origin in the request", async () => {476 mockSendMessage477 .mockResolvedValueOnce({ ok: true, hasCredentials: true })478 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });479480 await navigator.credentials.get(makeGetOptions());481482 const getCall = mockSendMessage.mock.calls.find(483 (c) => (c[0] as { type: string }).type === "PASSKEY_GET",484 );485 const payload = (getCall![0] as { payload: { origin: string } }).payload;486 expect(payload.origin).toBe(window.location.origin);487 });488 });489490 describe("exportFunction unavailable", () => {491 it("falls through to native when exportFunction is not available", async () => {492 vi.resetModules();493 494 vi.stubGlobal("exportFunction", undefined);495 vi.stubGlobal("cloneInto", undefined);496497 const nativeCreate = vi.fn(async () => null);498 Object.defineProperty(navigator, "credentials", {499 value: { create: nativeCreate, get: vi.fn(async () => null) },500 configurable: true,501 });502503 await import("./passkeys-firefox");504505 506 await navigator.credentials.create(makeCreateOptions());507 expect(nativeCreate).toHaveBeenCalled();508 509 expect(mockSendMessage).not.toHaveBeenCalled();510 });511 });512});