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.each([["chrome", true], ["firefox", false]] as const)("PASSKEY_GET preserves registration backup eligibility for a legacy %s passkey", async (browser, expectedBackupEligible) => {438 const vault = await import("./vault");439 await vault.createVault("correct-pass-12345");440 const generated = await generatePasskeyKeypair(-7);441 const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));442 const entry = await vault.addEntry({443 type: "login",444 title: "GitHub",445 passkey: {446 rpId: "github.com", rpName: "GitHub", credentialId,447 publicKeyCose: base64urlEncode(generated.publicKeyCose),448 privateKeyJwk: generated.privateKeyJwk,449 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),450 userName: "june", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "",451 backupEligible: false, backupState: false, createdAt: 1, browser,452 },453 });454 const challenge = "challenge-base64url";455 const origin = "https://github.com";456 const clientDataJSON = Array.from(new TextEncoder().encode(457 JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })458 ));459460 const resultPromise = dispatch({461 type: "PASSKEY_GET",462 payload: { rpId: "github.com", challenge, origin, clientDataJSON, allowCredentials: [{ id: credentialId, type: "public-key" }] },463 });464 const passkey = await import("./passkey");465 await vi.waitFor(async () => {466 expect(await passkey.getPendingPasskeyRequest()).not.toBeNull();467 });468 const pending = await passkey.getPendingPasskeyRequest();469 await passkey.resolvePendingPasskeyRequest(pending!.requestId, true);470 const result = await resultPromise;471 expect(result.ok).toBe(true);472 const assertion = (result as unknown as { credential: { authenticatorData: string } }).credential;473 const authenticatorData = Uint8Array.from(atob(assertion.authenticatorData.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0));474 expect(Boolean(authenticatorData[32]! & 0x08)).toBe(expectedBackupEligible);475 expect(vault.getEntries().find((candidate) => candidate.id === entry.id)!.passkey!.backupEligible).toBe(expectedBackupEligible);476 });477478 it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => {479 const vault = await import("./vault");480 await vault.createVault("correct-pass-12345");481482 const challenge = "challenge-base64url";483 const origin = "https://github.com";484 const clientDataCreate = Array.from(new TextEncoder().encode(485 JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false })486 ));487488 autoApprove();489 const createRes = await dispatch({490 type: "PASSKEY_CREATE",491 payload: {492 rpId: "github.com",493 rpName: "GitHub",494 userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),495 userName: "june",496 userDisplayName: "June",497 challenge,498 clientDataJSON: clientDataCreate,499 origin,500 pubKeyCredParams: [{ alg: -7, type: "public-key" }],501 },502 });503504 expect(createRes).toMatchObject({ ok: true });505 const cred = (createRes as any).credential;506 expect(cred.type).toBe("create");507 expect(typeof cred.id).toBe("string");508 expect(cred.algorithm).toBe(-7);509510 const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");511 expect(stored).toBeDefined();512 expect(stored!.passkey!.signCount).toBe(0);513 expect(stored!.passkey!.browser).toBe("firefox");514 expect(stored!.passkey!.backupEligible).toBe(true);515516 const clientDataGet = Array.from(new TextEncoder().encode(517 JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })518 ));519520 autoApprove();521 const getRes = await dispatch({522 type: "PASSKEY_GET",523 payload: {524 rpId: "github.com",525 challenge,526 clientDataJSON: clientDataGet,527 origin,528 allowCredentials: [{ id: cred.id, type: "public-key" }],529 },530 });531532 expect(getRes).toMatchObject({ ok: true });533 const assertion = (getRes as any).credential;534 expect(assertion.type).toBe("get");535 expect(typeof assertion.signature).toBe("string");536 expect(typeof assertion.authenticatorData).toBe("string");537 const authenticatorData = Uint8Array.from(atob(assertion.authenticatorData.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(assertion.authenticatorData.length / 4) * 4, "=")), (char) => char.charCodeAt(0));538 expect(authenticatorData[32]! & 0x08).toBe(0x08);539540 const after = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");541 expect(after!.passkey!.signCount).toBe(1);542 expect(after!.passkey!.lastUsedAt).toBeDefined();543 });544});