passkey-firefox-proxy.test.ts

22.9 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 reports a locked vault without enumerating credentials", 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: false, error: "vault_locked" });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("MARK_PASSKEY_BANNER_SHOWN is origin-bound and surfaces via bannerShown (PB-26-203)", async () => {368    const vault = await import("./vault");369    await vault.createVault("correct-pass-12345");370    await vault.addEntry({371      type: "login", title: "GitHub", url: "https://github.com", username: "june",372      passkey: {373        rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",374        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,375        userHandle: "u", userName: "[email protected]", userDisplayName: "June", algorithm: -7,376        signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,377      },378    });379380    // evil.com cannot mark github.com's flag: no write, no error leak.381    await expect(dispatch({382      type: "MARK_PASSKEY_BANNER_SHOWN", payload: { domain: "github.com" },383    }, "https://evil.com/")).resolves.toMatchObject({ ok: true });384    const before = await dispatch({385      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },386    }, "https://github.com/");387    expect((before.data as { bannerShown?: boolean } | null)?.bannerShown).toBe(false);388389    // github.com marks its own flag; the read path surfaces it.390    await expect(dispatch({391      type: "MARK_PASSKEY_BANNER_SHOWN", payload: { domain: "github.com" },392    }, "https://github.com/")).resolves.toMatchObject({ ok: true });393    const after = await dispatch({394      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },395    }, "https://github.com/");396    expect((after.data as { bannerShown?: boolean } | null)?.bannerShown).toBe(true);397  });398399  it("handleFirefoxPasskeyCreate throws a SecurityError directly on rpId mismatch", async () => {400    const { handleFirefoxPasskeyCreate } = await import("./passkey");401    await expect(402      handleFirefoxPasskeyCreate({403        rpId: "google.com",404        rpName: "Google",405        userId: "dGVzdA",406        userName: "june",407        challenge: "x",408        clientDataJSON: [],409        origin: "https://evil.com",410        pubKeyCredParams: [{ alg: -7, type: "public-key" }],411      })412    ).rejects.toThrow("SecurityError");413  });414415  it("PASSKEY_GET returns no_credentials when nothing matches", async () => {416    const vault = await import("./vault");417    await vault.createVault("correct-pass-12345");418419    const clientDataJSON = Array.from(new TextEncoder().encode(420      JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false })421    ));422423    await expect(424      dispatch({425        type: "PASSKEY_GET",426        payload: {427          rpId: "github.com",428          challenge: "x",429          clientDataJSON,430          origin: "https://github.com",431          allowCredentials: [],432        },433      })434    ).resolves.toMatchObject({ ok: false, error: "no_credentials" });435  });436437  it.each(["unlock", "dismiss", "timeout"] as const)("PASSKEY_GET waits for a locked vault: %s", async (outcome) => {438    const vault = await import("./vault");439    const passkey = await import("./passkey");440    await vault.createVault("correct-pass-12345");441    vault.lock();442    if (outcome === "timeout") vi.useFakeTimers();443    const resultPromise = dispatch({444      type: "PASSKEY_GET",445      payload: { rpId: "github.com", challenge: "x", origin: "https://github.com", clientDataJSON: Array.from(new TextEncoder().encode("{}")), allowCredentials: [] },446    });447    await vi.waitFor(() => expect(chrome.action.openPopup).toHaveBeenCalled());448    let settled = false;449    void resultPromise.then(() => { settled = true; });450    await Promise.resolve();451    expect(settled).toBe(false);452    if (outcome === "unlock") {453      await vault.unlock("correct-pass-12345");454      passkey.onVaultUnlocked();455      await expect(resultPromise).resolves.toMatchObject({ ok: false, error: "no_credentials" });456    } else if (outcome === "dismiss") {457      await passkey.denyAllPendingPasskeyRequests();458      await expect(resultPromise).resolves.toMatchObject({ ok: false, error: "user_cancelled" });459    } else {460      await vi.advanceTimersByTimeAsync(60_000);461      await expect(resultPromise).resolves.toMatchObject({ ok: false, error: "user_cancelled" });462      vi.useRealTimers();463    }464  });465466  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) => {467    const vault = await import("./vault");468    await vault.createVault("correct-pass-12345");469    const generated = await generatePasskeyKeypair(-7);470    const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));471    const entry = await vault.addEntry({472      type: "login",473      title: "GitHub",474      passkey: {475        rpId: "github.com", rpName: "GitHub", credentialId,476        publicKeyCose: base64urlEncode(generated.publicKeyCose),477        privateKeyJwk: generated.privateKeyJwk,478        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),479        userName: "june", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "",480        backupEligible: false, backupState: false, createdAt: 1, browser,481      },482    });483    const challenge = "challenge-base64url";484    const origin = "https://github.com";485    const clientDataJSON = Array.from(new TextEncoder().encode(486      JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })487    ));488489    if (locked) vault.lock();490    const resultPromise = dispatch({491      type: "PASSKEY_GET",492      payload: { rpId: "github.com", challenge, origin, clientDataJSON, allowCredentials: [{ id: credentialId, type: "public-key" }] },493    });494    const passkey = await import("./passkey");495    if (locked) {496      await vi.waitFor(() => expect(chrome.action.openPopup).toHaveBeenCalled());497      await vault.unlock("correct-pass-12345");498      passkey.onVaultUnlocked();499    }500    await vi.waitFor(async () => {501      expect(await passkey.getPendingPasskeyRequest()).not.toBeNull();502    });503    const pending = await passkey.getPendingPasskeyRequest();504    await passkey.resolvePendingPasskeyRequest(pending!.requestId, true);505    const result = await resultPromise;506    expect(result.ok).toBe(true);507    const assertion = (result as unknown as { credential: { authenticatorData: string } }).credential;508    const authenticatorData = Uint8Array.from(atob(assertion.authenticatorData.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0));509    expect(Boolean(authenticatorData[32]! & 0x08)).toBe(expectedBackupEligible);510    expect(vault.getEntries().find((candidate) => candidate.id === entry.id)!.passkey!.backupEligible).toBe(expectedBackupEligible);511  });512513  it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => {514    const vault = await import("./vault");515    await vault.createVault("correct-pass-12345");516517    const challenge = "challenge-base64url";518    const origin = "https://github.com";519    const clientDataCreate = Array.from(new TextEncoder().encode(520      JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false })521    ));522523    autoApprove();524    const createRes = await dispatch({525      type: "PASSKEY_CREATE",526      payload: {527        rpId: "github.com",528        rpName: "GitHub",529        userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),530        userName: "june",531        userDisplayName: "June",532        challenge,533        clientDataJSON: clientDataCreate,534        origin,535        pubKeyCredParams: [{ alg: -7, type: "public-key" }],536      },537    });538539    expect(createRes).toMatchObject({ ok: true });540    const cred = (createRes as any).credential;541    expect(cred.type).toBe("create");542    expect(typeof cred.id).toBe("string");543    expect(cred.algorithm).toBe(-7);544545    const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");546    expect(stored).toBeDefined();547    expect(stored!.passkey!.signCount).toBe(0);548    expect(stored!.passkey!.browser).toBe("firefox");549    expect(stored!.passkey!.backupEligible).toBe(true);550551    const clientDataGet = Array.from(new TextEncoder().encode(552      JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })553    ));554555    autoApprove();556    const getRes = await dispatch({557      type: "PASSKEY_GET",558      payload: {559        rpId: "github.com",560        challenge,561        clientDataJSON: clientDataGet,562        origin,563        allowCredentials: [{ id: cred.id, type: "public-key" }],564      },565    });566567    expect(getRes).toMatchObject({ ok: true });568    const assertion = (getRes as any).credential;569    expect(assertion.type).toBe("get");570    expect(typeof assertion.signature).toBe("string");571    expect(typeof assertion.authenticatorData).toBe("string");572    const authenticatorData = Uint8Array.from(atob(assertion.authenticatorData.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(assertion.authenticatorData.length / 4) * 4, "=")), (char) => char.charCodeAt(0));573    expect(authenticatorData[32]! & 0x08).toBe(0x08);574575    const after = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");576    expect(after!.passkey!.signCount).toBe(1);577    expect(after!.passkey!.lastUsedAt).toBeDefined();578  });579});