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
5 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 −0
manifest.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": {

view file

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",

view file

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": {

view file

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+});

view file

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+});

view file