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 118 119 120 async function dispatch(message: Message, senderUrl = "https://github.com/login"): Promise<MessageResponse> {121 return await new Promise<MessageResponse>((resolve) => {122 const open = listener(message, { id: "peach-extension", url: senderUrl, origin: new URL(senderUrl).origin }, (r) => resolve(r));123 expect(open).toBe(true);124 });125 }126127 128 129 async function autoApprove() {130 const passkey = await import("./passkey");131 const wait = async () => {132 for (let i = 0; i < 50; i += 1) {133 const pending = await passkey.getPendingPasskeyRequest();134 if (pending) {135 await passkey.resolvePendingPasskeyRequest(pending.requestId, true);136 return;137 }138 await new Promise((r) => setTimeout(r, 10));139 }140 };141 void wait();142 }143144 it("PASSKEY_HAS_CREDENTIALS returns false when vault has no passkey for rpId", async () => {145 const vault = await import("./vault");146 await vault.createVault("correct-pass-12345");147148 await expect(149 dispatch({150 type: "PASSKEY_HAS_CREDENTIALS",151 payload: { rpId: "github.com", allowCredentials: [] },152 })153 ).resolves.toMatchObject({ ok: true, hasCredentials: false });154 });155156 it("PASSKEY_HAS_CREDENTIALS returns true when allowCredentials is empty and rpId matches", async () => {157 const vault = await import("./vault");158 await vault.createVault("correct-pass-12345");159 const generated = await generatePasskeyKeypair(-7);160 await vault.addEntry({161 type: "login",162 title: "GitHub",163 passkey: {164 rpId: "github.com",165 rpName: "GitHub",166 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),167 publicKeyCose: base64urlEncode(generated.publicKeyCose),168 privateKeyJwk: generated.privateKeyJwk,169 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),170 userName: "june",171 userDisplayName: "June",172 algorithm: -7,173 signCount: 0,174 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",175 backupEligible: true,176 backupState: false,177 createdAt: Date.now(),178 },179 });180181 await expect(182 dispatch({183 type: "PASSKEY_HAS_CREDENTIALS",184 payload: { rpId: "github.com", allowCredentials: [] },185 })186 ).resolves.toMatchObject({ ok: true, hasCredentials: true });187 });188189 it("PASSKEY_HAS_CREDENTIALS matches by credentialId in allowCredentials", async () => {190 const vault = await import("./vault");191 await vault.createVault("correct-pass-12345");192 const generated = await generatePasskeyKeypair(-7);193 const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));194 await vault.addEntry({195 type: "login",196 title: "GitHub",197 passkey: {198 rpId: "github.com",199 rpName: "GitHub",200 credentialId,201 publicKeyCose: base64urlEncode(generated.publicKeyCose),202 privateKeyJwk: generated.privateKeyJwk,203 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),204 userName: "june",205 userDisplayName: "June",206 algorithm: -7,207 signCount: 0,208 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",209 backupEligible: true,210 backupState: false,211 createdAt: Date.now(),212 },213 });214215 await expect(216 dispatch({217 type: "PASSKEY_HAS_CREDENTIALS",218 payload: {219 rpId: "github.com",220 allowCredentials: [{ id: "different-id", type: "public-key" }],221 },222 })223 ).resolves.toMatchObject({ ok: true, hasCredentials: false });224225 await expect(226 dispatch({227 type: "PASSKEY_HAS_CREDENTIALS",228 payload: {229 rpId: "github.com",230 allowCredentials: [{ id: credentialId, type: "public-key" }],231 },232 })233 ).resolves.toMatchObject({ ok: true, hasCredentials: true });234 });235236 it("PASSKEY_HAS_CREDENTIALS returns hasCredentials:false when vault is locked", async () => {237 const vault = await import("./vault");238 await vault.createVault("correct-pass-12345");239 vault.lock();240241 await expect(242 dispatch({243 type: "PASSKEY_HAS_CREDENTIALS",244 payload: { rpId: "github.com", allowCredentials: [] },245 })246 ).resolves.toMatchObject({ ok: true, hasCredentials: false });247 });248249 it("PASSKEY_CREATE rejects a cross-origin rpId without minting a credential (SEV-1-A)", async () => {250 const vault = await import("./vault");251 await vault.createVault("correct-pass-12345");252253 autoApprove();254 255 const res = await dispatch({256 type: "PASSKEY_CREATE",257 payload: {258 rpId: "google.com",259 rpName: "Google",260 userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),261 userName: "june",262 userDisplayName: "June",263 challenge: "x",264 clientDataJSON: Array.from(new TextEncoder().encode("{}")),265 origin: "https://evil.com",266 pubKeyCredParams: [{ alg: -7, type: "public-key" }],267 },268 }, "https://evil.com/attack");269270 expect(res.ok).toBe(false);271 expect(String(res.error)).toContain("SecurityError");272 273 expect(vault.getEntries().find((e) => e.passkey?.rpId === "google.com")).toBeUndefined();274 });275276 it("PASSKEY_GET rejects a cross-origin rpId (SEV-1-A)", async () => {277 const vault = await import("./vault");278 await vault.createVault("correct-pass-12345");279280 const res = await dispatch({281 type: "PASSKEY_GET",282 payload: {283 rpId: "google.com",284 challenge: "x",285 clientDataJSON: Array.from(new TextEncoder().encode("{}")),286 origin: "https://evil.com",287 allowCredentials: [],288 },289 }, "https://evil.com/attack");290291 expect(res.ok).toBe(false);292 expect(String(res.error)).toContain("SecurityError");293 });294295 it("PASSKEY_GET refuses a FORGED payload.origin that matches rpId when the attested sender origin differs (audit CRITICAL — origin spoofing)", async () => {296 const vault = await import("./vault");297 await vault.createVault("correct-pass-12345");298299 300 301 302 const forgedClientData = Array.from(new TextEncoder().encode(303 JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://victimbank.com", crossOrigin: false }),304 ));305 const res = await dispatch({306 type: "PASSKEY_GET",307 payload: {308 rpId: "victimbank.com",309 origin: "https://victimbank.com",310 challenge: "x",311 clientDataJSON: forgedClientData,312 allowCredentials: [],313 },314 }, "https://evil.com/attack");315316 expect(res.ok).toBe(false);317 expect(String(res.error)).toContain("SecurityError");318 });319320 it("PASSKEY_HAS_CREDENTIALS is scoped to the caller origin (no cross-site enumeration)", async () => {321 const vault = await import("./vault");322 await vault.createVault("correct-pass-12345");323 await vault.addEntry({324 type: "login", title: "GitHub", url: "https://github.com", username: "june",325 passkey: {326 rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",327 privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,328 userHandle: "u", userName: "june", userDisplayName: "June", algorithm: -7,329 signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,330 },331 });332333 334 await expect(dispatch({335 type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] },336 }, "https://evil.com/")).resolves.toMatchObject({ ok: true, hasCredentials: false });337338 339 await expect(dispatch({340 type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] },341 }, "https://github.com/")).resolves.toMatchObject({ ok: true, hasCredentials: true });342 });343344 it("GET_PASSKEY_FOR_DOMAIN is scoped to the caller origin (no username leak cross-site)", async () => {345 const vault = await import("./vault");346 await vault.createVault("correct-pass-12345");347 await vault.addEntry({348 type: "login", title: "GitHub", url: "https://github.com", username: "june",349 passkey: {350 rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",351 privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,352 userHandle: "u", userName: "[email protected]", userDisplayName: "June", algorithm: -7,353 signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,354 },355 });356357 await expect(dispatch({358 type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },359 }, "https://evil.com/")).resolves.toMatchObject({ ok: true, data: null });360361 const own = await dispatch({362 type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },363 }, "https://github.com/");364 expect((own.data as { userName?: string } | null)?.userName).toBe("[email protected]");365 });366367 it("handleFirefoxPasskeyCreate throws a SecurityError directly on rpId mismatch", async () => {368 const { handleFirefoxPasskeyCreate } = await import("./passkey");369 await expect(370 handleFirefoxPasskeyCreate({371 rpId: "google.com",372 rpName: "Google",373 userId: "dGVzdA",374 userName: "june",375 challenge: "x",376 clientDataJSON: [],377 origin: "https://evil.com",378 pubKeyCredParams: [{ alg: -7, type: "public-key" }],379 })380 ).rejects.toThrow("SecurityError");381 });382383 it("PASSKEY_GET returns no_credentials when nothing matches", async () => {384 const vault = await import("./vault");385 await vault.createVault("correct-pass-12345");386387 const clientDataJSON = Array.from(new TextEncoder().encode(388 JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false })389 ));390391 await expect(392 dispatch({393 type: "PASSKEY_GET",394 payload: {395 rpId: "github.com",396 challenge: "x",397 clientDataJSON,398 origin: "https://github.com",399 allowCredentials: [],400 },401 })402 ).resolves.toMatchObject({ ok: false, error: "no_credentials" });403 });404405 it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => {406 const vault = await import("./vault");407 await vault.createVault("correct-pass-12345");408409 const challenge = "challenge-base64url";410 const origin = "https://github.com";411 const clientDataCreate = Array.from(new TextEncoder().encode(412 JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false })413 ));414415 autoApprove();416 const createRes = await dispatch({417 type: "PASSKEY_CREATE",418 payload: {419 rpId: "github.com",420 rpName: "GitHub",421 userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),422 userName: "june",423 userDisplayName: "June",424 challenge,425 clientDataJSON: clientDataCreate,426 origin,427 pubKeyCredParams: [{ alg: -7, type: "public-key" }],428 },429 });430431 expect(createRes).toMatchObject({ ok: true });432 const cred = (createRes as any).credential;433 expect(cred.type).toBe("create");434 expect(typeof cred.id).toBe("string");435 expect(cred.algorithm).toBe(-7);436437 const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");438 expect(stored).toBeDefined();439 expect(stored!.passkey!.signCount).toBe(0);440 expect(stored!.passkey!.browser).toBe("firefox");441442 const clientDataGet = Array.from(new TextEncoder().encode(443 JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })444 ));445446 autoApprove();447 const getRes = await dispatch({448 type: "PASSKEY_GET",449 payload: {450 rpId: "github.com",451 challenge,452 clientDataJSON: clientDataGet,453 origin,454 allowCredentials: [{ id: cred.id, type: "public-key" }],455 },456 });457458 expect(getRes).toMatchObject({ ok: true });459 const assertion = (getRes as any).credential;460 expect(assertion.type).toBe("get");461 expect(typeof assertion.signature).toBe("string");462 expect(typeof assertion.authenticatorData).toBe("string");463464 const after = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");465 expect(after!.passkey!.signCount).toBe(1);466 expect(after!.passkey!.lastUsedAt).toBeDefined();467 });468});