passkey-firefox-proxy.test.ts

17.2 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("PASSKEY_HAS_CREDENTIALS returns false when vault has no passkey for rpId", async () => {145    const vault = await import("./vault");146    await vault.createVault("correct-pass-12345");147148    await expect(149      dispatch({150        type: "PASSKEY_HAS_CREDENTIALS",151        payload: { rpId: "github.com", allowCredentials: [] },152      })153    ).resolves.toMatchObject({ ok: true, hasCredentials: false });154  });155156  it("PASSKEY_HAS_CREDENTIALS returns true when allowCredentials is empty and rpId matches", async () => {157    const vault = await import("./vault");158    await vault.createVault("correct-pass-12345");159    const generated = await generatePasskeyKeypair(-7);160    await vault.addEntry({161      type: "login",162      title: "GitHub",163      passkey: {164        rpId: "github.com",165        rpName: "GitHub",166        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),167        publicKeyCose: base64urlEncode(generated.publicKeyCose),168        privateKeyJwk: generated.privateKeyJwk,169        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),170        userName: "june",171        userDisplayName: "June",172        algorithm: -7,173        signCount: 0,174        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",175        backupEligible: true,176        backupState: false,177        createdAt: Date.now(),178      },179    });180181    await expect(182      dispatch({183        type: "PASSKEY_HAS_CREDENTIALS",184        payload: { rpId: "github.com", allowCredentials: [] },185      })186    ).resolves.toMatchObject({ ok: true, hasCredentials: true });187  });188189  it("PASSKEY_HAS_CREDENTIALS matches by credentialId in allowCredentials", async () => {190    const vault = await import("./vault");191    await vault.createVault("correct-pass-12345");192    const generated = await generatePasskeyKeypair(-7);193    const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));194    await vault.addEntry({195      type: "login",196      title: "GitHub",197      passkey: {198        rpId: "github.com",199        rpName: "GitHub",200        credentialId,201        publicKeyCose: base64urlEncode(generated.publicKeyCose),202        privateKeyJwk: generated.privateKeyJwk,203        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),204        userName: "june",205        userDisplayName: "June",206        algorithm: -7,207        signCount: 0,208        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",209        backupEligible: true,210        backupState: false,211        createdAt: Date.now(),212      },213    });214215    await expect(216      dispatch({217        type: "PASSKEY_HAS_CREDENTIALS",218        payload: {219          rpId: "github.com",220          allowCredentials: [{ id: "different-id", type: "public-key" }],221        },222      })223    ).resolves.toMatchObject({ ok: true, hasCredentials: false });224225    await expect(226      dispatch({227        type: "PASSKEY_HAS_CREDENTIALS",228        payload: {229          rpId: "github.com",230          allowCredentials: [{ id: credentialId, type: "public-key" }],231        },232      })233    ).resolves.toMatchObject({ ok: true, hasCredentials: true });234  });235236  it("PASSKEY_HAS_CREDENTIALS returns hasCredentials:false when vault is locked", async () => {237    const vault = await import("./vault");238    await vault.createVault("correct-pass-12345");239    vault.lock();240241    await expect(242      dispatch({243        type: "PASSKEY_HAS_CREDENTIALS",244        payload: { rpId: "github.com", allowCredentials: [] },245      })246    ).resolves.toMatchObject({ ok: true, hasCredentials: false });247  });248249  it("PASSKEY_CREATE rejects a cross-origin rpId without minting a credential (SEV-1-A)", async () => {250    const vault = await import("./vault");251    await vault.createVault("correct-pass-12345");252253    autoApprove();254    // evil.com (attested sender) requests a credential scoped to google.com.255    const res = await dispatch({256      type: "PASSKEY_CREATE",257      payload: {258        rpId: "google.com",259        rpName: "Google",260        userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),261        userName: "june",262        userDisplayName: "June",263        challenge: "x",264        clientDataJSON: Array.from(new TextEncoder().encode("{}")),265        origin: "https://evil.com",266        pubKeyCredParams: [{ alg: -7, type: "public-key" }],267      },268    }, "https://evil.com/attack");269270    expect(res.ok).toBe(false);271    expect(String(res.error)).toContain("SecurityError");272    // Crucially, nothing was written to the vault.273    expect(vault.getEntries().find((e) => e.passkey?.rpId === "google.com")).toBeUndefined();274  });275276  it("PASSKEY_GET rejects a cross-origin rpId (SEV-1-A)", async () => {277    const vault = await import("./vault");278    await vault.createVault("correct-pass-12345");279280    const res = await dispatch({281      type: "PASSKEY_GET",282      payload: {283        rpId: "google.com",284        challenge: "x",285        clientDataJSON: Array.from(new TextEncoder().encode("{}")),286        origin: "https://evil.com",287        allowCredentials: [],288      },289    }, "https://evil.com/attack");290291    expect(res.ok).toBe(false);292    expect(String(res.error)).toContain("SecurityError");293  });294295  it("PASSKEY_GET refuses a FORGED payload.origin that matches rpId when the attested sender origin differs (audit CRITICAL — origin spoofing)", async () => {296    const vault = await import("./vault");297    await vault.createVault("correct-pass-12345");298299    // The attacker self-consistently forges rpId + payload.origin + clientDataJSON300    // origin all to victimbank.com — exactly the bypass that defeated the old301    // isValidRpId(payload.rpId, payload.origin) check. The real frame is evil.com.302    const forgedClientData = Array.from(new TextEncoder().encode(303      JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://victimbank.com", crossOrigin: false }),304    ));305    const res = await dispatch({306      type: "PASSKEY_GET",307      payload: {308        rpId: "victimbank.com",309        origin: "https://victimbank.com",310        challenge: "x",311        clientDataJSON: forgedClientData,312        allowCredentials: [],313      },314    }, "https://evil.com/attack");315316    expect(res.ok).toBe(false);317    expect(String(res.error)).toContain("SecurityError");318  });319320  it("PASSKEY_HAS_CREDENTIALS is scoped to the caller origin (no cross-site enumeration)", async () => {321    const vault = await import("./vault");322    await vault.createVault("correct-pass-12345");323    await vault.addEntry({324      type: "login", title: "GitHub", url: "https://github.com", username: "june",325      passkey: {326        rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",327        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,328        userHandle: "u", userName: "june", userDisplayName: "June", algorithm: -7,329        signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,330      },331    });332333    // A page on evil.com may not learn the user holds a github.com passkey.334    await expect(dispatch({335      type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] },336    }, "https://evil.com/")).resolves.toMatchObject({ ok: true, hasCredentials: false });337338    // github.com itself sees its own credential.339    await expect(dispatch({340      type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] },341    }, "https://github.com/")).resolves.toMatchObject({ ok: true, hasCredentials: true });342  });343344  it("GET_PASSKEY_FOR_DOMAIN is scoped to the caller origin (no username leak cross-site)", async () => {345    const vault = await import("./vault");346    await vault.createVault("correct-pass-12345");347    await vault.addEntry({348      type: "login", title: "GitHub", url: "https://github.com", username: "june",349      passkey: {350        rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",351        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,352        userHandle: "u", userName: "[email protected]", userDisplayName: "June", algorithm: -7,353        signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,354      },355    });356357    await expect(dispatch({358      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },359    }, "https://evil.com/")).resolves.toMatchObject({ ok: true, data: null });360361    const own = await dispatch({362      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },363    }, "https://github.com/");364    expect((own.data as { userName?: string } | null)?.userName).toBe("[email protected]");365  });366367  it("handleFirefoxPasskeyCreate throws a SecurityError directly on rpId mismatch", async () => {368    const { handleFirefoxPasskeyCreate } = await import("./passkey");369    await expect(370      handleFirefoxPasskeyCreate({371        rpId: "google.com",372        rpName: "Google",373        userId: "dGVzdA",374        userName: "june",375        challenge: "x",376        clientDataJSON: [],377        origin: "https://evil.com",378        pubKeyCredParams: [{ alg: -7, type: "public-key" }],379      })380    ).rejects.toThrow("SecurityError");381  });382383  it("PASSKEY_GET returns no_credentials when nothing matches", async () => {384    const vault = await import("./vault");385    await vault.createVault("correct-pass-12345");386387    const clientDataJSON = Array.from(new TextEncoder().encode(388      JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false })389    ));390391    await expect(392      dispatch({393        type: "PASSKEY_GET",394        payload: {395          rpId: "github.com",396          challenge: "x",397          clientDataJSON,398          origin: "https://github.com",399          allowCredentials: [],400        },401      })402    ).resolves.toMatchObject({ ok: false, error: "no_credentials" });403  });404405  it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => {406    const vault = await import("./vault");407    await vault.createVault("correct-pass-12345");408409    const challenge = "challenge-base64url";410    const origin = "https://github.com";411    const clientDataCreate = Array.from(new TextEncoder().encode(412      JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false })413    ));414415    autoApprove();416    const createRes = await dispatch({417      type: "PASSKEY_CREATE",418      payload: {419        rpId: "github.com",420        rpName: "GitHub",421        userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),422        userName: "june",423        userDisplayName: "June",424        challenge,425        clientDataJSON: clientDataCreate,426        origin,427        pubKeyCredParams: [{ alg: -7, type: "public-key" }],428      },429    });430431    expect(createRes).toMatchObject({ ok: true });432    const cred = (createRes as any).credential;433    expect(cred.type).toBe("create");434    expect(typeof cred.id).toBe("string");435    expect(cred.algorithm).toBe(-7);436437    const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");438    expect(stored).toBeDefined();439    expect(stored!.passkey!.signCount).toBe(0);440    expect(stored!.passkey!.browser).toBe("firefox");441442    const clientDataGet = Array.from(new TextEncoder().encode(443      JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })444    ));445446    autoApprove();447    const getRes = await dispatch({448      type: "PASSKEY_GET",449      payload: {450        rpId: "github.com",451        challenge,452        clientDataJSON: clientDataGet,453        origin,454        allowCredentials: [{ id: cred.id, type: "public-key" }],455      },456    });457458    expect(getRes).toMatchObject({ ok: true });459    const assertion = (getRes as any).credential;460    expect(assertion.type).toBe("get");461    expect(typeof assertion.signature).toBe("string");462    expect(typeof assertion.authenticatorData).toBe("string");463464    const after = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");465    expect(after!.passkey!.signCount).toBe(1);466    expect(after!.passkey!.lastUsedAt).toBeDefined();467  });468});