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("a forged PEACH_PASSKEY_RESPONSE with a correct-former UUID has no listener and no effect", async () => {144 145 146 const postSpy = vi.spyOn(window, "postMessage");147148 149 const forged = new MessageEvent("message", {150 data: {151 type: "PEACH_PASSKEY_RESPONSE",152 id: "00000000-0000-0000-0000-000000000000",153 payload: { ok: true, credential: validCreateCredential() },154 },155 source: window,156 });157 window.dispatchEvent(forged);158159 160 161 162 const passkeyPostCalls = postSpy.mock.calls.filter(163 ([data]) => {164 const d = data as { type?: string };165 return d?.type === "PEACH_PASSKEY_REQUEST" || d?.type === "PEACH_PASSKEY_RESPONSE";166 },167 );168 expect(passkeyPostCalls).toHaveLength(0);169 postSpy.mockRestore();170 });171172 it("production code has no PEACH_PASSKEY_RESPONSE message listener", async () => {173 174 const fs = await import("node:fs");175 const path = await import("node:path");176 const source = fs.readFileSync(177 path.resolve(process.cwd(), "src/content/passkeys-firefox.ts"),178 "utf-8",179 );180 expect(source).not.toContain("PEACH_PASSKEY_RESPONSE");181 expect(source).not.toContain("PEACH_PASSKEY_REQUEST");182 183 expect(source).not.toMatch(/window\.postMessage\s*\(/);184 });185 });186187 describe("malformed responses fail closed", () => {188 it("create: falls through to original when credential.id is not base64url", async () => {189 mockSendMessage.mockResolvedValueOnce({190 ok: true,191 credential: { ...validCreateCredential(), id: "!!!not-base64url!!!" },192 });193194 const result = navigator.credentials.create(makeCreateOptions());195 196 197 await expect(result).resolves.toBeNull();198 });199200 it("create: falls through when credential type is wrong", async () => {201 mockSendMessage.mockResolvedValueOnce({202 ok: true,203 credential: { ...validCreateCredential(), type: "get" },204 });205206 await expect(207 navigator.credentials.create(makeCreateOptions()),208 ).resolves.toBeNull();209 });210211 it("create: falls through when clientDataJSON is missing", async () => {212 const cred = validCreateCredential();213 delete (cred as any).clientDataJSON;214 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: cred });215216 await expect(217 navigator.credentials.create(makeCreateOptions()),218 ).resolves.toBeNull();219 });220221 it("create: falls through when attestationObject is not base64url", async () => {222 mockSendMessage.mockResolvedValueOnce({223 ok: true,224 credential: { ...validCreateCredential(), attestationObject: "not valid!" },225 });226227 await expect(228 navigator.credentials.create(makeCreateOptions()),229 ).resolves.toBeNull();230 });231232 it("create: falls through when algorithm is unsupported", async () => {233 mockSendMessage.mockResolvedValueOnce({234 ok: true,235 credential: { ...validCreateCredential(), algorithm: -999 },236 });237238 await expect(239 navigator.credentials.create(makeCreateOptions()),240 ).resolves.toBeNull();241 });242243 it("get: falls through when signature is not base64url", async () => {244 245 mockSendMessage246 .mockResolvedValueOnce({ ok: true, hasCredentials: true })247 .mockResolvedValueOnce({248 ok: true,249 credential: { ...validGetCredential(), signature: "!!!invalid!!!" },250 });251252 await expect(253 navigator.credentials.get(makeGetOptions()),254 ).resolves.toBeNull();255 });256257 it("get: falls through when authenticatorData is not base64url", async () => {258 mockSendMessage259 .mockResolvedValueOnce({ ok: true, hasCredentials: true })260 .mockResolvedValueOnce({261 ok: true,262 credential: { ...validGetCredential(), authenticatorData: "" },263 });264265 await expect(266 navigator.credentials.get(makeGetOptions()),267 ).resolves.toBeNull();268 });269270 it("get: falls through when credential is null", async () => {271 mockSendMessage272 .mockResolvedValueOnce({ ok: true, hasCredentials: true })273 .mockResolvedValueOnce({ ok: true, credential: null });274275 await expect(276 navigator.credentials.get(makeGetOptions()),277 ).resolves.toBeNull();278 });279 });280281 describe("privileged bridge captures sendMessage at install time", () => {282 it("wrapper closure uses the captured sendMessage, not a re-lookup", async () => {283 284 285 286 const original = mockSendMessage;287 const replacement = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });288 vi.stubGlobal("chrome", {289 runtime: { id: "peach-extension", sendMessage: replacement, getURL: vi.fn(() => "moz-extension://abcd/") },290 });291292 293 mockSendMessage = original;294 await navigator.credentials.create(makeCreateOptions());295296 297 expect(replacement).not.toHaveBeenCalled();298 });299 });300301 describe("valid responses produce credentials", () => {302 it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => {303 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });304305 const cred = await navigator.credentials.create(makeCreateOptions());306 expect(cred).not.toBeNull();307 expect((cred as any).type).toBe("public-key");308 expect((cred as any).id).toBeDefined();309 expect((cred as any).rawId).toBeDefined();310 expect((cred as any).response).toBeDefined();311 expect((cred as any).response.attestationObject).toBeDefined();312 });313314 it("get: returns a PublicKeyCredential-shaped object for a valid response", async () => {315 mockSendMessage316 .mockResolvedValueOnce({ ok: true, hasCredentials: true })317 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });318319 const cred = await navigator.credentials.get(makeGetOptions());320 expect(cred).not.toBeNull();321 expect((cred as any).type).toBe("public-key");322 expect((cred as any).response.signature).toBeDefined();323 expect((cred as any).response.authenticatorData).toBeDefined();324 });325326 it("create: throws NotAllowedError on user_cancelled", async () => {327 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" });328329 await expect(330 navigator.credentials.create(makeCreateOptions()),331 ).rejects.toMatchObject({ name: "NotAllowedError" });332 });333334 it("get: throws NotAllowedError on user_cancelled", async () => {335 mockSendMessage336 .mockResolvedValueOnce({ ok: true, hasCredentials: true })337 .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });338339 await expect(340 navigator.credentials.get(makeGetOptions()),341 ).rejects.toMatchObject({ name: "NotAllowedError" });342 });343344 it("create: falls through on vault_locked", async () => {345 mockSendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });346 await expect(347 navigator.credentials.create(makeCreateOptions()),348 ).resolves.toBeNull();349 });350351 it("get: falls through on no_credentials after has-credentials pre-check", async () => {352 mockSendMessage353 .mockResolvedValueOnce({ ok: true, hasCredentials: true })354 .mockResolvedValueOnce({ ok: false, error: "no_credentials" });355 await expect(356 navigator.credentials.get(makeGetOptions()),357 ).resolves.toBeNull();358 });359360 it("get: falls through when has-credentials returns false", async () => {361 mockSendMessage.mockResolvedValueOnce({ ok: true, hasCredentials: false });362 363 await navigator.credentials.get(makeGetOptions());364 const calls = mockSendMessage.mock.calls.map((c) => (c[0] as { type: string }).type);365 expect(calls).not.toContain("PASSKEY_GET");366 });367 });368369 describe("origin handling", () => {370 it("create: sends window.location.origin in the request, not a page-supplied value", async () => {371 mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });372373 await navigator.credentials.create(makeCreateOptions());374375 const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } };376 expect(call.payload.origin).toBe(window.location.origin);377378 379 const clientData = JSON.parse(new TextDecoder().decode(new Uint8Array(call.payload.clientDataJSON)));380 expect(clientData.origin).toBe(window.location.origin);381 });382383 it("get: sends window.location.origin in the request", async () => {384 mockSendMessage385 .mockResolvedValueOnce({ ok: true, hasCredentials: true })386 .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });387388 await navigator.credentials.get(makeGetOptions());389390 const getCall = mockSendMessage.mock.calls.find(391 (c) => (c[0] as { type: string }).type === "PASSKEY_GET",392 );393 const payload = (getCall![0] as { payload: { origin: string } }).payload;394 expect(payload.origin).toBe(window.location.origin);395 });396 });397398 describe("exportFunction unavailable", () => {399 it("falls through to native when exportFunction is not available", async () => {400 vi.resetModules();401 402 vi.stubGlobal("exportFunction", undefined);403 vi.stubGlobal("cloneInto", undefined);404405 const nativeCreate = vi.fn(async () => null);406 Object.defineProperty(navigator, "credentials", {407 value: { create: nativeCreate, get: vi.fn(async () => null) },408 configurable: true,409 });410411 await import("./passkeys-firefox");412413 414 await navigator.credentials.create(makeCreateOptions());415 expect(nativeCreate).toHaveBeenCalled();416 417 expect(mockSendMessage).not.toHaveBeenCalled();418 });419 });420});