12345678import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";910import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";11import type { Message, MessageResponse } from "../shared/types";1213vi.mock("./license", async (importOriginal) => {14 const actual = await importOriginal<typeof import("./license")>();15 return {16 ...actual,17 isProUser: vi.fn(async () => true),18 };19});20212223242526vi.mock("../shared/passkey-flags", () => ({27 FIREFOX_USE_CONTENT_SCRIPT_PROXY: true,28}));2930type StorageShape = Record<string, unknown>;31type MessageListener = (32 msg: unknown,33 sender: chrome.runtime.MessageSender,34 sendResponse: (response: MessageResponse) => void35) => boolean;3637describe("firefox passkey proxy handlers", () => {38 let storage: StorageShape;39 let sessionStorage: StorageShape;40 let listener: MessageListener;4142 beforeEach(async () => {43 vi.resetModules();44 vi.useRealTimers();45 storage = { peach_settings: { breachMonitoring: false } };46 sessionStorage = {};4748 const tabs = [{ id: 1 }];49 const chromeMock = {50 runtime: {51 id: "peach-extension",52 getURL: vi.fn((_path: string) => "moz-extension://abcd/"),53 onMessage: {54 addListener: vi.fn((fn: MessageListener) => {55 listener = fn;56 }),57 },58 sendMessage: vi.fn(async () => ({ ok: true })),59 },60 tabs: {61 query: vi.fn((_q: unknown, callback?: (t: Array<{ id?: number }>) => void) => {62 if (typeof callback === "function") { callback(tabs); return; }63 return Promise.resolve(tabs);64 }),65 sendMessage: vi.fn(async () => ({ ok: true })),66 },67 storage: {68 local: {69 get: vi.fn(async (key?: string | string[]) => {70 if (!key) return storage;71 if (typeof key === "string") return { [key]: storage[key] };72 return key.reduce<Record<string, unknown>>((r, k) => { r[k] = storage[k]; return r; }, {});73 }),74 set: vi.fn(async (next: Record<string, unknown>) => Object.assign(storage, next)),75 remove: vi.fn(async (keys: string | string[]) => {76 const list = Array.isArray(keys) ? keys : [keys];77 for (const k of list) delete storage[k];78 }),79 },80 session: {81 get: vi.fn(async (key?: string | string[]) => {82 if (!key) return sessionStorage;83 if (typeof key === "string") return { [key]: sessionStorage[key] };84 return key.reduce<Record<string, unknown>>((r, k) => { r[k] = sessionStorage[k]; return r; }, {});85 }),86 set: vi.fn(async (next: Record<string, unknown>) => Object.assign(sessionStorage, next)),87 remove: vi.fn(async (keys: string | string[]) => {88 const list = Array.isArray(keys) ? keys : [keys];89 for (const k of list) delete sessionStorage[k];90 }),91 clear: vi.fn(async () => { sessionStorage = {}; }),92 },93 },94 action: {95 setBadgeText: vi.fn(async () => {}),96 setBadgeBackgroundColor: vi.fn(async () => {}),97 openPopup: vi.fn(async () => {}),98 },99 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },100 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },101 scripting: { executeScript: vi.fn(async () => []) },102 } as unknown as typeof chrome;103104 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;105 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);106107 const messages = await import("./messages");108 messages.setupMessageHandler();109 const vault = await import("./vault");110 await vault.ensureSettingsLoaded();111 });112113 afterEach(() => {114 vi.unstubAllGlobals();115 });116117 async function dispatch(message: Message): Promise<MessageResponse> {118 return await new Promise<MessageResponse>((resolve) => {119 const open = listener(message, { id: "peach-extension" }, (r) => resolve(r));120 expect(open).toBe(true);121 });122 }123124 125 126 async function autoApprove() {127 const passkey = await import("./passkey");128 const wait = async () => {129 for (let i = 0; i < 50; i += 1) {130 const pending = await passkey.getPendingPasskeyRequest();131 if (pending) {132 await passkey.resolvePendingPasskeyRequest(pending.requestId, true);133 return;134 }135 await new Promise((r) => setTimeout(r, 10));136 }137 };138 void wait();139 }140141 it("PASSKEY_HAS_CREDENTIALS returns false when vault has no passkey for rpId", async () => {142 const vault = await import("./vault");143 await vault.createVault("correct-pass-12345");144145 await expect(146 dispatch({147 type: "PASSKEY_HAS_CREDENTIALS",148 payload: { rpId: "github.com", allowCredentials: [] },149 })150 ).resolves.toMatchObject({ ok: true, hasCredentials: false });151 });152153 it("PASSKEY_HAS_CREDENTIALS returns true when allowCredentials is empty and rpId matches", async () => {154 const vault = await import("./vault");155 await vault.createVault("correct-pass-12345");156 const generated = await generatePasskeyKeypair(-7);157 await vault.addEntry({158 type: "login",159 title: "GitHub",160 passkey: {161 rpId: "github.com",162 rpName: "GitHub",163 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),164 publicKeyCose: base64urlEncode(generated.publicKeyCose),165 privateKeyJwk: generated.privateKeyJwk,166 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),167 userName: "june",168 userDisplayName: "June",169 algorithm: -7,170 signCount: 0,171 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",172 backupEligible: true,173 backupState: false,174 createdAt: Date.now(),175 },176 });177178 await expect(179 dispatch({180 type: "PASSKEY_HAS_CREDENTIALS",181 payload: { rpId: "github.com", allowCredentials: [] },182 })183 ).resolves.toMatchObject({ ok: true, hasCredentials: true });184 });185186 it("PASSKEY_HAS_CREDENTIALS matches by credentialId in allowCredentials", async () => {187 const vault = await import("./vault");188 await vault.createVault("correct-pass-12345");189 const generated = await generatePasskeyKeypair(-7);190 const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));191 await vault.addEntry({192 type: "login",193 title: "GitHub",194 passkey: {195 rpId: "github.com",196 rpName: "GitHub",197 credentialId,198 publicKeyCose: base64urlEncode(generated.publicKeyCose),199 privateKeyJwk: generated.privateKeyJwk,200 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),201 userName: "june",202 userDisplayName: "June",203 algorithm: -7,204 signCount: 0,205 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",206 backupEligible: true,207 backupState: false,208 createdAt: Date.now(),209 },210 });211212 await expect(213 dispatch({214 type: "PASSKEY_HAS_CREDENTIALS",215 payload: {216 rpId: "github.com",217 allowCredentials: [{ id: "different-id", type: "public-key" }],218 },219 })220 ).resolves.toMatchObject({ ok: true, hasCredentials: false });221222 await expect(223 dispatch({224 type: "PASSKEY_HAS_CREDENTIALS",225 payload: {226 rpId: "github.com",227 allowCredentials: [{ id: credentialId, type: "public-key" }],228 },229 })230 ).resolves.toMatchObject({ ok: true, hasCredentials: true });231 });232233 it("PASSKEY_HAS_CREDENTIALS returns hasCredentials:false when vault is locked", async () => {234 const vault = await import("./vault");235 await vault.createVault("correct-pass-12345");236 vault.lock();237238 await expect(239 dispatch({240 type: "PASSKEY_HAS_CREDENTIALS",241 payload: { rpId: "github.com", allowCredentials: [] },242 })243 ).resolves.toMatchObject({ ok: true, hasCredentials: false });244 });245246 it("PASSKEY_CREATE rejects a cross-origin rpId without minting a credential (SEV-1-A)", async () => {247 const vault = await import("./vault");248 await vault.createVault("correct-pass-12345");249250 autoApprove();251 const res = await dispatch({252 type: "PASSKEY_CREATE",253 payload: {254 255 rpId: "google.com",256 rpName: "Google",257 userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),258 userName: "june",259 userDisplayName: "June",260 challenge: "x",261 clientDataJSON: Array.from(new TextEncoder().encode("{}")),262 origin: "https://evil.com",263 pubKeyCredParams: [{ alg: -7, type: "public-key" }],264 },265 });266267 expect(res.ok).toBe(false);268 expect(String(res.error)).toContain("SecurityError");269 270 expect(vault.getEntries().find((e) => e.passkey?.rpId === "google.com")).toBeUndefined();271 });272273 it("PASSKEY_GET rejects a cross-origin rpId (SEV-1-A)", async () => {274 const vault = await import("./vault");275 await vault.createVault("correct-pass-12345");276277 const res = await dispatch({278 type: "PASSKEY_GET",279 payload: {280 rpId: "google.com",281 challenge: "x",282 clientDataJSON: Array.from(new TextEncoder().encode("{}")),283 origin: "https://evil.com",284 allowCredentials: [],285 },286 });287288 expect(res.ok).toBe(false);289 expect(String(res.error)).toContain("SecurityError");290 });291292 it("handleFirefoxPasskeyCreate throws a SecurityError directly on rpId mismatch", async () => {293 const { handleFirefoxPasskeyCreate } = await import("./passkey");294 await expect(295 handleFirefoxPasskeyCreate({296 rpId: "google.com",297 rpName: "Google",298 userId: "dGVzdA",299 userName: "june",300 challenge: "x",301 clientDataJSON: [],302 origin: "https://evil.com",303 pubKeyCredParams: [{ alg: -7, type: "public-key" }],304 })305 ).rejects.toThrow("SecurityError");306 });307308 it("PASSKEY_GET returns no_credentials when nothing matches", async () => {309 const vault = await import("./vault");310 await vault.createVault("correct-pass-12345");311312 const clientDataJSON = Array.from(new TextEncoder().encode(313 JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false })314 ));315316 await expect(317 dispatch({318 type: "PASSKEY_GET",319 payload: {320 rpId: "github.com",321 challenge: "x",322 clientDataJSON,323 origin: "https://github.com",324 allowCredentials: [],325 },326 })327 ).resolves.toMatchObject({ ok: false, error: "no_credentials" });328 });329330 it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => {331 const vault = await import("./vault");332 await vault.createVault("correct-pass-12345");333334 const challenge = "challenge-base64url";335 const origin = "https://github.com";336 const clientDataCreate = Array.from(new TextEncoder().encode(337 JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false })338 ));339340 autoApprove();341 const createRes = await dispatch({342 type: "PASSKEY_CREATE",343 payload: {344 rpId: "github.com",345 rpName: "GitHub",346 userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),347 userName: "june",348 userDisplayName: "June",349 challenge,350 clientDataJSON: clientDataCreate,351 origin,352 pubKeyCredParams: [{ alg: -7, type: "public-key" }],353 },354 });355356 expect(createRes).toMatchObject({ ok: true });357 const cred = (createRes as any).credential;358 expect(cred.type).toBe("create");359 expect(typeof cred.id).toBe("string");360 expect(cred.algorithm).toBe(-7);361362 const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");363 expect(stored).toBeDefined();364 expect(stored!.passkey!.signCount).toBe(0);365 expect(stored!.passkey!.browser).toBe("firefox");366367 const clientDataGet = Array.from(new TextEncoder().encode(368 JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })369 ));370371 autoApprove();372 const getRes = await dispatch({373 type: "PASSKEY_GET",374 payload: {375 rpId: "github.com",376 challenge,377 clientDataJSON: clientDataGet,378 origin,379 allowCredentials: [{ id: cred.id, type: "public-key" }],380 },381 });382383 expect(getRes).toMatchObject({ ok: true });384 const assertion = (getRes as any).credential;385 expect(assertion.type).toBe("get");386 expect(typeof assertion.signature).toBe("string");387 expect(typeof assertion.authenticatorData).toBe("string");388389 const after = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");390 expect(after!.passkey!.signCount).toBe(1);391 expect(after!.passkey!.lastUsedAt).toBeDefined();392 });393});