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_GET returns no_credentials when nothing matches", async () => {247 const vault = await import("./vault");248 await vault.createVault("correct-pass-12345");249250 const clientDataJSON = Array.from(new TextEncoder().encode(251 JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false })252 ));253254 await expect(255 dispatch({256 type: "PASSKEY_GET",257 payload: {258 rpId: "github.com",259 challenge: "x",260 clientDataJSON,261 origin: "https://github.com",262 allowCredentials: [],263 },264 })265 ).resolves.toMatchObject({ ok: false, error: "no_credentials" });266 });267268 it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => {269 const vault = await import("./vault");270 await vault.createVault("correct-pass-12345");271272 const challenge = "challenge-base64url";273 const origin = "https://github.com";274 const clientDataCreate = Array.from(new TextEncoder().encode(275 JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false })276 ));277278 autoApprove();279 const createRes = await dispatch({280 type: "PASSKEY_CREATE",281 payload: {282 rpId: "github.com",283 rpName: "GitHub",284 userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),285 userName: "june",286 userDisplayName: "June",287 challenge,288 clientDataJSON: clientDataCreate,289 origin,290 pubKeyCredParams: [{ alg: -7, type: "public-key" }],291 },292 });293294 expect(createRes).toMatchObject({ ok: true });295 const cred = (createRes as any).credential;296 expect(cred.type).toBe("create");297 expect(typeof cred.id).toBe("string");298 expect(cred.algorithm).toBe(-7);299300 const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");301 expect(stored).toBeDefined();302 expect(stored!.passkey!.signCount).toBe(0);303 expect(stored!.passkey!.browser).toBe("firefox");304305 const clientDataGet = Array.from(new TextEncoder().encode(306 JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })307 ));308309 autoApprove();310 const getRes = await dispatch({311 type: "PASSKEY_GET",312 payload: {313 rpId: "github.com",314 challenge,315 clientDataJSON: clientDataGet,316 origin,317 allowCredentials: [{ id: cred.id, type: "public-key" }],318 },319 });320321 expect(getRes).toMatchObject({ ok: true });322 const assertion = (getRes as any).credential;323 expect(assertion.type).toBe("get");324 expect(typeof assertion.signature).toBe("string");325 expect(typeof assertion.authenticatorData).toBe("string");326327 const after = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");328 expect(after!.passkey!.signCount).toBe(1);329 expect(after!.passkey!.lastUsedAt).toBeDefined();330 });331});