12345678910111213141516171819202122232425import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";2627vi.mock("./license", async (importOriginal) => {28 const actual = await importOriginal<typeof import("./license")>();29 return {30 ...actual,31 isProUser: vi.fn(async () => true),32 };33});3435vi.mock("../shared/passkey-flags", () => ({36 FIREFOX_USE_CONTENT_SCRIPT_PROXY: false,37}));3839type StorageShape = Record<string, unknown>;4041interface ProxyListeners {42 create?: (req: { requestId: number; requestDetailsJson: string }) => void;43 get?: (req: { requestId: number; requestDetailsJson: string }) => void;44 uvpaa?: (req: { requestId: number }) => void;45 cancel?: (requestId: number) => void;46}4748interface CompleteCaptures {49 create: Array<{ requestId: number; responseJson?: string; error?: { name: string; message: string } }>;50 get: Array<{ requestId: number; responseJson?: string; error?: { name: string; message: string } }>;51 uvpaa: Array<{ requestId: number; isUvpaa: boolean }>;52}5354describe("chrome webAuthenticationProxy ceremony", () => {55 let storage: StorageShape;56 let sessionStorage: StorageShape;57 let proxyListeners: ProxyListeners;58 let completes: CompleteCaptures;59 let tabsSendMessage: ReturnType<typeof vi.fn>;60 let activeTabs: Array<{ id?: number; url?: string }>;61 let broadcastTabs: Array<{ id?: number }>;62 let openPopup: ReturnType<typeof vi.fn>;6364 beforeEach(async () => {65 vi.resetModules();66 vi.useRealTimers();67 storage = { peach_settings: { breachMonitoring: false, passkeyProviderEnabled: true } };68 sessionStorage = {};69 proxyListeners = {};70 completes = { create: [], get: [], uvpaa: [] };71 tabsSendMessage = vi.fn(async () => ({ ok: true }));72 activeTabs = [{ id: 1, url: "https://myaccount.google.com/security/passkeys" }];73 broadcastTabs = [{ id: 1 }, { id: 2 }];74 openPopup = vi.fn(async () => {});7576 const chromeMock = {77 runtime: {78 id: "peach-extension",79 getURL: vi.fn(() => "chrome-extension://abcd/"),80 onMessage: { addListener: vi.fn() },81 sendMessage: vi.fn(async () => ({ ok: true })),82 },83 tabs: {84 query: vi.fn((q: unknown, callback?: (t: Array<{ id?: number; url?: string }>) => void) => {85 const query = q as { active?: boolean; currentWindow?: boolean; lastFocusedWindow?: boolean };86 const list = query.active ? activeTabs : broadcastTabs;87 if (typeof callback === "function") { callback(list); return; }88 return Promise.resolve(list);89 }),90 sendMessage: tabsSendMessage,91 },92 storage: {93 local: {94 get: vi.fn(async (key?: string | string[]) => {95 if (!key) return storage;96 if (typeof key === "string") return { [key]: storage[key] };97 return key.reduce<Record<string, unknown>>((r, k) => { r[k] = storage[k]; return r; }, {});98 }),99 set: vi.fn(async (next: Record<string, unknown>) => Object.assign(storage, next)),100 remove: vi.fn(async (keys: string | string[]) => {101 const list = Array.isArray(keys) ? keys : [keys];102 for (const k of list) delete storage[k];103 }),104 },105 session: {106 get: vi.fn(async (key?: string | string[] | null) => {107 if (key === null || key === undefined) return sessionStorage;108 if (typeof key === "string") return { [key]: sessionStorage[key] };109 return (key as string[]).reduce<Record<string, unknown>>((r, k) => { r[k] = sessionStorage[k]; return r; }, {});110 }),111 set: vi.fn(async (next: Record<string, unknown>) => Object.assign(sessionStorage, next)),112 remove: vi.fn(async (keys: string | string[]) => {113 const list = Array.isArray(keys) ? keys : [keys];114 for (const k of list) delete sessionStorage[k];115 }),116 clear: vi.fn(async () => { sessionStorage = {}; }),117 },118 },119 action: {120 setBadgeText: vi.fn(async () => {}),121 setBadgeBackgroundColor: vi.fn(async () => {}),122 openPopup,123 },124 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },125 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },126 scripting: { executeScript: vi.fn(async () => []) },127 webAuthenticationProxy: {128 attach: vi.fn(async () => {}),129 detach: vi.fn(async () => {}),130 completeCreateRequest: vi.fn(async (details: typeof completes.create[number]) => {131 completes.create.push(details);132 }),133 completeGetRequest: vi.fn(async (details: typeof completes.get[number]) => {134 completes.get.push(details);135 }),136 completeIsUvpaaRequest: vi.fn(async (details: typeof completes.uvpaa[number]) => {137 completes.uvpaa.push(details);138 }),139 onCreateRequest: { addListener: vi.fn((fn: ProxyListeners["create"]) => { proxyListeners.create = fn; }) },140 onGetRequest: { addListener: vi.fn((fn: ProxyListeners["get"]) => { proxyListeners.get = fn; }) },141 onIsUvpaaRequest: { addListener: vi.fn((fn: ProxyListeners["uvpaa"]) => { proxyListeners.uvpaa = fn; }) },142 onRequestCanceled: { addListener: vi.fn((fn: ProxyListeners["cancel"]) => { proxyListeners.cancel = fn; }) },143 },144 } as unknown as typeof chrome;145146 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;147 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);148149 const vaultModule = await import("./vault");150 await vaultModule.ensureSettingsLoaded();151 const passkey = await import("./passkey");152 await passkey.setupPasskeyProxy();153 });154155 afterEach(() => {156 vi.unstubAllGlobals();157 });158159 function fireCreate(options: object, requestId = 100): void {160 proxyListeners.create!({161 requestId,162 requestDetailsJson: JSON.stringify(options),163 });164 }165166 function fireGet(options: object, requestId = 200): void {167 proxyListeners.get!({168 requestId,169 requestDetailsJson: JSON.stringify(options),170 });171 }172173 async function waitForCreateComplete(requestId: number, timeoutMs = 2000): Promise<typeof completes.create[number]> {174 const start = Date.now();175 while (Date.now() - start < timeoutMs) {176 const found = completes.create.find((c) => c.requestId === requestId);177 if (found) return found;178 await new Promise((r) => setTimeout(r, 10));179 }180 throw new Error(`completeCreateRequest never called for requestId=${requestId}`);181 }182183 async function waitForGetComplete(requestId: number, timeoutMs = 2000): Promise<typeof completes.get[number]> {184 const start = Date.now();185 while (Date.now() - start < timeoutMs) {186 const found = completes.get.find((c) => c.requestId === requestId);187 if (found) return found;188 await new Promise((r) => setTimeout(r, 10));189 }190 throw new Error(`completeGetRequest never called for requestId=${requestId}`);191 }192193 194 function autoApprove() {195 void (async () => {196 const passkey = await import("./passkey");197 for (let i = 0; i < 200; i += 1) {198 const pending = await passkey.getPendingPasskeyRequest();199 if (pending) {200 await passkey.resolvePendingPasskeyRequest(pending.requestId, true);201 return;202 }203 await new Promise((r) => setTimeout(r, 10));204 }205 })();206 }207208 function autoDeny() {209 void (async () => {210 const passkey = await import("./passkey");211 for (let i = 0; i < 200; i += 1) {212 const pending = await passkey.getPendingPasskeyRequest();213 if (pending) {214 await passkey.resolvePendingPasskeyRequest(pending.requestId, false);215 return;216 }217 await new Promise((r) => setTimeout(r, 10));218 }219 })();220 }221222 function buildCreateOptions(overrides: Partial<{223 rpId: string;224 rpName: string;225 userName: string;226 userDisplayName: string;227 userIdB64: string;228 challenge: string;229 pubKeyCredParams: Array<{ alg: number; type: string }>;230 }> = {}) {231 return {232 rp: { id: overrides.rpId ?? "google.com", name: overrides.rpName ?? "Google" },233 user: {234 id: overrides.userIdB64 ?? "dXNlci1pZA",235 name: overrides.userName ?? "[email protected]",236 displayName: overrides.userDisplayName ?? "June",237 },238 challenge: overrides.challenge ?? "Y2hhbGxlbmdl",239 pubKeyCredParams: overrides.pubKeyCredParams ?? [240 { alg: -7, type: "public-key" },241 { alg: -257, type: "public-key" },242 ],243 };244 }245246 function decodeClientData(b64url: string): { type: string; challenge: string; origin: string; crossOrigin: boolean } {247 const padded = b64url.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(b64url.length / 4) * 4, "=");248 const json = atob(padded);249 const bytes = new Uint8Array(json.length);250 for (let i = 0; i < json.length; i += 1) bytes[i] = json.charCodeAt(i);251 return JSON.parse(new TextDecoder().decode(bytes));252 }253254 255 256 257258 it("isUvpaa returns true when the vault is locked (regression — was 'A passkey can't be created on this device')", async () => {259 proxyListeners.uvpaa!({ requestId: 1 });260 await new Promise((r) => setTimeout(r, 0));261 expect(completes.uvpaa).toEqual([{ requestId: 1, isUvpaa: true }]);262 });263264 it("isUvpaa returns true when the vault is unlocked", async () => {265 const vault = await import("./vault");266 await vault.createVault("correct-pass-12345");267 proxyListeners.uvpaa!({ requestId: 7 });268 await new Promise((r) => setTimeout(r, 0));269 expect(completes.uvpaa).toEqual([{ requestId: 7, isUvpaa: true }]);270 });271272 273 274 275276 it("handleCreateRequest on a locked vault summons inline unlock and proceeds once unlocked", async () => {277 const vault = await import("./vault");278 await vault.createVault("correct-pass-12345");279 vault.lock();280281 autoApprove();282 fireCreate(buildCreateOptions(), 101);283284 285 286 await new Promise((r) => setTimeout(r, 50));287 expect(tabsSendMessage).toHaveBeenCalledWith(288 1,289 expect.objectContaining({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" })290 );291292 293 294 295 296 await vault.unlock("correct-pass-12345");297 const passkey = await import("./passkey");298 passkey.onVaultUnlocked();299300 const done = await waitForCreateComplete(101);301 expect(done.error).toBeUndefined();302 expect(done.responseJson).toBeDefined();303 });304305 it("handleCreateRequest rejects with NotAllowedError when unlock waits timeout", async () => {306 vi.useFakeTimers();307 const vault = await import("./vault");308 await vault.createVault("correct-pass-12345");309 vault.lock();310311 fireCreate(buildCreateOptions(), 102);312313 314 await vi.advanceTimersByTimeAsync(10);315 316 await vi.advanceTimersByTimeAsync(60_500);317 vi.useRealTimers();318319 const done = await waitForCreateComplete(102);320 expect(done.error).toMatchObject({ name: "NotAllowedError" });321 expect(done.responseJson).toBeUndefined();322 });323324 325 326 327 328329 it("create encodes the active tab origin into clientDataJSON when hostname is a subdomain of the rpId", async () => {330 const vault = await import("./vault");331 await vault.createVault("correct-pass-12345");332333 activeTabs = [{ id: 1, url: "https://myaccount.google.com/security/passkeys" }];334335 autoApprove();336 fireCreate(buildCreateOptions({ rpId: "google.com" }), 110);337 const done = await waitForCreateComplete(110);338339 const response = JSON.parse(done.responseJson!);340 const clientData = decodeClientData(response.response.clientDataJSON);341 expect(clientData.origin).toBe("https://myaccount.google.com");342 expect(clientData.type).toBe("webauthn.create");343 });344345 it("create encodes the active tab origin when hostname equals the rpId exactly", async () => {346 const vault = await import("./vault");347 await vault.createVault("correct-pass-12345");348349 activeTabs = [{ id: 1, url: "https://github.com/settings/security" }];350351 autoApprove();352 fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 111);353 const done = await waitForCreateComplete(111);354355 const response = JSON.parse(done.responseJson!);356 const clientData = decodeClientData(response.response.clientDataJSON);357 expect(clientData.origin).toBe("https://github.com");358 });359360 it("create falls back to https://<rpId> when the active tab is not a registrable-suffix match (safety)", async () => {361 const vault = await import("./vault");362 await vault.createVault("correct-pass-12345");363364 365 366 367 activeTabs = [{ id: 1, url: "https://evil.example/passkey" }];368369 autoApprove();370 fireCreate(buildCreateOptions({ rpId: "google.com" }), 112);371 const done = await waitForCreateComplete(112);372373 const response = JSON.parse(done.responseJson!);374 const clientData = decodeClientData(response.response.clientDataJSON);375 expect(clientData.origin).toBe("https://google.com");376 });377378 it("create falls back to https://<rpId> when the active tab has no URL", async () => {379 const vault = await import("./vault");380 await vault.createVault("correct-pass-12345");381382 activeTabs = [{ id: 1 }]; 383384 autoApprove();385 fireCreate(buildCreateOptions({ rpId: "google.com" }), 113);386 const done = await waitForCreateComplete(113);387388 const response = JSON.parse(done.responseJson!);389 const clientData = decodeClientData(response.response.clientDataJSON);390 expect(clientData.origin).toBe("https://google.com");391 });392393 394 395 396397 it("create rejects with NotSupportedError when rp.id is missing", async () => {398 const vault = await import("./vault");399 await vault.createVault("correct-pass-12345");400401 fireCreate({ rp: { name: "No id" }, user: { id: "x", name: "u" }, challenge: "c", pubKeyCredParams: [{ alg: -7, type: "public-key" }] }, 120);402 const done = await waitForCreateComplete(120);403 expect(done.error).toMatchObject({ name: "NotSupportedError" });404 });405406 it("create rejects with NotSupportedError when no supported algorithm is offered", async () => {407 const vault = await import("./vault");408 await vault.createVault("correct-pass-12345");409410 autoApprove();411 fireCreate(buildCreateOptions({ pubKeyCredParams: [{ alg: -257, type: "public-key" }] }), 121);412 const done = await waitForCreateComplete(121);413 expect(done.error).toMatchObject({ name: "NotSupportedError" });414 });415416 it("create rejects with NotAllowedError when the user denies the approval", async () => {417 const vault = await import("./vault");418 await vault.createVault("correct-pass-12345");419420 autoDeny();421 fireCreate(buildCreateOptions(), 122);422 const done = await waitForCreateComplete(122);423 expect(done.error).toMatchObject({ name: "NotAllowedError" });424 });425426 it("create rejects with NotAllowedError + broadcasts the pro gate when the user is not Pro", async () => {427 const license = await import("./license");428 (license.isProUser as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);429430 const vault = await import("./vault");431 await vault.createVault("correct-pass-12345");432433 fireCreate(buildCreateOptions(), 123);434 const done = await waitForCreateComplete(123);435 expect(done.error).toMatchObject({ name: "NotAllowedError" });436 437 expect(tabsSendMessage).toHaveBeenCalledWith(438 expect.any(Number),439 expect.objectContaining({ type: "PASSKEY_PRO_GATE" })440 );441 });442443 it("create persists the passkey to the vault and tags it browser:'chrome'", async () => {444 const vault = await import("./vault");445 await vault.createVault("correct-pass-12345");446447 autoApprove();448 fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 124);449 await waitForCreateComplete(124);450451 const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");452 expect(stored).toBeDefined();453 expect(stored!.passkey!.browser).toBe("chrome");454 expect(stored!.passkey!.signCount).toBe(0);455 expect(stored!.passkey!.privateKeyJwk).toBeDefined();456 });457458 it("create attaches the passkey to an existing login entry for the same domain instead of creating a new one", async () => {459 const vault = await import("./vault");460 await vault.createVault("correct-pass-12345");461 const existing = await vault.addEntry({462 type: "login",463 title: "GitHub",464 url: "https://github.com",465 username: "june",466 password: "hunter2",467 });468 const beforeCount = vault.getEntries().length;469470 autoApprove();471 fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 125);472 await waitForCreateComplete(125);473474 expect(vault.getEntries().length).toBe(beforeCount);475 const updated = vault.getEntries().find((e) => e.id === existing.id);476 expect(updated?.passkey?.rpId).toBe("github.com");477 });478479 480 481 482483 async function seedPasskey(rpId: string, opts?: { signCount?: number }) {484 const vault = await import("./vault");485 const { base64urlEncode, generatePasskeyKeypair } = await import("./passkey-crypto");486 const generated = await generatePasskeyKeypair(-7);487 const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));488 const entry = await vault.addEntry({489 type: "login",490 title: rpId,491 url: `https://${rpId}`,492 username: "june",493 passkey: {494 rpId,495 rpName: rpId,496 credentialId,497 publicKeyCose: base64urlEncode(generated.publicKeyCose),498 privateKeyJwk: generated.privateKeyJwk,499 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),500 userName: "june",501 userDisplayName: "June",502 algorithm: -7,503 signCount: opts?.signCount ?? 0,504 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",505 backupEligible: false,506 backupState: false,507 browser: "chrome",508 transports: ["internal"],509 createdAt: Date.now(),510 },511 });512 return { entry, credentialId };513 }514515 it("get passes through to platform when no stored credential matches the rpId", async () => {516 const vault = await import("./vault");517 await vault.createVault("correct-pass-12345");518519 fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 200);520 const done = await waitForGetComplete(200);521 522 expect(done.error).toBeUndefined();523 expect(done.responseJson).toBeUndefined();524 });525526 it("get encodes the active tab page origin into clientDataJSON (not https://<rpId>)", async () => {527 const vault = await import("./vault");528 await vault.createVault("correct-pass-12345");529 await seedPasskey("google.com");530531 activeTabs = [{ id: 1, url: "https://accounts.google.com/login" }];532533 autoApprove();534 fireGet({ rpId: "google.com", challenge: "Y2g", allowCredentials: [] }, 201);535 const done = await waitForGetComplete(201);536537 const response = JSON.parse(done.responseJson!);538 const clientData = decodeClientData(response.response.clientDataJSON);539 expect(clientData.origin).toBe("https://accounts.google.com");540 expect(clientData.type).toBe("webauthn.get");541 });542543 it("get increments signCount and updates lastUsedAt on success", async () => {544 const vault = await import("./vault");545 await vault.createVault("correct-pass-12345");546 const { entry } = await seedPasskey("github.com", { signCount: 7 });547548 autoApprove();549 fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 202);550 await waitForGetComplete(202);551552 const after = vault.getEntries().find((e) => e.id === entry.id);553 expect(after!.passkey!.signCount).toBe(8);554 expect(after!.passkey!.lastUsedAt).toBeDefined();555 });556557 it("get passes through to platform when the stored passkey has no private key (Firefox-created)", async () => {558 const vault = await import("./vault");559 await vault.createVault("correct-pass-12345");560 const { base64urlEncode, generatePasskeyKeypair } = await import("./passkey-crypto");561 const generated = await generatePasskeyKeypair(-7);562 await vault.addEntry({563 type: "login",564 title: "Cross-browser passkey",565 passkey: {566 rpId: "webauthn.io",567 rpName: "WebAuthn.io",568 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),569 publicKeyCose: base64urlEncode(generated.publicKeyCose),570 571 572 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),573 userName: "june",574 userDisplayName: "June",575 algorithm: -7,576 signCount: 0,577 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",578 backupEligible: false,579 backupState: false,580 browser: "firefox",581 transports: ["internal"],582 createdAt: Date.now(),583 },584 });585586 fireGet({ rpId: "webauthn.io", challenge: "Y2g", allowCredentials: [] }, 203);587 const done = await waitForGetComplete(203);588 589 expect(done.error).toBeUndefined();590 expect(done.responseJson).toBeUndefined();591 });592593 it("get on a locked vault parks the request and proceeds after unlock", async () => {594 const vault = await import("./vault");595 await vault.createVault("correct-pass-12345");596 await seedPasskey("github.com");597 vault.lock();598599 autoApprove();600 fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 204);601602 await new Promise((r) => setTimeout(r, 50));603 expect(tabsSendMessage).toHaveBeenCalledWith(604 1,605 expect.objectContaining({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" })606 );607608 await vault.unlock("correct-pass-12345");609 const passkey = await import("./passkey");610 passkey.onVaultUnlocked();611612 const done = await waitForGetComplete(204);613 expect(done.error).toBeUndefined();614 expect(done.responseJson).toBeDefined();615 });616617 618 619 620621 622 623 624625 it("pass-through swallows 'Invalid requestId' from a cancelled conditional-UI get without throwing", async () => {626 const vault = await import("./vault");627 await vault.createVault("correct-pass-12345");628629 630 631 const ce = (globalThis as { chrome: typeof chrome }).chrome.webAuthenticationProxy632 .completeGetRequest as unknown as ReturnType<typeof vi.fn>;633 ce.mockImplementationOnce(async () => {634 throw new Error("Invalid requestId");635 });636637 638 639 let resolved = false;640 proxyListeners.get!({641 requestId: 999,642 requestDetailsJson: JSON.stringify({ rpId: "unknown.example", challenge: "x", allowCredentials: [] }),643 });644 645 await new Promise((r) => setTimeout(r, 30));646 resolved = true;647 expect(resolved).toBe(true);648 });649650 it("rejectCreate swallows 'Invalid requestId' from a cancelled create", async () => {651 const vault = await import("./vault");652 await vault.createVault("correct-pass-12345");653654 const ce = (globalThis as { chrome: typeof chrome }).chrome.webAuthenticationProxy655 .completeCreateRequest as unknown as ReturnType<typeof vi.fn>;656 ce.mockImplementationOnce(async () => {657 throw new Error("Invalid requestId");658 });659660 661 fireCreate({ rp: {}, user: { id: "x", name: "u" }, challenge: "c", pubKeyCredParams: [{ alg: -7, type: "public-key" }] }, 998);662 await new Promise((r) => setTimeout(r, 30));663 664 665 666 });667668 it("onRequestCanceled clears a parked unlock waiter so the handler exits promptly", async () => {669 const vault = await import("./vault");670 await vault.createVault("correct-pass-12345");671 vault.lock();672673 fireCreate(buildCreateOptions(), 997);674 675 await new Promise((r) => setTimeout(r, 30));676677 proxyListeners.cancel!(997);678679 const done = await waitForCreateComplete(997, 500);680 expect(done.error).toMatchObject({ name: "NotAllowedError" });681 });682683 it("onRequestCanceled resolves a still-pending approval as denied", async () => {684 const vault = await import("./vault");685 await vault.createVault("correct-pass-12345");686687 fireCreate(buildCreateOptions(), 301);688689 690 const passkey = await import("./passkey");691 let pending: Awaited<ReturnType<typeof passkey.getPendingPasskeyRequest>> = null;692 for (let i = 0; i < 50; i += 1) {693 pending = await passkey.getPendingPasskeyRequest();694 if (pending) break;695 await new Promise((r) => setTimeout(r, 10));696 }697 expect(pending).toBeTruthy();698699 proxyListeners.cancel!(301);700701 702 for (let i = 0; i < 50; i += 1) {703 const after = await passkey.getPendingPasskeyRequest();704 if (!after) return;705 await new Promise((r) => setTimeout(r, 10));706 }707 throw new Error("pending passkey request was not cleared after onRequestCanceled");708 });709});