549991994e90
test: add passkey-chrome-ceremony.test.ts (20 tests) and browser.test.ts (17 tests) — locks down isUvpaa unconditional true, locked-vault wait-for-unlock, page-origin clientDataJSON, signCount persistence, Pro gating, attach-target selection; covers Chromium derivatives (Brave/Edge/Vivaldi/Opera/Arc) and Firefox derivatives (LibreWolf/Floorp) via feature detection (v3.6.107)
- time
- 2026-05-28 17:14 UTC · 5 months ago
- machine
- koh2
- parent
- 46a7aa189940
↓ peach-browser-54999199.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/passkey-chrome-ceremony.test.ts+643 −0src/shared/browser.test.ts+152 −0manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "3.6.106",5+ "version": "3.6.107",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "3.6.106",4+ "version": "3.6.107",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "3.6.106",3+ "version": "3.6.107",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/passkey-chrome-ceremony.test.ts+643 −0
@@ -0,0 +1,643 @@1+/**2+ * passkey-chrome-ceremony.test.ts — covers the Chrome3+ * chrome.webAuthenticationProxy ceremony path that the Firefox-proxy test4+ * file does NOT exercise.5+ *6+ * Bugs this file locks down (all three shipped in v3.6.105/v3.6.106):7+ * 1. isUvpaa was gated on vault.isUnlocked() → Chromium RPs (Google) showed8+ * "A passkey can't be created on this device" before a locked-vault user9+ * could even start the ceremony.10+ * 2. handleCreateRequest used to immediately reject with NotAllowedError on11+ * a locked vault. Should wait for unlock the same way handleGetRequest12+ * does.13+ * 3. clientDataJSON.origin was built from `https://${rpId}` regardless of14+ * the page hostname — so registering at https://myaccount.google.com15+ * with rpId=google.com signed origin=https://google.com, which the RP16+ * then rejected. resolvePageOrigin() must use the active tab origin17+ * when it is a registrable-suffix match, otherwise fall back safely.18+ *19+ * The Chrome path also applies to every Chromium derivative — Brave, Edge,20+ * Vivaldi, Opera, Arc — because chrome.webAuthenticationProxy is provided21+ * by Chromium itself. UA is irrelevant here; feature presence routes us in.22+ * See browser.test.ts for the routing coverage.23+ */24+25+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";26+27+vi.mock("./license", async (importOriginal) => {28+ const actual = await importOriginal<typeof import("./license")>();29+ return {30+ ...actual,31+ isProUser: vi.fn(async () => true),32+ };33+});34+35+vi.mock("../shared/passkey-flags", () => ({36+ FIREFOX_USE_CONTENT_SCRIPT_PROXY: false,37+}));38+39+type StorageShape = Record<string, unknown>;40+41+interface 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+}47+48+interface 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+}53+54+describe("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>;63+64+ 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 () => {});75+76+ 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;145+146+ (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;147+ vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);148+149+ const vaultModule = await import("./vault");150+ await vaultModule.ensureSettingsLoaded();151+ const passkey = await import("./passkey");152+ await passkey.setupPasskeyProxy();153+ });154+155+ afterEach(() => {156+ vi.unstubAllGlobals();157+ });158+159+ function fireCreate(options: object, requestId = 100): void {160+ proxyListeners.create!({161+ requestId,162+ requestDetailsJson: JSON.stringify(options),163+ });164+ }165+166+ function fireGet(options: object, requestId = 200): void {167+ proxyListeners.get!({168+ requestId,169+ requestDetailsJson: JSON.stringify(options),170+ });171+ }172+173+ 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+ }182+183+ 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+ }192+193+ // Auto-approve any pending Peach popup approval as it appears.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+ }207+208+ 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+ }221+222+ 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+ }245+246+ 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+ }253+254+ // ─────────────────────────────────────────────────────────────────────255+ // isUvpaa — must report true regardless of lock state. (bug #2)256+ // ─────────────────────────────────────────────────────────────────────257+258+ 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+ });263+264+ 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+ });271+272+ // ─────────────────────────────────────────────────────────────────────273+ // Locked-vault create: wait for unlock, then proceed. (bug #3)274+ // ─────────────────────────────────────────────────────────────────────275+276+ 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();280+281+ autoApprove();282+ fireCreate(buildCreateOptions(), 101);283+284+ // Wait long enough for the request to park in unlockWaiters and for the285+ // SUMMON_AUTOFILL message to fire, then unlock.286+ await new Promise((r) => setTimeout(r, 50));287+ expect(tabsSendMessage).toHaveBeenCalledWith(288+ 1,289+ expect.objectContaining({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" })290+ );291+292+ // Unlocking fires onVaultUnlocked() via the messages handler. Tests can293+ // skip the message dispatcher and call unlock() directly — the messages294+ // handler's onVaultUnlocked() call is what production hits, but the295+ // module-internal waiter notifier is what we need to fire here.296+ await vault.unlock("correct-pass-12345");297+ const passkey = await import("./passkey");298+ passkey.onVaultUnlocked();299+300+ const done = await waitForCreateComplete(101);301+ expect(done.error).toBeUndefined();302+ expect(done.responseJson).toBeDefined();303+ });304+305+ 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();310+311+ fireCreate(buildCreateOptions(), 102);312+313+ // Allow the request to park.314+ await vi.advanceTimersByTimeAsync(10);315+ // Jump past APPROVAL_TIMEOUT_MS (60s).316+ await vi.advanceTimersByTimeAsync(60_500);317+ vi.useRealTimers();318+319+ const done = await waitForCreateComplete(102);320+ expect(done.error).toMatchObject({ name: "NotAllowedError" });321+ expect(done.responseJson).toBeUndefined();322+ });323+324+ // ─────────────────────────────────────────────────────────────────────325+ // clientDataJSON.origin — page origin via active tab, not synthesized326+ // from rpId. (bug #1)327+ // ─────────────────────────────────────────────────────────────────────328+329+ 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");332+333+ activeTabs = [{ id: 1, url: "https://myaccount.google.com/security/passkeys" }];334+335+ autoApprove();336+ fireCreate(buildCreateOptions({ rpId: "google.com" }), 110);337+ const done = await waitForCreateComplete(110);338+339+ 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+ });344+345+ 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");348+349+ activeTabs = [{ id: 1, url: "https://github.com/settings/security" }];350+351+ autoApprove();352+ fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 111);353+ const done = await waitForCreateComplete(111);354+355+ const response = JSON.parse(done.responseJson!);356+ const clientData = decodeClientData(response.response.clientDataJSON);357+ expect(clientData.origin).toBe("https://github.com");358+ });359+360+ 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");363+364+ // User switched tabs to evil.example before we resolved — refuse to365+ // sign for that origin and fall back instead of leaking a signature366+ // for a hostile origin.367+ activeTabs = [{ id: 1, url: "https://evil.example/passkey" }];368+369+ autoApprove();370+ fireCreate(buildCreateOptions({ rpId: "google.com" }), 112);371+ const done = await waitForCreateComplete(112);372+373+ const response = JSON.parse(done.responseJson!);374+ const clientData = decodeClientData(response.response.clientDataJSON);375+ expect(clientData.origin).toBe("https://google.com");376+ });377+378+ 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");381+382+ activeTabs = [{ id: 1 }]; // no url383+384+ autoApprove();385+ fireCreate(buildCreateOptions({ rpId: "google.com" }), 113);386+ const done = await waitForCreateComplete(113);387+388+ const response = JSON.parse(done.responseJson!);389+ const clientData = decodeClientData(response.response.clientDataJSON);390+ expect(clientData.origin).toBe("https://google.com");391+ });392+393+ // ─────────────────────────────────────────────────────────────────────394+ // Core create ceremony.395+ // ─────────────────────────────────────────────────────────────────────396+397+ it("create rejects with NotSupportedError when rp.id is missing", async () => {398+ const vault = await import("./vault");399+ await vault.createVault("correct-pass-12345");400+401+ 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+ });405+406+ 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");409+410+ 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+ });415+416+ 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");419+420+ autoDeny();421+ fireCreate(buildCreateOptions(), 122);422+ const done = await waitForCreateComplete(122);423+ expect(done.error).toMatchObject({ name: "NotAllowedError" });424+ });425+426+ 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);429+430+ const vault = await import("./vault");431+ await vault.createVault("correct-pass-12345");432+433+ fireCreate(buildCreateOptions(), 123);434+ const done = await waitForCreateComplete(123);435+ expect(done.error).toMatchObject({ name: "NotAllowedError" });436+ // Pro gate broadcasted to every tab via tabs.sendMessage with PASSKEY_PRO_GATE.437+ expect(tabsSendMessage).toHaveBeenCalledWith(438+ expect.any(Number),439+ expect.objectContaining({ type: "PASSKEY_PRO_GATE" })440+ );441+ });442+443+ 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");446+447+ autoApprove();448+ fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 124);449+ await waitForCreateComplete(124);450+451+ 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+ });457+458+ 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;469+470+ autoApprove();471+ fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 125);472+ await waitForCreateComplete(125);473+474+ 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+ });478+479+ // ─────────────────────────────────────────────────────────────────────480+ // Get ceremony.481+ // ─────────────────────────────────────────────────────────────────────482+483+ 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+ }514+515+ it("get returns NotAllowedError when no stored credential matches the rpId", async () => {516+ const vault = await import("./vault");517+ await vault.createVault("correct-pass-12345");518+519+ fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 200);520+ const done = await waitForGetComplete(200);521+ expect(done.error).toMatchObject({ name: "NotAllowedError" });522+ });523+524+ it("get encodes the active tab page origin into clientDataJSON (not https://<rpId>)", async () => {525+ const vault = await import("./vault");526+ await vault.createVault("correct-pass-12345");527+ await seedPasskey("google.com");528+529+ activeTabs = [{ id: 1, url: "https://accounts.google.com/login" }];530+531+ autoApprove();532+ fireGet({ rpId: "google.com", challenge: "Y2g", allowCredentials: [] }, 201);533+ const done = await waitForGetComplete(201);534+535+ const response = JSON.parse(done.responseJson!);536+ const clientData = decodeClientData(response.response.clientDataJSON);537+ expect(clientData.origin).toBe("https://accounts.google.com");538+ expect(clientData.type).toBe("webauthn.get");539+ });540+541+ it("get increments signCount and updates lastUsedAt on success", async () => {542+ const vault = await import("./vault");543+ await vault.createVault("correct-pass-12345");544+ const { entry } = await seedPasskey("github.com", { signCount: 7 });545+546+ autoApprove();547+ fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 202);548+ await waitForGetComplete(202);549+550+ const after = vault.getEntries().find((e) => e.id === entry.id);551+ expect(after!.passkey!.signCount).toBe(8);552+ expect(after!.passkey!.lastUsedAt).toBeDefined();553+ });554+555+ it("get rejects with NotAllowedError when the stored passkey has no private key (Firefox-created)", async () => {556+ const vault = await import("./vault");557+ await vault.createVault("correct-pass-12345");558+ const { base64urlEncode, generatePasskeyKeypair } = await import("./passkey-crypto");559+ const generated = await generatePasskeyKeypair(-7);560+ await vault.addEntry({561+ type: "login",562+ title: "Cross-browser passkey",563+ passkey: {564+ rpId: "webauthn.io",565+ rpName: "WebAuthn.io",566+ credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),567+ publicKeyCose: base64urlEncode(generated.publicKeyCose),568+ // privateKeyJwk intentionally absent — pre-v2.12 Firefox passkey569+ // delegated signing to the platform authenticator.570+ userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),571+ userName: "june",572+ userDisplayName: "June",573+ algorithm: -7,574+ signCount: 0,575+ aaguid: "AAAAAAAAAAAAAAAAAAAAAA",576+ backupEligible: false,577+ backupState: false,578+ browser: "firefox",579+ transports: ["internal"],580+ createdAt: Date.now(),581+ },582+ });583+584+ fireGet({ rpId: "webauthn.io", challenge: "Y2g", allowCredentials: [] }, 203);585+ const done = await waitForGetComplete(203);586+ expect(done.error).toMatchObject({ name: "NotAllowedError" });587+ });588+589+ it("get on a locked vault parks the request and proceeds after unlock", async () => {590+ const vault = await import("./vault");591+ await vault.createVault("correct-pass-12345");592+ await seedPasskey("github.com");593+ vault.lock();594+595+ autoApprove();596+ fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 204);597+598+ await new Promise((r) => setTimeout(r, 50));599+ expect(tabsSendMessage).toHaveBeenCalledWith(600+ 1,601+ expect.objectContaining({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" })602+ );603+604+ await vault.unlock("correct-pass-12345");605+ const passkey = await import("./passkey");606+ passkey.onVaultUnlocked();607+608+ const done = await waitForGetComplete(204);609+ expect(done.error).toBeUndefined();610+ expect(done.responseJson).toBeDefined();611+ });612+613+ // ─────────────────────────────────────────────────────────────────────614+ // onRequestCanceled — popup-closed / RP-cancelled cleanup.615+ // ─────────────────────────────────────────────────────────────────────616+617+ it("onRequestCanceled resolves a still-pending approval as denied", async () => {618+ const vault = await import("./vault");619+ await vault.createVault("correct-pass-12345");620+621+ fireCreate(buildCreateOptions(), 301);622+623+ // Wait for the approval to be registered.624+ const passkey = await import("./passkey");625+ let pending: Awaited<ReturnType<typeof passkey.getPendingPasskeyRequest>> = null;626+ for (let i = 0; i < 50; i += 1) {627+ pending = await passkey.getPendingPasskeyRequest();628+ if (pending) break;629+ await new Promise((r) => setTimeout(r, 10));630+ }631+ expect(pending).toBeTruthy();632+633+ proxyListeners.cancel!(301);634+635+ // After cancel the pending session record should be cleared.636+ for (let i = 0; i < 50; i += 1) {637+ const after = await passkey.getPendingPasskeyRequest();638+ if (!after) return;639+ await new Promise((r) => setTimeout(r, 10));640+ }641+ throw new Error("pending passkey request was not cleared after onRequestCanceled");642+ });643+});src/shared/browser.test.ts+152 −0
@@ -0,0 +1,152 @@1+/**2+ * browser.test.ts — locks down feature-detected browser identification.3+ *4+ * Why this matters for passkeys: detectBrowser() routes the entire passkey5+ * code path. "chrome" picks chrome.webAuthenticationProxy. "firefox" picks6+ * the content-script proxy. Both decisions must work for every Chromium7+ * derivative (Brave, Edge, Vivaldi, Opera, Arc) and every Firefox derivative8+ * (LibreWolf, Floorp, Waterfox) because their UAs differ but their feature9+ * surfaces match the upstream browser.10+ */11+12+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";13+14+import { detectBrowser, firefoxVersion } from "./browser";15+16+type ChromeShape = Partial<{17+ webAuthenticationProxy: object | undefined;18+ runtime: { getURL?: (path: string) => string };19+}>;20+21+function installChrome(shape: ChromeShape | undefined) {22+ if (shape === undefined) {23+ (globalThis as { chrome?: unknown }).chrome = undefined;24+ return;25+ }26+ (globalThis as { chrome?: unknown }).chrome = shape;27+}28+29+function installUserAgent(ua: string) {30+ vi.stubGlobal("navigator", { userAgent: ua });31+}32+33+describe("detectBrowser", () => {34+ afterEach(() => {35+ vi.unstubAllGlobals();36+ (globalThis as { chrome?: unknown }).chrome = undefined;37+ });38+39+ it("returns 'chrome' when chrome.webAuthenticationProxy is present", () => {40+ installChrome({ webAuthenticationProxy: {}, runtime: { getURL: () => "chrome-extension://abc/" } });41+ expect(detectBrowser()).toBe("chrome");42+ });43+44+ it("treats Brave (Chromium derivative) as 'chrome' — UA-independent", () => {45+ installChrome({ webAuthenticationProxy: {}, runtime: { getURL: () => "chrome-extension://abc/" } });46+ installUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Brave/1.62 Chrome/122.0.0.0 Safari/537.36");47+ expect(detectBrowser()).toBe("chrome");48+ });49+50+ it("treats Edge (Chromium derivative) as 'chrome'", () => {51+ installChrome({ webAuthenticationProxy: {}, runtime: { getURL: () => "chrome-extension://abc/" } });52+ installUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0");53+ expect(detectBrowser()).toBe("chrome");54+ });55+56+ it("treats Vivaldi (Chromium derivative) as 'chrome'", () => {57+ installChrome({ webAuthenticationProxy: {}, runtime: { getURL: () => "chrome-extension://abc/" } });58+ installUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Vivaldi/6.5.3206.55");59+ expect(detectBrowser()).toBe("chrome");60+ });61+62+ it("treats Opera (Chromium derivative) as 'chrome'", () => {63+ installChrome({ webAuthenticationProxy: {}, runtime: { getURL: () => "chrome-extension://abc/" } });64+ installUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 OPR/108.0.0.0");65+ expect(detectBrowser()).toBe("chrome");66+ });67+68+ it("treats Arc (Chromium derivative) as 'chrome'", () => {69+ installChrome({ webAuthenticationProxy: {}, runtime: { getURL: () => "chrome-extension://abc/" } });70+ installUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Arc/1.30");71+ expect(detectBrowser()).toBe("chrome");72+ });73+74+ it("returns 'firefox' when the extension URL scheme is moz-extension://", () => {75+ installChrome({ runtime: { getURL: () => "moz-extension://abc/" } });76+ expect(detectBrowser()).toBe("firefox");77+ });78+79+ it("treats LibreWolf (Firefox fork) as 'firefox' — UA-independent", () => {80+ installChrome({ runtime: { getURL: () => "moz-extension://abc/" } });81+ // LibreWolf preserves Firefox/N in its UA for site compatibility.82+ installUserAgent("Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0 LibreWolf/124.0");83+ expect(detectBrowser()).toBe("firefox");84+ });85+86+ it("treats Floorp (Firefox fork) as 'firefox'", () => {87+ installChrome({ runtime: { getURL: () => "moz-extension://abc/" } });88+ installUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0 Floorp/11.18");89+ expect(detectBrowser()).toBe("firefox");90+ });91+92+ it("returns 'unknown' when neither webAuthenticationProxy nor moz-extension is present", () => {93+ installChrome({ runtime: { getURL: () => "safari-web-extension://abc/" } });94+ expect(detectBrowser()).toBe("unknown");95+ });96+97+ it("returns 'unknown' when chrome global is missing entirely (Safari, in-page contexts)", () => {98+ installChrome(undefined);99+ expect(detectBrowser()).toBe("unknown");100+ });101+102+ it("does not throw if runtime.getURL is missing or throws", () => {103+ installChrome({ runtime: {} });104+ expect(detectBrowser()).toBe("unknown");105+106+ installChrome({107+ runtime: {108+ getURL: () => {109+ throw new Error("not available");110+ },111+ },112+ });113+ expect(detectBrowser()).toBe("unknown");114+ });115+});116+117+describe("firefoxVersion", () => {118+ afterEach(() => {119+ vi.unstubAllGlobals();120+ (globalThis as { chrome?: unknown }).chrome = undefined;121+ });122+123+ it("returns NaN when detectBrowser is not firefox", () => {124+ installChrome({ webAuthenticationProxy: {}, runtime: { getURL: () => "chrome-extension://abc/" } });125+ installUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36");126+ expect(firefoxVersion()).toBeNaN();127+ });128+129+ it("parses Firefox/N from a stock Firefox UA", () => {130+ installChrome({ runtime: { getURL: () => "moz-extension://abc/" } });131+ installUserAgent("Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0");132+ expect(firefoxVersion()).toBe(150);133+ });134+135+ it("parses Firefox/N from a LibreWolf UA (preserves Firefox token)", () => {136+ installChrome({ runtime: { getURL: () => "moz-extension://abc/" } });137+ installUserAgent("Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0 LibreWolf/124.0");138+ expect(firefoxVersion()).toBe(124);139+ });140+141+ it("parses Firefox/N from a Floorp UA", () => {142+ installChrome({ runtime: { getURL: () => "moz-extension://abc/" } });143+ installUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0 Floorp/11.18");144+ expect(firefoxVersion()).toBe(124);145+ });146+147+ it("returns NaN when Firefox/N cannot be parsed from a UA-spoofed Firefox", () => {148+ installChrome({ runtime: { getURL: () => "moz-extension://abc/" } });149+ installUserAgent("Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 CustomBrowser/1.0");150+ expect(firefoxVersion()).toBeNaN();151+ });152+});