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("MARK_PASSKEY_BANNER_SHOWN is origin-bound and surfaces via bannerShown (PB-26-203)", async () => {368 const vault = await import("./vault");369 await vault.createVault("correct-pass-12345");370 await vault.addEntry({371 type: "login", title: "GitHub", url: "https://github.com", username: "june",372 passkey: {373 rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",374 privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,375 userHandle: "u", userName: "[email protected]", userDisplayName: "June", algorithm: -7,376 signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,377 },378 });379380 381 await expect(dispatch({382 type: "MARK_PASSKEY_BANNER_SHOWN", payload: { domain: "github.com" },383 }, "https://evil.com/")).resolves.toMatchObject({ ok: true });384 const before = await dispatch({385 type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },386 }, "https://github.com/");387 expect((before.data as { bannerShown?: boolean } | null)?.bannerShown).toBe(false);388389 390 await expect(dispatch({391 type: "MARK_PASSKEY_BANNER_SHOWN", payload: { domain: "github.com" },392 }, "https://github.com/")).resolves.toMatchObject({ ok: true });393 const after = await dispatch({394 type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },395 }, "https://github.com/");396 expect((after.data as { bannerShown?: boolean } | null)?.bannerShown).toBe(true);397 });398399 it("handleFirefoxPasskeyCreate throws a SecurityError directly on rpId mismatch", async () => {400 const { handleFirefoxPasskeyCreate } = await import("./passkey");401 await expect(402 handleFirefoxPasskeyCreate({403 rpId: "google.com",404 rpName: "Google",405 userId: "dGVzdA",406 userName: "june",407 challenge: "x",408 clientDataJSON: [],409 origin: "https://evil.com",410 pubKeyCredParams: [{ alg: -7, type: "public-key" }],411 })412 ).rejects.toThrow("SecurityError");413 });414415 it("PASSKEY_GET returns no_credentials when nothing matches", async () => {416 const vault = await import("./vault");417 await vault.createVault("correct-pass-12345");418419 const clientDataJSON = Array.from(new TextEncoder().encode(420 JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false })421 ));422423 await expect(424 dispatch({425 type: "PASSKEY_GET",426 payload: {427 rpId: "github.com",428 challenge: "x",429 clientDataJSON,430 origin: "https://github.com",431 allowCredentials: [],432 },433 })434 ).resolves.toMatchObject({ ok: false, error: "no_credentials" });435 });436437 it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => {438 const vault = await import("./vault");439 await vault.createVault("correct-pass-12345");440441 const challenge = "challenge-base64url";442 const origin = "https://github.com";443 const clientDataCreate = Array.from(new TextEncoder().encode(444 JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false })445 ));446447 autoApprove();448 const createRes = await dispatch({449 type: "PASSKEY_CREATE",450 payload: {451 rpId: "github.com",452 rpName: "GitHub",453 userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),454 userName: "june",455 userDisplayName: "June",456 challenge,457 clientDataJSON: clientDataCreate,458 origin,459 pubKeyCredParams: [{ alg: -7, type: "public-key" }],460 },461 });462463 expect(createRes).toMatchObject({ ok: true });464 const cred = (createRes as any).credential;465 expect(cred.type).toBe("create");466 expect(typeof cred.id).toBe("string");467 expect(cred.algorithm).toBe(-7);468469 const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");470 expect(stored).toBeDefined();471 expect(stored!.passkey!.signCount).toBe(0);472 expect(stored!.passkey!.browser).toBe("firefox");473474 const clientDataGet = Array.from(new TextEncoder().encode(475 JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })476 ));477478 autoApprove();479 const getRes = await dispatch({480 type: "PASSKEY_GET",481 payload: {482 rpId: "github.com",483 challenge,484 clientDataJSON: clientDataGet,485 origin,486 allowCredentials: [{ id: cred.id, type: "public-key" }],487 },488 });489490 expect(getRes).toMatchObject({ ok: true });491 const assertion = (getRes as any).credential;492 expect(assertion.type).toBe("get");493 expect(typeof assertion.signature).toBe("string");494 expect(typeof assertion.authenticatorData).toBe("string");495496 const after = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");497 expect(after!.passkey!.signCount).toBe(1);498 expect(after!.passkey!.lastUsedAt).toBeDefined();499 });500});