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: rejects 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 ).rejects.toMatchObject({ name: "NotAllowedError" });285 });286287 it("get: rejects 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 ).rejects.toMatchObject({ name: "NotAllowedError" });298 });299300 it("get: rejects 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 ).rejects.toMatchObject({ name: "NotAllowedError" });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("get: waits for a cold background instead of opening Firefox native UI", async () => {346 mockSendMessage347 .mockRejectedValueOnce(new Error("Could not establish connection. Receiving end does not exist."))348 .mockResolvedValueOnce({ ok: false, error: "vault_locked" })349 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });350 const result = await navigator.credentials.get(makeGetOptions());351 expect(result?.id).toBe(validGetCredential().id);352 expect(nativeGet).not.toHaveBeenCalled();353 });354355 it("get: retries a missing background response before continuing through unlock", async () => {356 mockSendMessage357 .mockResolvedValueOnce(undefined)358 .mockResolvedValueOnce({ ok: false, error: "vault_locked" })359 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });360 expect((await navigator.credentials.get(makeGetOptions()))?.id).toBe(validGetCredential().id);361 expect(nativeGet).not.toHaveBeenCalled();362 });363364 it("get: rejects unavailable Peach instead of silently opening native Firefox UI", async () => {365 mockSendMessage.mockRejectedValue(new Error("Receiving end does not exist"));366 await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });367 expect(mockSendMessage).toHaveBeenCalledTimes(3);368 expect(nativeGet).not.toHaveBeenCalled();369 });370371 it("get: rejects a Peach operation error instead of opening native Firefox UI", async () => {372 mockSendMessage373 .mockResolvedValueOnce({ ok: true, hasCredentials: true })374 .mockResolvedValueOnce({ ok: false, error: "operation_failed" });375 await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });376 expect(nativeGet).not.toHaveBeenCalled();377 });378379 it("get: never retries a signing request after a delivery error", async () => {380 mockSendMessage381 .mockResolvedValueOnce({ ok: true, hasCredentials: true })382 .mockRejectedValueOnce(new Error("Background disconnected"));383 await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });384 expect(mockSendMessage).toHaveBeenCalledTimes(2);385 expect(nativeGet).not.toHaveBeenCalled();386 });387388 it("get: preserves native sign-in when interception is disabled", async () => {389 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "not_applicable" });390 const options = makeGetOptions();391 await expect(navigator.credentials.get(options)).resolves.toBeNull();392 expect(nativeGet).toHaveBeenCalledWith(options);393 expect(mockSendMessage).toHaveBeenCalledTimes(1);394 });395396 it("get: rejects a failed credential check without retrying a delivered reply", async () => {397 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "internal_error" });398 await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });399 expect(mockSendMessage).toHaveBeenCalledTimes(1);400 expect(nativeGet).not.toHaveBeenCalled();401 });402403 it("get: preserves an explicit security-key request without contacting Peach", async () => {404 const options = makeGetOptions();405 options.publicKey = { ...options.publicKey!, hints: ["security-key"] } as PublicKeyCredentialRequestOptions;406 await expect(navigator.credentials.get(options)).resolves.toBeNull();407 expect(nativeGet).toHaveBeenCalledWith(options);408 expect(mockSendMessage).not.toHaveBeenCalled();409 });410411 it.each([412 "new Uint8Array([1, 2, 3, 4]).buffer",413 "new Uint8Array(new Uint8Array([9, 1, 2, 3, 4, 9]).buffer, 1, 4)",414 ])("get: preserves page-realm buffers and view offsets (%s)", async (expression) => {415 const { runInNewContext } = await import("node:vm");416 const challenge: BufferSource = runInNewContext(expression);417 const credentialId: ArrayBuffer = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");418 expect(challenge instanceof ArrayBuffer).toBe(false);419 mockSendMessage420 .mockResolvedValueOnce({ ok: true, hasCredentials: true })421 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });422 await navigator.credentials.get({423 publicKey: { challenge, rpId: "github.com", allowCredentials: [{ id: credentialId, type: "public-key" }] },424 });425 const request = mockSendMessage.mock.calls.find(([message]) => message.type === "PASSKEY_GET")?.[0] as {426 payload: { challenge: string; clientDataJSON: number[]; allowCredentials: { id: string }[] };427 };428 expect(request.payload.challenge).toBe("AQIDBA");429 expect(request.payload.allowCredentials).toEqual([{ id: "BQYHCA", type: "public-key" }]);430 expect(JSON.parse(new TextDecoder().decode(new Uint8Array(request.payload.clientDataJSON))).challenge).toBe("AQIDBA");431 });432433 it("create: preserves the challenge and user ID from the page's ArrayBuffer realm", async () => {434 const { runInNewContext } = await import("node:vm");435 const options = makeCreateOptions();436 options.publicKey!.challenge = runInNewContext("new Uint8Array([1, 2, 3, 4]).buffer");437 options.publicKey!.user.id = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");438 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });439 await navigator.credentials.create(options);440 const request = mockSendMessage.mock.calls[0]![0] as { payload: { challenge: string; userId: string } };441 expect(request.payload.challenge).toBe("AQIDBA");442 expect(request.payload.userId).toBe("BQYHCA");443 });444445 it("get: exposes method results to a page serializing the assertion", async () => {446 const expected = validGetCredential();447 mockSendMessage448 .mockResolvedValueOnce({ ok: true, hasCredentials: true })449 .mockResolvedValueOnce({ ok: true, credential: expected });450 const credential = await navigator.credentials.get(makeGetOptions()) as PublicKeyCredential;451 expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);452 expect(JSON.parse(JSON.stringify(credential))).toMatchObject({453 id: expected.id, response: { signature: expected.signature, userHandle: expected.userHandle },454 clientExtensionResults: {},455 });456 });457458 it("create: exposes binary getters and method results to the page", async () => {459 const expected = validCreateCredential();460 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: expected });461 const credential = await navigator.credentials.create(makeCreateOptions()) as PublicKeyCredential;462 const response = credential.response as AuthenticatorAttestationResponse;463 expect(response.getTransports()).toEqual(["internal"]);464 expect(new Uint8Array(response.getAuthenticatorData())).toHaveLength(37);465 expect(new Uint8Array(response.getPublicKey()!)).toHaveLength(64);466 expect(response.getPublicKeyAlgorithm()).toBe(-7);467 expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);468 expect(JSON.parse(JSON.stringify(credential))).toMatchObject({469 id: expected.id, response: { attestationObject: expected.attestationObject },470 clientExtensionResults: {},471 });472 });473474 it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => {475 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });476477 const cred = await navigator.credentials.create(makeCreateOptions());478 expect(cred).not.toBeNull();479 expect((cred as any).type).toBe("public-key");480 expect((cred as any).id).toBeDefined();481 expect((cred as any).rawId).toBeDefined();482 expect((cred as any).response).toBeDefined();483 expect((cred as any).response.attestationObject).toBeDefined();484 });485486 it("get: returns a PublicKeyCredential-shaped object for a valid response", async () => {487 mockSendMessage488 .mockResolvedValueOnce({ ok: true, hasCredentials: true })489 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });490491 const cred = await navigator.credentials.get(makeGetOptions());492 expect(cred).not.toBeNull();493 expect((cred as any).type).toBe("public-key");494 expect((cred as any).response.signature).toBeDefined();495 expect((cred as any).response.authenticatorData).toBeDefined();496 });497498 it("create: throws NotAllowedError on user_cancelled", async () => {499 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" });500501 await expect(502 navigator.credentials.create(makeCreateOptions()),503 ).rejects.toMatchObject({ name: "NotAllowedError" });504 });505506 it("get: throws NotAllowedError on user_cancelled", async () => {507 mockSendMessage508 .mockResolvedValueOnce({ ok: true, hasCredentials: true })509 .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });510511 await expect(512 navigator.credentials.get(makeGetOptions()),513 ).rejects.toMatchObject({ name: "NotAllowedError" });514 });515516 it("create: falls through on vault_locked", async () => {517 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });518 await expect(519 navigator.credentials.create(makeCreateOptions()),520 ).resolves.toBeNull();521 });522523 it("get: continues through unlock when the credential check reports vault_locked", async () => {524 mockSendMessage525 .mockResolvedValueOnce({ ok: false, error: "vault_locked" })526 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });527 const result = await navigator.credentials.get(makeGetOptions());528 expect(result?.id).toBe(validGetCredential().id);529 expect(nativeGet).not.toHaveBeenCalled();530 });531532 it("get: rejects an abandoned unlock without opening native Firefox UI", async () => {533 mockSendMessage534 .mockResolvedValueOnce({ ok: false, error: "vault_locked" })535 .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });536 await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });537 expect(nativeGet).not.toHaveBeenCalled();538 });539540 it("get: falls through on no_credentials after has-credentials pre-check", async () => {541 mockSendMessage542 .mockResolvedValueOnce({ ok: true, hasCredentials: true })543 .mockResolvedValueOnce({ ok: false, error: "no_credentials" });544 await expect(545 navigator.credentials.get(makeGetOptions()),546 ).resolves.toBeNull();547 });548549 it("get: falls through when has-credentials returns false", async () => {550 mockSendMessage.mockResolvedValueOnce({ ok: true, hasCredentials: false });551 552 await navigator.credentials.get(makeGetOptions());553 const calls = mockSendMessage.mock.calls.map((c) => (c[0] as { type: string }).type);554 expect(calls).not.toContain("PASSKEY_GET");555 });556 });557558 describe("origin handling", () => {559 it("create: sends window.location.origin in the request, not a page-supplied value", async () => {560 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });561562 await navigator.credentials.create(makeCreateOptions());563564 const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } };565 expect(call.payload.origin).toBe(window.location.origin);566567 568 const clientData = JSON.parse(new TextDecoder().decode(new Uint8Array(call.payload.clientDataJSON)));569 expect(clientData.origin).toBe(window.location.origin);570 });571572 it("get: sends window.location.origin in the request", async () => {573 mockSendMessage574 .mockResolvedValueOnce({ ok: true, hasCredentials: true })575 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });576577 await navigator.credentials.get(makeGetOptions());578579 const getCall = mockSendMessage.mock.calls.find(580 (c) => (c[0] as { type: string }).type === "PASSKEY_GET",581 );582 const payload = (getCall![0] as { payload: { origin: string } }).payload;583 expect(payload.origin).toBe(window.location.origin);584 });585 });586587 describe("exportFunction unavailable", () => {588 it("falls through to native when exportFunction is not available", async () => {589 vi.resetModules();590 591 vi.stubGlobal("exportFunction", undefined);592 vi.stubGlobal("cloneInto", undefined);593594 const nativeCreate = vi.fn(async () => null);595 Object.defineProperty(navigator, "credentials", {596 value: { create: nativeCreate, get: vi.fn(async () => null) },597 configurable: true,598 });599600 await import("./passkeys-firefox");601602 603 await navigator.credentials.create(makeCreateOptions());604 expect(nativeCreate).toHaveBeenCalled();605 606 expect(mockSendMessage).not.toHaveBeenCalled();607 });608 });609});