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}3940function stubCloneInto<T>(obj: T, _targetScope: any, _options?: { cloneFunctions?: boolean }): T {41 return obj;42}43444546let mockSendMessage: ReturnType<typeof vi.fn>;4748function setupChromeMock() {49 mockSendMessage = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });50 vi.stubGlobal("chrome", {51 runtime: {52 id: "peach-extension",53 sendMessage: mockSendMessage,54 getURL: vi.fn(() => "moz-extension://abcd/"),55 },56 });57}58596061function base64url(bytes: Uint8Array): string {62 let str = "";63 for (const b of bytes) str += String.fromCharCode(b);64 return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");65}6667function makeCreateOptions(rpId = "github.com"): CredentialCreationOptions {68 return {69 publicKey: {70 rp: { id: rpId, name: "GitHub" },71 user: { id: new Uint8Array(16), name: "june", displayName: "June" },72 challenge: new Uint8Array(16),73 pubKeyCredParams: [{ alg: -7, type: "public-key" }],74 },75 } as CredentialCreationOptions;76}7778function makeGetOptions(rpId = "github.com"): CredentialRequestOptions {79 return {80 publicKey: {81 rpId,82 challenge: new Uint8Array(16),83 allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }],84 },85 } as CredentialRequestOptions;86}8788function validCreateCredential() {89 return {90 type: "create",91 id: base64url(new Uint8Array(32)),92 clientDataJSON: Array.from(new TextEncoder().encode(JSON.stringify({ type: "webauthn.create", challenge: "x", origin: "https://github.com", crossOrigin: false }))),93 attestationObject: base64url(new Uint8Array(64)),94 authenticatorData: base64url(new Uint8Array(37)),95 publicKey: base64url(new Uint8Array(64)),96 algorithm: -7,97 };98}99100function validGetCredential() {101 return {102 type: "get",103 id: base64url(new Uint8Array(32)),104 clientDataJSON: Array.from(new TextEncoder().encode(JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false }))),105 authenticatorData: base64url(new Uint8Array(37)),106 signature: base64url(new Uint8Array(64)),107 userHandle: base64url(new Uint8Array(16)),108 };109}110111112113describe("PB-26-209: exportFunction privileged bridge", () => {114 beforeEach(async () => {115 vi.resetModules();116 setupChromeMock();117 exportedFns.clear();118119 120 vi.stubGlobal("exportFunction", stubExportFunction);121 vi.stubGlobal("cloneInto", stubCloneInto);122123 124 delete (window as any).__peachPasskeyProxyInstalled;125126 127 128 const nativeCreate = vi.fn(async () => null);129 const nativeGet = vi.fn(async () => null);130 Object.defineProperty(navigator, "credentials", {131 value: { create: nativeCreate, get: nativeGet },132 configurable: true,133 });134135 await import("./passkeys-firefox");136 });137138 afterEach(() => {139 vi.unstubAllGlobals();140 });141142 describe("no forgeable response channel", () => {143 it("exports non-async wrappers so Firefox can return page-scope promises", () => {144 expect(exportedFns.get("create")?.constructor.name).toBe("Function");145 expect(exportedFns.get("get")?.constructor.name).toBe("Function");146 });147148 it("a forged PEACH_PASSKEY_RESPONSE with a correct-former UUID has no listener and no effect", async () => {149 150 151 const postSpy = vi.spyOn(window, "postMessage");152153 154 const forged = new MessageEvent("message", {155 data: {156 type: "PEACH_PASSKEY_RESPONSE",157 id: "00000000-0000-0000-0000-000000000000",158 payload: { ok: true, credential: validCreateCredential() },159 },160 source: window,161 });162 window.dispatchEvent(forged);163164 165 166 167 const passkeyPostCalls = postSpy.mock.calls.filter(168 ([data]) => {169 const d = data as { type?: string };170 return d?.type === "PEACH_PASSKEY_REQUEST" || d?.type === "PEACH_PASSKEY_RESPONSE";171 },172 );173 expect(passkeyPostCalls).toHaveLength(0);174 postSpy.mockRestore();175 });176177 it("production code has no PEACH_PASSKEY_RESPONSE message listener", async () => {178 179 const fs = await import("node:fs");180 const path = await import("node:path");181 const source = fs.readFileSync(182 path.resolve(process.cwd(), "src/content/passkeys-firefox.ts"),183 "utf-8",184 );185 expect(source).not.toContain("PEACH_PASSKEY_RESPONSE");186 expect(source).not.toContain("PEACH_PASSKEY_REQUEST");187 188 expect(source).not.toMatch(/window\.postMessage\s*\(/);189 });190 });191192 describe("malformed responses fail closed", () => {193 it("create: falls through to original when credential.id is not base64url", async () => {194 mockSendMessage.mockResolvedValueOnce({195 ok: true,196 credential: { ...validCreateCredential(), id: "!!!not-base64url!!!" },197 });198199 const result = navigator.credentials.create(makeCreateOptions());200 201 202 await expect(result).resolves.toBeNull();203 });204205 it("create: falls through when credential type is wrong", async () => {206 mockSendMessage.mockResolvedValueOnce({207 ok: true,208 credential: { ...validCreateCredential(), type: "get" },209 });210211 await expect(212 navigator.credentials.create(makeCreateOptions()),213 ).resolves.toBeNull();214 });215216 it("create: falls through when clientDataJSON is missing", async () => {217 const cred = validCreateCredential();218 delete (cred as any).clientDataJSON;219 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: cred });220221 await expect(222 navigator.credentials.create(makeCreateOptions()),223 ).resolves.toBeNull();224 });225226 it("create: falls through when attestationObject is not base64url", async () => {227 mockSendMessage.mockResolvedValueOnce({228 ok: true,229 credential: { ...validCreateCredential(), attestationObject: "not valid!" },230 });231232 await expect(233 navigator.credentials.create(makeCreateOptions()),234 ).resolves.toBeNull();235 });236237 it("create: falls through when algorithm is unsupported", async () => {238 mockSendMessage.mockResolvedValueOnce({239 ok: true,240 credential: { ...validCreateCredential(), algorithm: -999 },241 });242243 await expect(244 navigator.credentials.create(makeCreateOptions()),245 ).resolves.toBeNull();246 });247248 it("get: falls through when signature is not base64url", async () => {249 250 mockSendMessage251 .mockResolvedValueOnce({ ok: true, hasCredentials: true })252 .mockResolvedValueOnce({253 ok: true,254 credential: { ...validGetCredential(), signature: "!!!invalid!!!" },255 });256257 await expect(258 navigator.credentials.get(makeGetOptions()),259 ).resolves.toBeNull();260 });261262 it("get: falls through when authenticatorData is not base64url", async () => {263 mockSendMessage264 .mockResolvedValueOnce({ ok: true, hasCredentials: true })265 .mockResolvedValueOnce({266 ok: true,267 credential: { ...validGetCredential(), authenticatorData: "" },268 });269270 await expect(271 navigator.credentials.get(makeGetOptions()),272 ).resolves.toBeNull();273 });274275 it("get: falls through when credential is null", async () => {276 mockSendMessage277 .mockResolvedValueOnce({ ok: true, hasCredentials: true })278 .mockResolvedValueOnce({ ok: true, credential: null });279280 await expect(281 navigator.credentials.get(makeGetOptions()),282 ).resolves.toBeNull();283 });284 });285286 describe("privileged bridge captures sendMessage at install time", () => {287 it("wrapper closure uses the captured sendMessage, not a re-lookup", async () => {288 289 290 291 const original = mockSendMessage;292 const replacement = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });293 vi.stubGlobal("chrome", {294 runtime: { id: "peach-extension", sendMessage: replacement, getURL: vi.fn(() => "moz-extension://abcd/") },295 });296297 298 mockSendMessage = original;299 await navigator.credentials.create(makeCreateOptions());300301 302 expect(replacement).not.toHaveBeenCalled();303 });304 });305306 describe("valid responses produce credentials", () => {307 it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => {308 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });309310 const cred = await navigator.credentials.create(makeCreateOptions());311 expect(cred).not.toBeNull();312 expect((cred as any).type).toBe("public-key");313 expect((cred as any).id).toBeDefined();314 expect((cred as any).rawId).toBeDefined();315 expect((cred as any).response).toBeDefined();316 expect((cred as any).response.attestationObject).toBeDefined();317 });318319 it("get: returns a PublicKeyCredential-shaped object for a valid response", async () => {320 mockSendMessage321 .mockResolvedValueOnce({ ok: true, hasCredentials: true })322 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });323324 const cred = await navigator.credentials.get(makeGetOptions());325 expect(cred).not.toBeNull();326 expect((cred as any).type).toBe("public-key");327 expect((cred as any).response.signature).toBeDefined();328 expect((cred as any).response.authenticatorData).toBeDefined();329 });330331 it("create: throws NotAllowedError on user_cancelled", async () => {332 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" });333334 await expect(335 navigator.credentials.create(makeCreateOptions()),336 ).rejects.toMatchObject({ name: "NotAllowedError" });337 });338339 it("get: throws NotAllowedError on user_cancelled", async () => {340 mockSendMessage341 .mockResolvedValueOnce({ ok: true, hasCredentials: true })342 .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });343344 await expect(345 navigator.credentials.get(makeGetOptions()),346 ).rejects.toMatchObject({ name: "NotAllowedError" });347 });348349 it("create: falls through on vault_locked", async () => {350 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });351 await expect(352 navigator.credentials.create(makeCreateOptions()),353 ).resolves.toBeNull();354 });355356 it("get: falls through on no_credentials after has-credentials pre-check", async () => {357 mockSendMessage358 .mockResolvedValueOnce({ ok: true, hasCredentials: true })359 .mockResolvedValueOnce({ ok: false, error: "no_credentials" });360 await expect(361 navigator.credentials.get(makeGetOptions()),362 ).resolves.toBeNull();363 });364365 it("get: falls through when has-credentials returns false", async () => {366 mockSendMessage.mockResolvedValueOnce({ ok: true, hasCredentials: false });367 368 await navigator.credentials.get(makeGetOptions());369 const calls = mockSendMessage.mock.calls.map((c) => (c[0] as { type: string }).type);370 expect(calls).not.toContain("PASSKEY_GET");371 });372 });373374 describe("origin handling", () => {375 it("create: sends window.location.origin in the request, not a page-supplied value", async () => {376 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });377378 await navigator.credentials.create(makeCreateOptions());379380 const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } };381 expect(call.payload.origin).toBe(window.location.origin);382383 384 const clientData = JSON.parse(new TextDecoder().decode(new Uint8Array(call.payload.clientDataJSON)));385 expect(clientData.origin).toBe(window.location.origin);386 });387388 it("get: sends window.location.origin in the request", async () => {389 mockSendMessage390 .mockResolvedValueOnce({ ok: true, hasCredentials: true })391 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });392393 await navigator.credentials.get(makeGetOptions());394395 const getCall = mockSendMessage.mock.calls.find(396 (c) => (c[0] as { type: string }).type === "PASSKEY_GET",397 );398 const payload = (getCall![0] as { payload: { origin: string } }).payload;399 expect(payload.origin).toBe(window.location.origin);400 });401 });402403 describe("exportFunction unavailable", () => {404 it("falls through to native when exportFunction is not available", async () => {405 vi.resetModules();406 407 vi.stubGlobal("exportFunction", undefined);408 vi.stubGlobal("cloneInto", undefined);409410 const nativeCreate = vi.fn(async () => null);411 Object.defineProperty(navigator, "credentials", {412 value: { create: nativeCreate, get: vi.fn(async () => null) },413 configurable: true,414 });415416 await import("./passkeys-firefox");417418 419 await navigator.credentials.create(makeCreateOptions());420 expect(nativeCreate).toHaveBeenCalled();421 422 expect(mockSendMessage).not.toHaveBeenCalled();423 });424 });425});