passkey-firefox-proxy.test.ts

26.6 KB · raw

1/**2 * passkey-firefox-proxy.test.ts — covers the v2.12.0 Firefox content-script3 * proxy path. We mock isProUser (license verification needs real Ed255194 * keys we don't ship in tests) and exercise the three handlers via the5 * messages dispatcher.6 */78import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";910import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";11import type { Message, MessageResponse } from "../shared/types";1213vi.mock("./license", async (importOriginal) => {14  const actual = await importOriginal<typeof import("./license")>();15  return {16    ...actual,17    isProUser: vi.fn(async () => true),18  };19});2021// FIREFOX_USE_CONTENT_SCRIPT_PROXY is computed at module init time from the22// presence of chrome.webAuthenticationProxy + the moz-extension:// origin.23// The test harness installs a chrome mock without webAuthenticationProxy and24// stubs runtime.getURL to return a moz-extension URL — so the flag evaluates25// to true under the same conditions Firefox would see in the wild.26vi.mock("../shared/passkey-flags", () => ({27  FIREFOX_USE_CONTENT_SCRIPT_PROXY: true,28}));2930type StorageShape = Record<string, unknown>;31type MessageListener = (32  msg: unknown,33  sender: chrome.runtime.MessageSender,34  sendResponse: (response: MessageResponse) => void35) => boolean;3637describe("firefox passkey proxy handlers", () => {38  let storage: StorageShape;39  let sessionStorage: StorageShape;40  let listener: MessageListener;4142  beforeEach(async () => {43    vi.resetModules();44    vi.useRealTimers();45    storage = { peach_settings: { breachMonitoring: false } };46    sessionStorage = {};4748    const tabs = [{ id: 1 }];49    const chromeMock = {50      runtime: {51        id: "peach-extension",52        getURL: vi.fn((_path: string) => "moz-extension://abcd/"),53        onMessage: {54          addListener: vi.fn((fn: MessageListener) => {55            listener = fn;56          }),57        },58        sendMessage: vi.fn(async () => ({ ok: true })),59      },60      tabs: {61        query: vi.fn((_q: unknown, callback?: (t: Array<{ id?: number }>) => void) => {62          if (typeof callback === "function") { callback(tabs); return; }63          return Promise.resolve(tabs);64        }),65        sendMessage: vi.fn(async () => ({ ok: true })),66      },67      storage: {68        local: {69          get: vi.fn(async (key?: string | string[]) => {70            if (!key) return storage;71            if (typeof key === "string") return { [key]: storage[key] };72            return key.reduce<Record<string, unknown>>((r, k) => { r[k] = storage[k]; return r; }, {});73          }),74          set: vi.fn(async (next: Record<string, unknown>) => Object.assign(storage, next)),75          remove: vi.fn(async (keys: string | string[]) => {76            const list = Array.isArray(keys) ? keys : [keys];77            for (const k of list) delete storage[k];78          }),79        },80        session: {81          get: vi.fn(async (key?: string | string[]) => {82            if (!key) return sessionStorage;83            if (typeof key === "string") return { [key]: sessionStorage[key] };84            return key.reduce<Record<string, unknown>>((r, k) => { r[k] = sessionStorage[k]; return r; }, {});85          }),86          set: vi.fn(async (next: Record<string, unknown>) => Object.assign(sessionStorage, next)),87          remove: vi.fn(async (keys: string | string[]) => {88            const list = Array.isArray(keys) ? keys : [keys];89            for (const k of list) delete sessionStorage[k];90          }),91          clear: vi.fn(async () => { sessionStorage = {}; }),92        },93      },94      action: {95        setBadgeText: vi.fn(async () => {}),96        setBadgeBackgroundColor: vi.fn(async () => {}),97        openPopup: vi.fn(async () => {}),98      },99      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },100      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },101      scripting: { executeScript: vi.fn(async () => []) },102    } as unknown as typeof chrome;103104    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;105    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);106107    const messages = await import("./messages");108    messages.setupMessageHandler();109    const vault = await import("./vault");110    await vault.ensureSettingsLoaded();111  });112113  afterEach(() => {114    vi.unstubAllGlobals();115  });116117  // senderUrl models the browser-attested page origin the relay's message118  // carries. Defaults to github.com so the existing github.com ceremonies pass119  // their rpId<->origin check; cross-origin tests pass an attacker URL.120  async function dispatch(message: Message, senderUrl = "https://github.com/login"): Promise<MessageResponse> {121    return await new Promise<MessageResponse>((resolve) => {122      const open = listener(message, { id: "peach-extension", url: senderUrl, origin: new URL(senderUrl).origin }, (r) => resolve(r));123      expect(open).toBe(true);124    });125  }126127  // Auto-approve the next ceremony by resolving the pending request as128  // approved as soon as the badge is set. Avoids depending on the popup.129  async function autoApprove() {130    const passkey = await import("./passkey");131    const wait = async () => {132      for (let i = 0; i < 50; i += 1) {133        const pending = await passkey.getPendingPasskeyRequest();134        if (pending) {135          await passkey.resolvePendingPasskeyRequest(pending.requestId, true);136          return;137        }138        await new Promise((r) => setTimeout(r, 10));139      }140    };141    void wait();142  }143144  it.each([[undefined, true], [true, true], [false, false]] as const)("defaults passkey interception on while preserving %s", async (preference, enabled) => {145    storage.peach_settings = { breachMonitoring: false, ...(preference === undefined ? {} : { passkeyProviderEnabled: preference }) };146    vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0 Firefox/157.0" });147    const passkey = await import("./passkey");148    await passkey.setupPasskeyProxy();149    const state = await passkey.getPasskeyProviderState();150    expect(state).toMatchObject({ supported: true, enabled, attached: enabled });151  });152153  it.each(["PASSKEY_HAS_CREDENTIALS", "PASSKEY_GET", "PASSKEY_CREATE"] as const)("honors explicit disabled interception for %s", async (type) => {154    const vault = await import("./vault");155    await vault.createVault("correct-pass-12345");156    await vault.updateSettings({ passkeyProviderEnabled: false });157    await expect(dispatch({158      type,159      payload: { rpId: "github.com", userName: "june", userId: "AQID", challenge: "x", origin: "https://github.com", clientDataJSON: Array.from(new TextEncoder().encode("{}")), allowCredentials: [] },160    })).resolves.toMatchObject({ ok: false, error: "not_applicable" });161    expect(chrome.action.openPopup).not.toHaveBeenCalled();162  });163164  it("PASSKEY_HAS_CREDENTIALS returns false when vault has no passkey for rpId", async () => {165    const vault = await import("./vault");166    await vault.createVault("correct-pass-12345");167168    await expect(169      dispatch({170        type: "PASSKEY_HAS_CREDENTIALS",171        payload: { rpId: "github.com", allowCredentials: [] },172      })173    ).resolves.toMatchObject({ ok: true, hasCredentials: false });174  });175176  it("PASSKEY_HAS_CREDENTIALS returns true when allowCredentials is empty and rpId matches", async () => {177    const vault = await import("./vault");178    await vault.createVault("correct-pass-12345");179    const generated = await generatePasskeyKeypair(-7);180    await vault.addEntry({181      type: "login",182      title: "GitHub",183      passkey: {184        rpId: "github.com",185        rpName: "GitHub",186        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),187        publicKeyCose: base64urlEncode(generated.publicKeyCose),188        privateKeyJwk: generated.privateKeyJwk,189        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),190        userName: "june",191        userDisplayName: "June",192        algorithm: -7,193        signCount: 0,194        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",195        backupEligible: true,196        backupState: false,197        createdAt: Date.now(),198      },199    });200201    await expect(202      dispatch({203        type: "PASSKEY_HAS_CREDENTIALS",204        payload: { rpId: "github.com", allowCredentials: [] },205      })206    ).resolves.toMatchObject({ ok: true, hasCredentials: true });207  });208209  it("PASSKEY_HAS_CREDENTIALS matches by credentialId in allowCredentials", async () => {210    const vault = await import("./vault");211    await vault.createVault("correct-pass-12345");212    const generated = await generatePasskeyKeypair(-7);213    const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));214    await vault.addEntry({215      type: "login",216      title: "GitHub",217      passkey: {218        rpId: "github.com",219        rpName: "GitHub",220        credentialId,221        publicKeyCose: base64urlEncode(generated.publicKeyCose),222        privateKeyJwk: generated.privateKeyJwk,223        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),224        userName: "june",225        userDisplayName: "June",226        algorithm: -7,227        signCount: 0,228        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",229        backupEligible: true,230        backupState: false,231        createdAt: Date.now(),232      },233    });234235    await expect(236      dispatch({237        type: "PASSKEY_HAS_CREDENTIALS",238        payload: {239          rpId: "github.com",240          allowCredentials: [{ id: "different-id", type: "public-key" }],241        },242      })243    ).resolves.toMatchObject({ ok: true, hasCredentials: false });244245    await expect(246      dispatch({247        type: "PASSKEY_HAS_CREDENTIALS",248        payload: {249          rpId: "github.com",250          allowCredentials: [{ id: credentialId, type: "public-key" }],251        },252      })253    ).resolves.toMatchObject({ ok: true, hasCredentials: true });254  });255256  it("PASSKEY_HAS_CREDENTIALS reports a locked vault without enumerating credentials", async () => {257    const vault = await import("./vault");258    await vault.createVault("correct-pass-12345");259    vault.lock();260261    await expect(262      dispatch({263        type: "PASSKEY_HAS_CREDENTIALS",264        payload: { rpId: "github.com", allowCredentials: [] },265      })266    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });267  });268269  it("PASSKEY_CREATE rejects a cross-origin rpId without minting a credential (SEV-1-A)", async () => {270    const vault = await import("./vault");271    await vault.createVault("correct-pass-12345");272273    autoApprove();274    // evil.com (attested sender) requests a credential scoped to google.com.275    const res = await dispatch({276      type: "PASSKEY_CREATE",277      payload: {278        rpId: "google.com",279        rpName: "Google",280        userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),281        userName: "june",282        userDisplayName: "June",283        challenge: "x",284        clientDataJSON: Array.from(new TextEncoder().encode("{}")),285        origin: "https://evil.com",286        pubKeyCredParams: [{ alg: -7, type: "public-key" }],287      },288    }, "https://evil.com/attack");289290    expect(res.ok).toBe(false);291    expect(String(res.error)).toContain("SecurityError");292    // Crucially, nothing was written to the vault.293    expect(vault.getEntries().find((e) => e.passkey?.rpId === "google.com")).toBeUndefined();294  });295296  it("PASSKEY_GET rejects a cross-origin rpId (SEV-1-A)", async () => {297    const vault = await import("./vault");298    await vault.createVault("correct-pass-12345");299300    const res = await dispatch({301      type: "PASSKEY_GET",302      payload: {303        rpId: "google.com",304        challenge: "x",305        clientDataJSON: Array.from(new TextEncoder().encode("{}")),306        origin: "https://evil.com",307        allowCredentials: [],308      },309    }, "https://evil.com/attack");310311    expect(res.ok).toBe(false);312    expect(String(res.error)).toContain("SecurityError");313  });314315  it("PASSKEY_GET refuses a FORGED payload.origin that matches rpId when the attested sender origin differs (audit CRITICAL — origin spoofing)", async () => {316    const vault = await import("./vault");317    await vault.createVault("correct-pass-12345");318319    // The attacker self-consistently forges rpId + payload.origin + clientDataJSON320    // origin all to victimbank.com — exactly the bypass that defeated the old321    // isValidRpId(payload.rpId, payload.origin) check. The real frame is evil.com.322    const forgedClientData = Array.from(new TextEncoder().encode(323      JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://victimbank.com", crossOrigin: false }),324    ));325    const res = await dispatch({326      type: "PASSKEY_GET",327      payload: {328        rpId: "victimbank.com",329        origin: "https://victimbank.com",330        challenge: "x",331        clientDataJSON: forgedClientData,332        allowCredentials: [],333      },334    }, "https://evil.com/attack");335336    expect(res.ok).toBe(false);337    expect(String(res.error)).toContain("SecurityError");338  });339340  it("PASSKEY_HAS_CREDENTIALS is scoped to the caller origin (no cross-site enumeration)", async () => {341    const vault = await import("./vault");342    await vault.createVault("correct-pass-12345");343    await vault.addEntry({344      type: "login", title: "GitHub", url: "https://github.com", username: "june",345      passkey: {346        rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",347        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,348        userHandle: "u", userName: "june", userDisplayName: "June", algorithm: -7,349        signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,350      },351    });352353    // A page on evil.com may not learn the user holds a github.com passkey.354    await expect(dispatch({355      type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] },356    }, "https://evil.com/")).resolves.toMatchObject({ ok: true, hasCredentials: false });357358    // github.com itself sees its own credential.359    await expect(dispatch({360      type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] },361    }, "https://github.com/")).resolves.toMatchObject({ ok: true, hasCredentials: true });362  });363364  it("GET_PASSKEY_FOR_DOMAIN is scoped to the caller origin (no username leak cross-site)", async () => {365    const vault = await import("./vault");366    await vault.createVault("correct-pass-12345");367    await vault.addEntry({368      type: "login", title: "GitHub", url: "https://github.com", username: "june",369      passkey: {370        rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",371        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,372        userHandle: "u", userName: "[email protected]", userDisplayName: "June", algorithm: -7,373        signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,374      },375    });376377    await expect(dispatch({378      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },379    }, "https://evil.com/")).resolves.toMatchObject({ ok: true, data: null });380381    const own = await dispatch({382      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },383    }, "https://github.com/");384    expect((own.data as { userName?: string } | null)?.userName).toBe("[email protected]");385  });386387  it("MARK_PASSKEY_BANNER_SHOWN is origin-bound and surfaces via bannerShown (PB-26-203)", async () => {388    const vault = await import("./vault");389    await vault.createVault("correct-pass-12345");390    await vault.addEntry({391      type: "login", title: "GitHub", url: "https://github.com", username: "june",392      passkey: {393        rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",394        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,395        userHandle: "u", userName: "[email protected]", userDisplayName: "June", algorithm: -7,396        signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,397      },398    });399400    // evil.com cannot mark github.com's flag: no write, no error leak.401    await expect(dispatch({402      type: "MARK_PASSKEY_BANNER_SHOWN", payload: { domain: "github.com" },403    }, "https://evil.com/")).resolves.toMatchObject({ ok: true });404    const before = await dispatch({405      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },406    }, "https://github.com/");407    expect((before.data as { bannerShown?: boolean } | null)?.bannerShown).toBe(false);408409    // github.com marks its own flag; the read path surfaces it.410    await expect(dispatch({411      type: "MARK_PASSKEY_BANNER_SHOWN", payload: { domain: "github.com" },412    }, "https://github.com/")).resolves.toMatchObject({ ok: true });413    const after = await dispatch({414      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },415    }, "https://github.com/");416    expect((after.data as { bannerShown?: boolean } | null)?.bannerShown).toBe(true);417  });418419  it("handleFirefoxPasskeyCreate throws a SecurityError directly on rpId mismatch", async () => {420    const { handleFirefoxPasskeyCreate } = await import("./passkey");421    await expect(422      handleFirefoxPasskeyCreate({423        rpId: "google.com",424        rpName: "Google",425        userId: "dGVzdA",426        userName: "june",427        challenge: "x",428        clientDataJSON: [],429        origin: "https://evil.com",430        pubKeyCredParams: [{ alg: -7, type: "public-key" }],431      })432    ).rejects.toThrow("SecurityError");433  });434435  it("PASSKEY_GET returns no_credentials when nothing matches", async () => {436    const vault = await import("./vault");437    await vault.createVault("correct-pass-12345");438439    const clientDataJSON = Array.from(new TextEncoder().encode(440      JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false })441    ));442443    await expect(444      dispatch({445        type: "PASSKEY_GET",446        payload: {447          rpId: "github.com",448          challenge: "x",449          clientDataJSON,450          origin: "https://github.com",451          allowCredentials: [],452        },453      })454    ).resolves.toMatchObject({ ok: false, error: "no_credentials" });455  });456457  it.each(["unlock", "dismiss", "timeout"] as const)("PASSKEY_GET waits for a locked vault: %s", async (outcome) => {458    const vault = await import("./vault");459    const passkey = await import("./passkey");460    await vault.createVault("correct-pass-12345");461    vault.lock();462    if (outcome === "timeout") vi.useFakeTimers();463    const resultPromise = dispatch({464      type: "PASSKEY_GET",465      payload: { rpId: "github.com", challenge: "x", origin: "https://github.com", clientDataJSON: Array.from(new TextEncoder().encode("{}")), allowCredentials: [] },466    });467    await vi.waitFor(() => expect(chrome.action.openPopup).toHaveBeenCalled());468    let settled = false;469    void resultPromise.then(() => { settled = true; });470    await Promise.resolve();471    expect(settled).toBe(false);472    if (outcome === "unlock") {473      await vault.unlock("correct-pass-12345");474      passkey.onVaultUnlocked();475      await expect(resultPromise).resolves.toMatchObject({ ok: false, error: "no_credentials" });476    } else if (outcome === "dismiss") {477      await passkey.denyAllPendingPasskeyRequests();478      await expect(resultPromise).resolves.toMatchObject({ ok: false, error: "user_cancelled" });479    } else {480      await vi.advanceTimersByTimeAsync(60_000);481      await expect(resultPromise).resolves.toMatchObject({ ok: false, error: "user_cancelled" });482      vi.useRealTimers();483    }484  });485486  it.each([["chrome", true, false], ["firefox", false, false], ["chrome", true, true]] as const)("PASSKEY_GET preserves legacy %s backup eligibility %s (locked: %s)", async (browser, expectedBackupEligible, locked) => {487    const vault = await import("./vault");488    await vault.createVault("correct-pass-12345");489    const generated = await generatePasskeyKeypair(-7);490    const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));491    const entry = await vault.addEntry({492      type: "login",493      title: "GitHub",494      passkey: {495        rpId: "github.com", rpName: "GitHub", credentialId,496        publicKeyCose: base64urlEncode(generated.publicKeyCose),497        privateKeyJwk: generated.privateKeyJwk,498        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),499        userName: "june", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "",500        backupEligible: false, backupState: false, createdAt: 1, browser,501      },502    });503    const challenge = "challenge-base64url";504    const origin = "https://github.com";505    const clientDataJSON = Array.from(new TextEncoder().encode(506      JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })507    ));508509    if (locked) vault.lock();510    const resultPromise = dispatch({511      type: "PASSKEY_GET",512      payload: { rpId: "github.com", challenge, origin, clientDataJSON, allowCredentials: [{ id: credentialId, type: "public-key" }] },513    });514    const passkey = await import("./passkey");515    if (locked) {516      await vi.waitFor(() => expect(chrome.action.openPopup).toHaveBeenCalled());517      await vault.unlock("correct-pass-12345");518      passkey.onVaultUnlocked();519    }520    await vi.waitFor(async () => {521      expect(await passkey.getPendingPasskeyRequest()).not.toBeNull();522    });523    const pending = await passkey.getPendingPasskeyRequest();524    await passkey.resolvePendingPasskeyRequest(pending!.requestId, true);525    const result = await resultPromise;526    expect(result.ok).toBe(true);527    const assertion = (result as unknown as { credential: { authenticatorData: string } }).credential;528    const authenticatorData = Uint8Array.from(atob(assertion.authenticatorData.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0));529    expect(Boolean(authenticatorData[32]! & 0x08)).toBe(expectedBackupEligible);530    expect(vault.getEntries().find((candidate) => candidate.id === entry.id)!.passkey!.backupEligible).toBe(expectedBackupEligible);531  });532533  it("finishes saving a Firefox assertion before approval lets the popup close and lock", async () => {534    const vault = await import("./vault");535    const passkey = await import("./passkey");536    await vault.createVault("correct-pass-12345");537    autoApprove();538    const created = await dispatch({539      type: "PASSKEY_CREATE",540      payload: { rpId: "github.com", userId: "AQID", userName: "june", challenge: "x", origin: "https://github.com", clientDataJSON: Array.from(new TextEncoder().encode("{}")), pubKeyCredParams: [{ alg: -7, type: "public-key" }] },541    });542    expect(created.ok).toBe(true);543    const resultPromise = dispatch({544      type: "PASSKEY_GET",545      payload: { rpId: "github.com", challenge: "x", origin: "https://github.com", clientDataJSON: Array.from(new TextEncoder().encode("{}")), allowCredentials: [] },546    });547    await vi.waitFor(async () => expect(await passkey.getPendingPasskeyRequest()).not.toBeNull());548    const pending = await passkey.getPendingPasskeyRequest();549    await passkey.resolvePendingPasskeyRequest(pending!.requestId, true);550    // The popup closes as soon as APPROVE_PASSKEY returns, triggering lock-on-close.551    vault.lock();552    await expect(resultPromise).resolves.toMatchObject({ ok: true });553    await vault.unlock("correct-pass-12345");554    expect(vault.getEntries().find((entry) => entry.passkey)?.passkey?.signCount).toBe(1);555  });556557  it("accepts approval delivered while the popup is opening", async () => {558    const vault = await import("./vault");559    const passkey = await import("./passkey");560    await vault.createVault("correct-pass-12345");561    vi.mocked(chrome.action.openPopup).mockImplementationOnce(async () => {562      const pending = await passkey.getPendingPasskeyRequest();563      expect(pending).not.toBeNull();564      void passkey.resolvePendingPasskeyRequest(pending!.requestId, true);565    });566    const resultPromise = dispatch({567      type: "PASSKEY_CREATE",568      payload: { rpId: "github.com", userId: "AQID", userName: "june", challenge: "x", origin: "https://github.com", clientDataJSON: Array.from(new TextEncoder().encode("{}")), pubKeyCredParams: [{ alg: -7, type: "public-key" }] },569    });570    await vi.waitFor(() => expect(chrome.action.openPopup).toHaveBeenCalled());571    await new Promise((resolve) => setTimeout(resolve, 20));572    await passkey.denyAllPendingPasskeyRequests();573    await expect(resultPromise).resolves.toMatchObject({ ok: true });574  });575576  it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => {577    const vault = await import("./vault");578    await vault.createVault("correct-pass-12345");579580    const challenge = "challenge-base64url";581    const origin = "https://github.com";582    const clientDataCreate = Array.from(new TextEncoder().encode(583      JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false })584    ));585586    autoApprove();587    const createRes = await dispatch({588      type: "PASSKEY_CREATE",589      payload: {590        rpId: "github.com",591        rpName: "GitHub",592        userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),593        userName: "june",594        userDisplayName: "June",595        challenge,596        clientDataJSON: clientDataCreate,597        origin,598        pubKeyCredParams: [{ alg: -7, type: "public-key" }],599      },600    });601602    expect(createRes).toMatchObject({ ok: true });603    const cred = (createRes as any).credential;604    expect(cred.type).toBe("create");605    expect(typeof cred.id).toBe("string");606    expect(cred.algorithm).toBe(-7);607608    const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");609    expect(stored).toBeDefined();610    expect(stored!.passkey!.signCount).toBe(0);611    expect(stored!.passkey!.browser).toBe("firefox");612    expect(stored!.passkey!.backupEligible).toBe(true);613614    const clientDataGet = Array.from(new TextEncoder().encode(615      JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })616    ));617618    autoApprove();619    const getRes = await dispatch({620      type: "PASSKEY_GET",621      payload: {622        rpId: "github.com",623        challenge,624        clientDataJSON: clientDataGet,625        origin,626        allowCredentials: [{ id: cred.id, type: "public-key" }],627      },628    });629630    expect(getRes).toMatchObject({ ok: true });631    const assertion = (getRes as any).credential;632    expect(assertion.type).toBe("get");633    expect(typeof assertion.signature).toBe("string");634    expect(typeof assertion.authenticatorData).toBe("string");635    const authenticatorData = Uint8Array.from(atob(assertion.authenticatorData.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(assertion.authenticatorData.length / 4) * 4, "=")), (char) => char.charCodeAt(0));636    expect(authenticatorData[32]! & 0x08).toBe(0x08);637638    const after = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");639    expect(after!.passkey!.signCount).toBe(1);640    expect(after!.passkey!.lastUsedAt).toBeDefined();641  });642});