/** * passkey-chrome-ceremony.test.ts — covers the Chrome * chrome.webAuthenticationProxy ceremony path that the Firefox-proxy test * file does NOT exercise. * * Bugs this file locks down (all three shipped in v3.6.105/v3.6.106): * 1. isUvpaa was gated on vault.isUnlocked() → Chromium RPs (Google) showed * "A passkey can't be created on this device" before a locked-vault user * could even start the ceremony. * 2. handleCreateRequest used to immediately reject with NotAllowedError on * a locked vault. Should wait for unlock the same way handleGetRequest * does. * 3. clientDataJSON.origin was built from `https://${rpId}` regardless of * the page hostname — so registering at https://myaccount.google.com * with rpId=google.com signed origin=https://google.com, which the RP * then rejected. resolvePageOrigin() must use the active tab origin * when it is a registrable-suffix match, otherwise fall back safely. * * The Chrome path also applies to every Chromium derivative — Brave, Edge, * Vivaldi, Opera, Arc — because chrome.webAuthenticationProxy is provided * by Chromium itself. UA is irrelevant here; feature presence routes us in. * See browser.test.ts for the routing coverage. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./license", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, isProUser: vi.fn(async () => true), }; }); vi.mock("../shared/passkey-flags", () => ({ FIREFOX_USE_CONTENT_SCRIPT_PROXY: false, })); type StorageShape = Record; interface ProxyListeners { create?: (req: { requestId: number; requestDetailsJson: string }) => void; get?: (req: { requestId: number; requestDetailsJson: string }) => void; uvpaa?: (req: { requestId: number }) => void; cancel?: (requestId: number) => void; } interface CompleteCaptures { create: Array<{ requestId: number; responseJson?: string; error?: { name: string; message: string } }>; get: Array<{ requestId: number; responseJson?: string; error?: { name: string; message: string } }>; uvpaa: Array<{ requestId: number; isUvpaa: boolean }>; } describe("chrome webAuthenticationProxy ceremony", () => { let storage: StorageShape; let sessionStorage: StorageShape; let proxyListeners: ProxyListeners; let completes: CompleteCaptures; let tabsSendMessage: ReturnType; let activeTabs: Array<{ id?: number; url?: string }>; let broadcastTabs: Array<{ id?: number }>; let openPopup: ReturnType; beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); storage = { peach_settings: { breachMonitoring: false, passkeyProviderEnabled: true } }; sessionStorage = {}; proxyListeners = {}; completes = { create: [], get: [], uvpaa: [] }; tabsSendMessage = vi.fn(async () => ({ ok: true })); activeTabs = [{ id: 1, url: "https://myaccount.google.com/security/passkeys" }]; broadcastTabs = [{ id: 1 }, { id: 2 }]; openPopup = vi.fn(async () => {}); const chromeMock = { runtime: { id: "peach-extension", getURL: vi.fn(() => "chrome-extension://abcd/"), onMessage: { addListener: vi.fn() }, sendMessage: vi.fn(async () => ({ ok: true })), }, tabs: { query: vi.fn((q: unknown, callback?: (t: Array<{ id?: number; url?: string }>) => void) => { const query = q as { active?: boolean; currentWindow?: boolean; lastFocusedWindow?: boolean }; const list = query.active ? activeTabs : broadcastTabs; if (typeof callback === "function") { callback(list); return; } return Promise.resolve(list); }), sendMessage: tabsSendMessage, }, storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === "string") return { [key]: storage[key] }; return key.reduce>((r, k) => { r[k] = storage[k]; return r; }, {}); }), set: vi.fn(async (next: Record) => Object.assign(storage, next)), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const k of list) delete storage[k]; }), }, session: { get: vi.fn(async (key?: string | string[] | null) => { if (key === null || key === undefined) return sessionStorage; if (typeof key === "string") return { [key]: sessionStorage[key] }; return (key as string[]).reduce>((r, k) => { r[k] = sessionStorage[k]; return r; }, {}); }), set: vi.fn(async (next: Record) => Object.assign(sessionStorage, next)), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const k of list) delete sessionStorage[k]; }), clear: vi.fn(async () => { sessionStorage = {}; }), }, }, action: { setBadgeText: vi.fn(async () => {}), setBadgeBackgroundColor: vi.fn(async () => {}), openPopup, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, scripting: { executeScript: vi.fn(async () => []) }, webAuthenticationProxy: { attach: vi.fn(async () => {}), detach: vi.fn(async () => {}), completeCreateRequest: vi.fn(async (details: typeof completes.create[number]) => { completes.create.push(details); }), completeGetRequest: vi.fn(async (details: typeof completes.get[number]) => { completes.get.push(details); }), completeIsUvpaaRequest: vi.fn(async (details: typeof completes.uvpaa[number]) => { completes.uvpaa.push(details); }), onCreateRequest: { addListener: vi.fn((fn: ProxyListeners["create"]) => { proxyListeners.create = fn; }) }, onGetRequest: { addListener: vi.fn((fn: ProxyListeners["get"]) => { proxyListeners.get = fn; }) }, onIsUvpaaRequest: { addListener: vi.fn((fn: ProxyListeners["uvpaa"]) => { proxyListeners.uvpaa = fn; }) }, onRequestCanceled: { addListener: vi.fn((fn: ProxyListeners["cancel"]) => { proxyListeners.cancel = fn; }) }, }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false); const vaultModule = await import("./vault"); await vaultModule.ensureSettingsLoaded(); const passkey = await import("./passkey"); await passkey.setupPasskeyProxy(); }); afterEach(() => { vi.unstubAllGlobals(); }); it("leaves Chrome native passkeys available when interception has no saved preference", async () => { vi.resetModules(); storage.peach_settings = { breachMonitoring: false }; vi.mocked(chrome.webAuthenticationProxy.attach).mockClear(); const passkey = await import("./passkey"); await passkey.setupPasskeyProxy(); expect(chrome.webAuthenticationProxy.attach).not.toHaveBeenCalled(); await expect(passkey.getPasskeyProviderState()).resolves.toMatchObject({ enabled: false, attached: false }); }); function fireCreate(options: object, requestId = 100): void { proxyListeners.create!({ requestId, requestDetailsJson: JSON.stringify(options), }); } function fireGet(options: object, requestId = 200): void { proxyListeners.get!({ requestId, requestDetailsJson: JSON.stringify(options), }); } async function waitForCreateComplete(requestId: number, timeoutMs = 2000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { const found = completes.create.find((c) => c.requestId === requestId); if (found) return found; await new Promise((r) => setTimeout(r, 10)); } throw new Error(`completeCreateRequest never called for requestId=${requestId}`); } async function waitForGetComplete(requestId: number, timeoutMs = 2000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { const found = completes.get.find((c) => c.requestId === requestId); if (found) return found; await new Promise((r) => setTimeout(r, 10)); } throw new Error(`completeGetRequest never called for requestId=${requestId}`); } // Auto-approve any pending Peach popup approval as it appears. function autoApprove() { void (async () => { const passkey = await import("./passkey"); for (let i = 0; i < 200; i += 1) { const pending = await passkey.getPendingPasskeyRequest(); if (pending) { await passkey.resolvePendingPasskeyRequest(pending.requestId, true); return; } await new Promise((r) => setTimeout(r, 10)); } })(); } function autoDeny() { void (async () => { const passkey = await import("./passkey"); for (let i = 0; i < 200; i += 1) { const pending = await passkey.getPendingPasskeyRequest(); if (pending) { await passkey.resolvePendingPasskeyRequest(pending.requestId, false); return; } await new Promise((r) => setTimeout(r, 10)); } })(); } function buildCreateOptions(overrides: Partial<{ rpId: string; rpName: string; userName: string; userDisplayName: string; userIdB64: string; challenge: string; pubKeyCredParams: Array<{ alg: number; type: string }>; }> = {}) { return { rp: { id: overrides.rpId ?? "google.com", name: overrides.rpName ?? "Google" }, user: { id: overrides.userIdB64 ?? "dXNlci1pZA", name: overrides.userName ?? "june@example.com", displayName: overrides.userDisplayName ?? "June", }, challenge: overrides.challenge ?? "Y2hhbGxlbmdl", pubKeyCredParams: overrides.pubKeyCredParams ?? [ { alg: -7, type: "public-key" }, { alg: -257, type: "public-key" }, ], }; } function decodeClientData(b64url: string): { type: string; challenge: string; origin: string; crossOrigin: boolean } { const padded = b64url.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(b64url.length / 4) * 4, "="); const json = atob(padded); const bytes = new Uint8Array(json.length); for (let i = 0; i < json.length; i += 1) bytes[i] = json.charCodeAt(i); return JSON.parse(new TextDecoder().decode(bytes)); } // ───────────────────────────────────────────────────────────────────── // isUvpaa — must report true regardless of lock state. (bug #2) // ───────────────────────────────────────────────────────────────────── it("isUvpaa returns true when the vault is locked (regression — was 'A passkey can't be created on this device')", async () => { proxyListeners.uvpaa!({ requestId: 1 }); await new Promise((r) => setTimeout(r, 0)); expect(completes.uvpaa).toEqual([{ requestId: 1, isUvpaa: true }]); }); it("isUvpaa returns true when the vault is unlocked", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); proxyListeners.uvpaa!({ requestId: 7 }); await new Promise((r) => setTimeout(r, 0)); expect(completes.uvpaa).toEqual([{ requestId: 7, isUvpaa: true }]); }); // ───────────────────────────────────────────────────────────────────── // Locked-vault create: wait for unlock, then proceed. (bug #3) // ───────────────────────────────────────────────────────────────────── it("handleCreateRequest on a locked vault summons inline unlock and proceeds once unlocked", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); vault.lock(); autoApprove(); fireCreate(buildCreateOptions(), 101); // Wait long enough for the request to park in unlockWaiters and for the // SUMMON_AUTOFILL message to fire, then unlock. await new Promise((r) => setTimeout(r, 50)); expect(tabsSendMessage).toHaveBeenCalledWith( 1, expect.objectContaining({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" }) ); // Unlocking fires onVaultUnlocked() via the messages handler. Tests can // skip the message dispatcher and call unlock() directly — the messages // handler's onVaultUnlocked() call is what production hits, but the // module-internal waiter notifier is what we need to fire here. await vault.unlock("correct-pass-12345"); const passkey = await import("./passkey"); passkey.onVaultUnlocked(); const done = await waitForCreateComplete(101); expect(done.error).toBeUndefined(); expect(done.responseJson).toBeDefined(); }); it("handleCreateRequest rejects with NotAllowedError when unlock waits timeout", async () => { vi.useFakeTimers(); const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); vault.lock(); fireCreate(buildCreateOptions(), 102); // Allow the request to park. await vi.advanceTimersByTimeAsync(10); // Jump past APPROVAL_TIMEOUT_MS (60s). await vi.advanceTimersByTimeAsync(60_500); vi.useRealTimers(); const done = await waitForCreateComplete(102); expect(done.error).toMatchObject({ name: "NotAllowedError" }); expect(done.responseJson).toBeUndefined(); }); // ───────────────────────────────────────────────────────────────────── // clientDataJSON.origin — page origin via active tab, not synthesized // from rpId. (bug #1) // ───────────────────────────────────────────────────────────────────── it("create encodes the active tab origin into clientDataJSON when hostname is a subdomain of the rpId", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); activeTabs = [{ id: 1, url: "https://myaccount.google.com/security/passkeys" }]; autoApprove(); fireCreate(buildCreateOptions({ rpId: "google.com" }), 110); const done = await waitForCreateComplete(110); const response = JSON.parse(done.responseJson!); const clientData = decodeClientData(response.response.clientDataJSON); expect(clientData.origin).toBe("https://myaccount.google.com"); expect(clientData.type).toBe("webauthn.create"); }); it("create encodes the active tab origin when hostname equals the rpId exactly", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); activeTabs = [{ id: 1, url: "https://github.com/settings/security" }]; autoApprove(); fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 111); const done = await waitForCreateComplete(111); const response = JSON.parse(done.responseJson!); const clientData = decodeClientData(response.response.clientDataJSON); expect(clientData.origin).toBe("https://github.com"); }); it("create falls back to https:// when the active tab is not a registrable-suffix match (safety)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); // User switched tabs to evil.example before we resolved — refuse to // sign for that origin and fall back instead of leaking a signature // for a hostile origin. activeTabs = [{ id: 1, url: "https://evil.example/passkey" }]; autoApprove(); fireCreate(buildCreateOptions({ rpId: "google.com" }), 112); const done = await waitForCreateComplete(112); const response = JSON.parse(done.responseJson!); const clientData = decodeClientData(response.response.clientDataJSON); expect(clientData.origin).toBe("https://google.com"); }); it("create falls back to https:// when the active tab has no URL", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); activeTabs = [{ id: 1 }]; // no url autoApprove(); fireCreate(buildCreateOptions({ rpId: "google.com" }), 113); const done = await waitForCreateComplete(113); const response = JSON.parse(done.responseJson!); const clientData = decodeClientData(response.response.clientDataJSON); expect(clientData.origin).toBe("https://google.com"); }); // ───────────────────────────────────────────────────────────────────── // Core create ceremony. // ───────────────────────────────────────────────────────────────────── it("create rejects with NotSupportedError when rp.id is missing", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); fireCreate({ rp: { name: "No id" }, user: { id: "x", name: "u" }, challenge: "c", pubKeyCredParams: [{ alg: -7, type: "public-key" }] }, 120); const done = await waitForCreateComplete(120); expect(done.error).toMatchObject({ name: "NotSupportedError" }); }); it("create rejects with NotSupportedError when no supported algorithm is offered", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); autoApprove(); fireCreate(buildCreateOptions({ pubKeyCredParams: [{ alg: -257, type: "public-key" }] }), 121); const done = await waitForCreateComplete(121); expect(done.error).toMatchObject({ name: "NotSupportedError" }); }); it("create rejects with NotAllowedError when the user denies the approval", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); autoDeny(); fireCreate(buildCreateOptions(), 122); const done = await waitForCreateComplete(122); expect(done.error).toMatchObject({ name: "NotAllowedError" }); }); it("create rejects with NotAllowedError + broadcasts the pro gate when the user is not Pro", async () => { const license = await import("./license"); (license.isProUser as unknown as ReturnType).mockResolvedValueOnce(false); const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); fireCreate(buildCreateOptions(), 123); const done = await waitForCreateComplete(123); expect(done.error).toMatchObject({ name: "NotAllowedError" }); // Pro gate broadcasted to every tab via tabs.sendMessage with PASSKEY_PRO_GATE. expect(tabsSendMessage).toHaveBeenCalledWith( expect.any(Number), expect.objectContaining({ type: "PASSKEY_PRO_GATE" }) ); }); it("create persists the passkey to the vault and tags it browser:'chrome'", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); autoApprove(); fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 124); await waitForCreateComplete(124); const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com"); expect(stored).toBeDefined(); expect(stored!.passkey!.browser).toBe("chrome"); expect(stored!.passkey!.backupEligible).toBe(true); expect(stored!.passkey!.signCount).toBe(0); expect(stored!.passkey!.privateKeyJwk).toBeDefined(); const createResponse = JSON.parse(completes.create.find((c) => c.requestId === 124)!.responseJson!); const authenticatorData = Uint8Array.from(atob(createResponse.response.authenticatorData.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(createResponse.response.authenticatorData.length / 4) * 4, "=")), (char) => char.charCodeAt(0)); expect(authenticatorData[32]! & 0x08).toBe(0x08); }); it("create attaches the passkey to an existing login entry for the same domain instead of creating a new one", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const existing = await vault.addEntry({ type: "login", title: "GitHub", url: "https://github.com", username: "june", password: "hunter2", }); const beforeCount = vault.getEntries().length; autoApprove(); fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 125); await waitForCreateComplete(125); expect(vault.getEntries().length).toBe(beforeCount); const updated = vault.getEntries().find((e) => e.id === existing.id); expect(updated?.passkey?.rpId).toBe("github.com"); }); // ───────────────────────────────────────────────────────────────────── // Get ceremony. // ───────────────────────────────────────────────────────────────────── async function seedPasskey(rpId: string, opts?: { signCount?: number }) { const vault = await import("./vault"); const { base64urlEncode, generatePasskeyKeypair } = await import("./passkey-crypto"); const generated = await generatePasskeyKeypair(-7); const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32))); const entry = await vault.addEntry({ type: "login", title: rpId, url: `https://${rpId}`, username: "june", passkey: { rpId, rpName: rpId, credentialId, publicKeyCose: base64urlEncode(generated.publicKeyCose), privateKeyJwk: generated.privateKeyJwk, userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))), userName: "june", userDisplayName: "June", algorithm: -7, signCount: opts?.signCount ?? 0, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: false, backupState: false, browser: "chrome", transports: ["internal"], createdAt: Date.now(), }, }); return { entry, credentialId }; } it("get passes through to platform when no stored credential matches the rpId", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 200); const done = await waitForGetComplete(200); // Pass-through: no error and no responseJson — Chrome routes to next authenticator. expect(done.error).toBeUndefined(); expect(done.responseJson).toBeUndefined(); }); // PB-26-015: a credential-ID match must still belong to the requesting rpId — // a foreign-RP request listing a known credential id must not select it. it("get does not match a credential whose stored rpId differs from the request rpId", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const { credentialId } = await seedPasskey("github.com"); fireGet( { rpId: "evil.example", challenge: "Y2g", allowCredentials: [{ type: "public-key", id: credentialId }], }, 205, ); const done = await waitForGetComplete(205); expect(done.error).toBeUndefined(); expect(done.responseJson).toBeUndefined(); }); it("get encodes the active tab page origin into clientDataJSON (not https://)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await seedPasskey("google.com"); activeTabs = [{ id: 1, url: "https://accounts.google.com/login" }]; autoApprove(); fireGet({ rpId: "google.com", challenge: "Y2g", allowCredentials: [] }, 201); const done = await waitForGetComplete(201); const response = JSON.parse(done.responseJson!); const clientData = decodeClientData(response.response.clientDataJSON); expect(clientData.origin).toBe("https://accounts.google.com"); expect(clientData.type).toBe("webauthn.get"); }); it("get increments signCount and updates lastUsedAt on success", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const { entry } = await seedPasskey("github.com", { signCount: 7 }); autoApprove(); fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 202); await waitForGetComplete(202); const after = vault.getEntries().find((e) => e.id === entry.id); expect(after!.passkey!.signCount).toBe(8); expect(after!.passkey!.lastUsedAt).toBeDefined(); }); it("get preserves the BE flag used to register legacy Chrome passkeys", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const { entry } = await seedPasskey("github.com"); expect(entry.passkey!.browser).toBe("chrome"); expect(entry.passkey!.backupEligible).toBe(false); autoApprove(); fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 206); const done = await waitForGetComplete(206); const response = JSON.parse(done.responseJson!); const authenticatorData = Uint8Array.from( atob(response.response.authenticatorData.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(response.response.authenticatorData.length / 4) * 4, "=")), (char) => char.charCodeAt(0), ); expect(authenticatorData[32]! & 0x08).toBe(0x08); expect(vault.getEntries().find((candidate) => candidate.id === entry.id)!.passkey!.backupEligible).toBe(true); }); it("get passes through to platform when the stored passkey has no private key (Firefox-created)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const { base64urlEncode, generatePasskeyKeypair } = await import("./passkey-crypto"); const generated = await generatePasskeyKeypair(-7); await vault.addEntry({ type: "login", title: "Cross-browser passkey", passkey: { rpId: "webauthn.io", rpName: "WebAuthn.io", credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), publicKeyCose: base64urlEncode(generated.publicKeyCose), // privateKeyJwk intentionally absent — pre-v2.12 Firefox passkey // delegated signing to the platform authenticator. userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))), userName: "june", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: false, backupState: false, browser: "firefox", transports: ["internal"], createdAt: Date.now(), }, }); fireGet({ rpId: "webauthn.io", challenge: "Y2g", allowCredentials: [] }, 203); const done = await waitForGetComplete(203); // Pass-through: no error and no responseJson — Chrome routes to next authenticator. expect(done.error).toBeUndefined(); expect(done.responseJson).toBeUndefined(); }); it("get on a locked vault parks the request and proceeds after unlock", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await seedPasskey("github.com"); vault.lock(); autoApprove(); fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 204); await new Promise((r) => setTimeout(r, 50)); expect(tabsSendMessage).toHaveBeenCalledWith( 1, expect.objectContaining({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" }) ); await vault.unlock("correct-pass-12345"); const passkey = await import("./passkey"); passkey.onVaultUnlocked(); const done = await waitForGetComplete(204); expect(done.error).toBeUndefined(); expect(done.responseJson).toBeDefined(); }); // ───────────────────────────────────────────────────────────────────── // onRequestCanceled — popup-closed / RP-cancelled cleanup. // ───────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────── // "Invalid requestId" survival — conditional-UI gets cancel fast. // ───────────────────────────────────────────────────────────────────── it("pass-through swallows 'Invalid requestId' from a cancelled conditional-UI get without throwing", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); // Simulate Chrome cancelling the request between handleGetRequest running // and the pass-through completeGetRequest call landing. const ce = (globalThis as { chrome: typeof chrome }).chrome.webAuthenticationProxy .completeGetRequest as unknown as ReturnType; ce.mockImplementationOnce(async () => { throw new Error("Invalid requestId"); }); // No matching passkey for this rpId → pass-through completeGetRequest call, // which must NOT propagate the Invalid requestId error. let resolved = false; proxyListeners.get!({ requestId: 999, requestDetailsJson: JSON.stringify({ rpId: "unknown.example", challenge: "x", allowCredentials: [] }), }); // Allow the handler to finish; if it threw, the test would unhandle-reject. await new Promise((r) => setTimeout(r, 30)); resolved = true; expect(resolved).toBe(true); }); it("rejectCreate swallows 'Invalid requestId' from a cancelled create", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const ce = (globalThis as { chrome: typeof chrome }).chrome.webAuthenticationProxy .completeCreateRequest as unknown as ReturnType; ce.mockImplementationOnce(async () => { throw new Error("Invalid requestId"); }); // Force the early rp.id missing path so rejectCreate runs first. fireCreate({ rp: {}, user: { id: "x", name: "u" }, challenge: "c", pubKeyCredParams: [{ alg: -7, type: "public-key" }] }, 998); await new Promise((r) => setTimeout(r, 30)); // No assertions on completes — the assertion is that the handler did // not throw an unhandled rejection (the test would fail under vitest's // unhandled-rejection guard otherwise). }); it("onRequestCanceled clears a parked unlock waiter so the handler exits promptly", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); vault.lock(); fireCreate(buildCreateOptions(), 997); // Wait for the request to park. await new Promise((r) => setTimeout(r, 30)); proxyListeners.cancel!(997); const done = await waitForCreateComplete(997, 500); expect(done.error).toMatchObject({ name: "NotAllowedError" }); }); it("onRequestCanceled resolves a still-pending approval as denied", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); fireCreate(buildCreateOptions(), 301); // Wait for the approval to be registered. const passkey = await import("./passkey"); let pending: Awaited> = null; for (let i = 0; i < 50; i += 1) { pending = await passkey.getPendingPasskeyRequest(); if (pending) break; await new Promise((r) => setTimeout(r, 10)); } expect(pending).toBeTruthy(); proxyListeners.cancel!(301); // After cancel the pending session record should be cleared. for (let i = 0; i < 50; i += 1) { const after = await passkey.getPendingPasskeyRequest(); if (!after) return; await new Promise((r) => setTimeout(r, 10)); } throw new Error("pending passkey request was not cleared after onRequestCanceled"); }); });