passkey-firefox-proxy.test.ts

13.5 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  async function dispatch(message: Message): Promise<MessageResponse> {118    return await new Promise<MessageResponse>((resolve) => {119      const open = listener(message, { id: "peach-extension" }, (r) => resolve(r));120      expect(open).toBe(true);121    });122  }123124  // Auto-approve the next ceremony by resolving the pending request as125  // approved as soon as the badge is set. Avoids depending on the popup.126  async function autoApprove() {127    const passkey = await import("./passkey");128    const wait = async () => {129      for (let i = 0; i < 50; i += 1) {130        const pending = await passkey.getPendingPasskeyRequest();131        if (pending) {132          await passkey.resolvePendingPasskeyRequest(pending.requestId, true);133          return;134        }135        await new Promise((r) => setTimeout(r, 10));136      }137    };138    void wait();139  }140141  it("PASSKEY_HAS_CREDENTIALS returns false when vault has no passkey for rpId", async () => {142    const vault = await import("./vault");143    await vault.createVault("correct-pass-12345");144145    await expect(146      dispatch({147        type: "PASSKEY_HAS_CREDENTIALS",148        payload: { rpId: "github.com", allowCredentials: [] },149      })150    ).resolves.toMatchObject({ ok: true, hasCredentials: false });151  });152153  it("PASSKEY_HAS_CREDENTIALS returns true when allowCredentials is empty and rpId matches", async () => {154    const vault = await import("./vault");155    await vault.createVault("correct-pass-12345");156    const generated = await generatePasskeyKeypair(-7);157    await vault.addEntry({158      type: "login",159      title: "GitHub",160      passkey: {161        rpId: "github.com",162        rpName: "GitHub",163        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),164        publicKeyCose: base64urlEncode(generated.publicKeyCose),165        privateKeyJwk: generated.privateKeyJwk,166        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),167        userName: "june",168        userDisplayName: "June",169        algorithm: -7,170        signCount: 0,171        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",172        backupEligible: true,173        backupState: false,174        createdAt: Date.now(),175      },176    });177178    await expect(179      dispatch({180        type: "PASSKEY_HAS_CREDENTIALS",181        payload: { rpId: "github.com", allowCredentials: [] },182      })183    ).resolves.toMatchObject({ ok: true, hasCredentials: true });184  });185186  it("PASSKEY_HAS_CREDENTIALS matches by credentialId in allowCredentials", async () => {187    const vault = await import("./vault");188    await vault.createVault("correct-pass-12345");189    const generated = await generatePasskeyKeypair(-7);190    const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));191    await vault.addEntry({192      type: "login",193      title: "GitHub",194      passkey: {195        rpId: "github.com",196        rpName: "GitHub",197        credentialId,198        publicKeyCose: base64urlEncode(generated.publicKeyCose),199        privateKeyJwk: generated.privateKeyJwk,200        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),201        userName: "june",202        userDisplayName: "June",203        algorithm: -7,204        signCount: 0,205        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",206        backupEligible: true,207        backupState: false,208        createdAt: Date.now(),209      },210    });211212    await expect(213      dispatch({214        type: "PASSKEY_HAS_CREDENTIALS",215        payload: {216          rpId: "github.com",217          allowCredentials: [{ id: "different-id", type: "public-key" }],218        },219      })220    ).resolves.toMatchObject({ ok: true, hasCredentials: false });221222    await expect(223      dispatch({224        type: "PASSKEY_HAS_CREDENTIALS",225        payload: {226          rpId: "github.com",227          allowCredentials: [{ id: credentialId, type: "public-key" }],228        },229      })230    ).resolves.toMatchObject({ ok: true, hasCredentials: true });231  });232233  it("PASSKEY_HAS_CREDENTIALS returns hasCredentials:false when vault is locked", async () => {234    const vault = await import("./vault");235    await vault.createVault("correct-pass-12345");236    vault.lock();237238    await expect(239      dispatch({240        type: "PASSKEY_HAS_CREDENTIALS",241        payload: { rpId: "github.com", allowCredentials: [] },242      })243    ).resolves.toMatchObject({ ok: true, hasCredentials: false });244  });245246  it("PASSKEY_CREATE rejects a cross-origin rpId without minting a credential (SEV-1-A)", async () => {247    const vault = await import("./vault");248    await vault.createVault("correct-pass-12345");249250    autoApprove();251    const res = await dispatch({252      type: "PASSKEY_CREATE",253      payload: {254        // evil.com requests a credential scoped to google.com — must be refused.255        rpId: "google.com",256        rpName: "Google",257        userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),258        userName: "june",259        userDisplayName: "June",260        challenge: "x",261        clientDataJSON: Array.from(new TextEncoder().encode("{}")),262        origin: "https://evil.com",263        pubKeyCredParams: [{ alg: -7, type: "public-key" }],264      },265    });266267    expect(res.ok).toBe(false);268    expect(String(res.error)).toContain("SecurityError");269    // Crucially, nothing was written to the vault.270    expect(vault.getEntries().find((e) => e.passkey?.rpId === "google.com")).toBeUndefined();271  });272273  it("PASSKEY_GET rejects a cross-origin rpId (SEV-1-A)", async () => {274    const vault = await import("./vault");275    await vault.createVault("correct-pass-12345");276277    const res = await dispatch({278      type: "PASSKEY_GET",279      payload: {280        rpId: "google.com",281        challenge: "x",282        clientDataJSON: Array.from(new TextEncoder().encode("{}")),283        origin: "https://evil.com",284        allowCredentials: [],285      },286    });287288    expect(res.ok).toBe(false);289    expect(String(res.error)).toContain("SecurityError");290  });291292  it("handleFirefoxPasskeyCreate throws a SecurityError directly on rpId mismatch", async () => {293    const { handleFirefoxPasskeyCreate } = await import("./passkey");294    await expect(295      handleFirefoxPasskeyCreate({296        rpId: "google.com",297        rpName: "Google",298        userId: "dGVzdA",299        userName: "june",300        challenge: "x",301        clientDataJSON: [],302        origin: "https://evil.com",303        pubKeyCredParams: [{ alg: -7, type: "public-key" }],304      })305    ).rejects.toThrow("SecurityError");306  });307308  it("PASSKEY_GET returns no_credentials when nothing matches", async () => {309    const vault = await import("./vault");310    await vault.createVault("correct-pass-12345");311312    const clientDataJSON = Array.from(new TextEncoder().encode(313      JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false })314    ));315316    await expect(317      dispatch({318        type: "PASSKEY_GET",319        payload: {320          rpId: "github.com",321          challenge: "x",322          clientDataJSON,323          origin: "https://github.com",324          allowCredentials: [],325        },326      })327    ).resolves.toMatchObject({ ok: false, error: "no_credentials" });328  });329330  it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => {331    const vault = await import("./vault");332    await vault.createVault("correct-pass-12345");333334    const challenge = "challenge-base64url";335    const origin = "https://github.com";336    const clientDataCreate = Array.from(new TextEncoder().encode(337      JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false })338    ));339340    autoApprove();341    const createRes = await dispatch({342      type: "PASSKEY_CREATE",343      payload: {344        rpId: "github.com",345        rpName: "GitHub",346        userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),347        userName: "june",348        userDisplayName: "June",349        challenge,350        clientDataJSON: clientDataCreate,351        origin,352        pubKeyCredParams: [{ alg: -7, type: "public-key" }],353      },354    });355356    expect(createRes).toMatchObject({ ok: true });357    const cred = (createRes as any).credential;358    expect(cred.type).toBe("create");359    expect(typeof cred.id).toBe("string");360    expect(cred.algorithm).toBe(-7);361362    const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");363    expect(stored).toBeDefined();364    expect(stored!.passkey!.signCount).toBe(0);365    expect(stored!.passkey!.browser).toBe("firefox");366367    const clientDataGet = Array.from(new TextEncoder().encode(368      JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })369    ));370371    autoApprove();372    const getRes = await dispatch({373      type: "PASSKEY_GET",374      payload: {375        rpId: "github.com",376        challenge,377        clientDataJSON: clientDataGet,378        origin,379        allowCredentials: [{ id: cred.id, type: "public-key" }],380      },381    });382383    expect(getRes).toMatchObject({ ok: true });384    const assertion = (getRes as any).credential;385    expect(assertion.type).toBe("get");386    expect(typeof assertion.signature).toBe("string");387    expect(typeof assertion.authenticatorData).toBe("string");388389    const after = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");390    expect(after!.passkey!.signCount).toBe(1);391    expect(after!.passkey!.lastUsedAt).toBeDefined();392  });393});