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 let nativeGet: ReturnType<typeof vi.fn>;138 beforeEach(async () => {139 vi.resetModules();140 setupChromeMock();141 exportedFns.clear();142 pageScopeValues = new WeakSet<object>();143144 145 vi.stubGlobal("exportFunction", stubExportFunction);146 vi.stubGlobal("cloneInto", stubCloneInto);147148 149 delete (window as any).__peachPasskeyProxyInstalled;150151 152 153 const nativeCreate = vi.fn(async () => null);154 nativeGet = vi.fn(async () => null);155 Object.defineProperty(navigator, "credentials", {156 value: { create: nativeCreate, get: nativeGet },157 configurable: true,158 });159160 await import("./passkeys-firefox");161 });162163 afterEach(() => {164 vi.unstubAllGlobals();165 });166167 describe("no forgeable response channel", () => {168 it("exports non-async wrappers so Firefox can return page-scope promises", () => {169 expect(exportedFns.get("create")?.constructor.name).toBe("Function");170 expect(exportedFns.get("get")?.constructor.name).toBe("Function");171 });172173 it("a forged PEACH_PASSKEY_RESPONSE with a correct-former UUID has no listener and no effect", async () => {174 175 176 const postSpy = vi.spyOn(window, "postMessage");177178 179 const forged = new MessageEvent("message", {180 data: {181 type: "PEACH_PASSKEY_RESPONSE",182 id: "00000000-0000-0000-0000-000000000000",183 payload: { ok: true, credential: validCreateCredential() },184 },185 source: window,186 });187 window.dispatchEvent(forged);188189 190 191 192 const passkeyPostCalls = postSpy.mock.calls.filter(193 ([data]) => {194 const d = data as { type?: string };195 return d?.type === "PEACH_PASSKEY_REQUEST" || d?.type === "PEACH_PASSKEY_RESPONSE";196 },197 );198 expect(passkeyPostCalls).toHaveLength(0);199 postSpy.mockRestore();200 });201202 it("production code has no PEACH_PASSKEY_RESPONSE message listener", async () => {203 204 const fs = await import("node:fs");205 const path = await import("node:path");206 const source = fs.readFileSync(207 path.resolve(process.cwd(), "src/content/passkeys-firefox.ts"),208 "utf-8",209 );210 expect(source).not.toContain("PEACH_PASSKEY_RESPONSE");211 expect(source).not.toContain("PEACH_PASSKEY_REQUEST");212 213 expect(source).not.toMatch(/window\.postMessage\s*\(/);214 });215 });216217 describe("malformed responses fail closed", () => {218 it("create: falls through to original when credential.id is not base64url", async () => {219 mockSendMessage.mockResolvedValueOnce({220 ok: true,221 credential: { ...validCreateCredential(), id: "!!!not-base64url!!!" },222 });223224 const result = navigator.credentials.create(makeCreateOptions());225 226 227 await expect(result).resolves.toBeNull();228 });229230 it("create: falls through when credential type is wrong", async () => {231 mockSendMessage.mockResolvedValueOnce({232 ok: true,233 credential: { ...validCreateCredential(), type: "get" },234 });235236 await expect(237 navigator.credentials.create(makeCreateOptions()),238 ).resolves.toBeNull();239 });240241 it("create: falls through when clientDataJSON is missing", async () => {242 const cred = validCreateCredential();243 delete (cred as any).clientDataJSON;244 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: cred });245246 await expect(247 navigator.credentials.create(makeCreateOptions()),248 ).resolves.toBeNull();249 });250251 it("create: falls through when attestationObject is not base64url", async () => {252 mockSendMessage.mockResolvedValueOnce({253 ok: true,254 credential: { ...validCreateCredential(), attestationObject: "not valid!" },255 });256257 await expect(258 navigator.credentials.create(makeCreateOptions()),259 ).resolves.toBeNull();260 });261262 it("create: falls through when algorithm is unsupported", async () => {263 mockSendMessage.mockResolvedValueOnce({264 ok: true,265 credential: { ...validCreateCredential(), algorithm: -999 },266 });267268 await expect(269 navigator.credentials.create(makeCreateOptions()),270 ).resolves.toBeNull();271 });272273 it("get: falls through when signature is not base64url", async () => {274 275 mockSendMessage276 .mockResolvedValueOnce({ ok: true, hasCredentials: true })277 .mockResolvedValueOnce({278 ok: true,279 credential: { ...validGetCredential(), signature: "!!!invalid!!!" },280 });281282 await expect(283 navigator.credentials.get(makeGetOptions()),284 ).resolves.toBeNull();285 });286287 it("get: falls through when authenticatorData is not base64url", async () => {288 mockSendMessage289 .mockResolvedValueOnce({ ok: true, hasCredentials: true })290 .mockResolvedValueOnce({291 ok: true,292 credential: { ...validGetCredential(), authenticatorData: "" },293 });294295 await expect(296 navigator.credentials.get(makeGetOptions()),297 ).resolves.toBeNull();298 });299300 it("get: falls through when credential is null", async () => {301 mockSendMessage302 .mockResolvedValueOnce({ ok: true, hasCredentials: true })303 .mockResolvedValueOnce({ ok: true, credential: null });304305 await expect(306 navigator.credentials.get(makeGetOptions()),307 ).resolves.toBeNull();308 });309 });310311 describe("privileged bridge captures sendMessage at install time", () => {312 it("wrapper closure uses the captured sendMessage, not a re-lookup", async () => {313 314 315 316 const original = mockSendMessage;317 const replacement = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });318 vi.stubGlobal("chrome", {319 runtime: { id: "peach-extension", sendMessage: replacement, getURL: vi.fn(() => "moz-extension://abcd/") },320 });321322 323 mockSendMessage = original;324 await navigator.credentials.create(makeCreateOptions());325326 327 expect(replacement).not.toHaveBeenCalled();328 });329 });330331 describe("valid responses produce credentials", () => {332 it("get: leaves conditional requests and their cancellation signal with Firefox", async () => {333 const controller = new AbortController();334 const options: CredentialRequestOptions = {335 ...makeGetOptions(), mediation: "conditional", signal: controller.signal,336 };337 mockSendMessage338 .mockResolvedValueOnce({ ok: true, hasCredentials: true })339 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });340 await expect(navigator.credentials.get(options)).resolves.toBeNull();341 expect(nativeGet).toHaveBeenCalledWith(options);342 expect(mockSendMessage).not.toHaveBeenCalled();343 });344345 it.each([346 "new Uint8Array([1, 2, 3, 4]).buffer",347 "new Uint8Array(new Uint8Array([9, 1, 2, 3, 4, 9]).buffer, 1, 4)",348 ])("get: preserves page-realm buffers and view offsets (%s)", async (expression) => {349 const { runInNewContext } = await import("node:vm");350 const challenge: BufferSource = runInNewContext(expression);351 const credentialId: ArrayBuffer = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");352 expect(challenge instanceof ArrayBuffer).toBe(false);353 mockSendMessage354 .mockResolvedValueOnce({ ok: true, hasCredentials: true })355 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });356 await navigator.credentials.get({357 publicKey: { challenge, rpId: "github.com", allowCredentials: [{ id: credentialId, type: "public-key" }] },358 });359 const request = mockSendMessage.mock.calls.find(([message]) => message.type === "PASSKEY_GET")?.[0] as {360 payload: { challenge: string; clientDataJSON: number[]; allowCredentials: { id: string }[] };361 };362 expect(request.payload.challenge).toBe("AQIDBA");363 expect(request.payload.allowCredentials).toEqual([{ id: "BQYHCA", type: "public-key" }]);364 expect(JSON.parse(new TextDecoder().decode(new Uint8Array(request.payload.clientDataJSON))).challenge).toBe("AQIDBA");365 });366367 it("create: preserves the challenge and user ID from the page's ArrayBuffer realm", async () => {368 const { runInNewContext } = await import("node:vm");369 const options = makeCreateOptions();370 options.publicKey!.challenge = runInNewContext("new Uint8Array([1, 2, 3, 4]).buffer");371 options.publicKey!.user.id = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");372 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });373 await navigator.credentials.create(options);374 const request = mockSendMessage.mock.calls[0]![0] as { payload: { challenge: string; userId: string } };375 expect(request.payload.challenge).toBe("AQIDBA");376 expect(request.payload.userId).toBe("BQYHCA");377 });378379 it("get: exposes method results to a page serializing the assertion", async () => {380 const expected = validGetCredential();381 mockSendMessage382 .mockResolvedValueOnce({ ok: true, hasCredentials: true })383 .mockResolvedValueOnce({ ok: true, credential: expected });384 const credential = await navigator.credentials.get(makeGetOptions()) as PublicKeyCredential;385 expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);386 expect(JSON.parse(JSON.stringify(credential))).toMatchObject({387 id: expected.id, response: { signature: expected.signature, userHandle: expected.userHandle },388 clientExtensionResults: {},389 });390 });391392 it("create: exposes binary getters and method results to the page", async () => {393 const expected = validCreateCredential();394 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: expected });395 const credential = await navigator.credentials.create(makeCreateOptions()) as PublicKeyCredential;396 const response = credential.response as AuthenticatorAttestationResponse;397 expect(response.getTransports()).toEqual(["internal"]);398 expect(new Uint8Array(response.getAuthenticatorData())).toHaveLength(37);399 expect(new Uint8Array(response.getPublicKey()!)).toHaveLength(64);400 expect(response.getPublicKeyAlgorithm()).toBe(-7);401 expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);402 expect(JSON.parse(JSON.stringify(credential))).toMatchObject({403 id: expected.id, response: { attestationObject: expected.attestationObject },404 clientExtensionResults: {},405 });406 });407408 it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => {409 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });410411 const cred = await navigator.credentials.create(makeCreateOptions());412 expect(cred).not.toBeNull();413 expect((cred as any).type).toBe("public-key");414 expect((cred as any).id).toBeDefined();415 expect((cred as any).rawId).toBeDefined();416 expect((cred as any).response).toBeDefined();417 expect((cred as any).response.attestationObject).toBeDefined();418 });419420 it("get: returns a PublicKeyCredential-shaped object for a valid response", async () => {421 mockSendMessage422 .mockResolvedValueOnce({ ok: true, hasCredentials: true })423 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });424425 const cred = await navigator.credentials.get(makeGetOptions());426 expect(cred).not.toBeNull();427 expect((cred as any).type).toBe("public-key");428 expect((cred as any).response.signature).toBeDefined();429 expect((cred as any).response.authenticatorData).toBeDefined();430 });431432 it("create: throws NotAllowedError on user_cancelled", async () => {433 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" });434435 await expect(436 navigator.credentials.create(makeCreateOptions()),437 ).rejects.toMatchObject({ name: "NotAllowedError" });438 });439440 it("get: throws NotAllowedError on user_cancelled", async () => {441 mockSendMessage442 .mockResolvedValueOnce({ ok: true, hasCredentials: true })443 .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });444445 await expect(446 navigator.credentials.get(makeGetOptions()),447 ).rejects.toMatchObject({ name: "NotAllowedError" });448 });449450 it("create: falls through on vault_locked", async () => {451 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });452 await expect(453 navigator.credentials.create(makeCreateOptions()),454 ).resolves.toBeNull();455 });456457 it("get: falls through on no_credentials after has-credentials pre-check", async () => {458 mockSendMessage459 .mockResolvedValueOnce({ ok: true, hasCredentials: true })460 .mockResolvedValueOnce({ ok: false, error: "no_credentials" });461 await expect(462 navigator.credentials.get(makeGetOptions()),463 ).resolves.toBeNull();464 });465466 it("get: falls through when has-credentials returns false", async () => {467 mockSendMessage.mockResolvedValueOnce({ ok: true, hasCredentials: false });468 469 await navigator.credentials.get(makeGetOptions());470 const calls = mockSendMessage.mock.calls.map((c) => (c[0] as { type: string }).type);471 expect(calls).not.toContain("PASSKEY_GET");472 });473 });474475 describe("origin handling", () => {476 it("create: sends window.location.origin in the request, not a page-supplied value", async () => {477 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });478479 await navigator.credentials.create(makeCreateOptions());480481 const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } };482 expect(call.payload.origin).toBe(window.location.origin);483484 485 const clientData = JSON.parse(new TextDecoder().decode(new Uint8Array(call.payload.clientDataJSON)));486 expect(clientData.origin).toBe(window.location.origin);487 });488489 it("get: sends window.location.origin in the request", async () => {490 mockSendMessage491 .mockResolvedValueOnce({ ok: true, hasCredentials: true })492 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });493494 await navigator.credentials.get(makeGetOptions());495496 const getCall = mockSendMessage.mock.calls.find(497 (c) => (c[0] as { type: string }).type === "PASSKEY_GET",498 );499 const payload = (getCall![0] as { payload: { origin: string } }).payload;500 expect(payload.origin).toBe(window.location.origin);501 });502 });503504 describe("exportFunction unavailable", () => {505 it("falls through to native when exportFunction is not available", async () => {506 vi.resetModules();507 508 vi.stubGlobal("exportFunction", undefined);509 vi.stubGlobal("cloneInto", undefined);510511 const nativeCreate = vi.fn(async () => null);512 Object.defineProperty(navigator, "credentials", {513 value: { create: nativeCreate, get: vi.fn(async () => null) },514 configurable: true,515 });516517 await import("./passkeys-firefox");518519 520 await navigator.credentials.create(makeCreateOptions());521 expect(nativeCreate).toHaveBeenCalled();522 523 expect(mockSendMessage).not.toHaveBeenCalled();524 });525 });526});