passkey-chrome-ceremony.test.ts

32.4 KB · raw

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 */2425import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";2627vi.mock("./license", async (importOriginal) => {28  const actual = await importOriginal<typeof import("./license")>();29  return {30    ...actual,31    isProUser: vi.fn(async () => true),32  };33});3435vi.mock("../shared/passkey-flags", () => ({36  FIREFOX_USE_CONTENT_SCRIPT_PROXY: false,37}));3839type StorageShape = Record<string, unknown>;4041interface 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}4748interface 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}5354describe("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>;6364  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 () => {});7576    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;145146    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;147    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);148149    const vaultModule = await import("./vault");150    await vaultModule.ensureSettingsLoaded();151    const passkey = await import("./passkey");152    await passkey.setupPasskeyProxy();153  });154155  afterEach(() => {156    vi.unstubAllGlobals();157  });158159  function fireCreate(options: object, requestId = 100): void {160    proxyListeners.create!({161      requestId,162      requestDetailsJson: JSON.stringify(options),163    });164  }165166  function fireGet(options: object, requestId = 200): void {167    proxyListeners.get!({168      requestId,169      requestDetailsJson: JSON.stringify(options),170    });171  }172173  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  }182183  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  }192193  // 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  }207208  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  }221222  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  }245246  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  }253254  // ─────────────────────────────────────────────────────────────────────255  // isUvpaa — must report true regardless of lock state. (bug #2)256  // ─────────────────────────────────────────────────────────────────────257258  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  });263264  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  });271272  // ─────────────────────────────────────────────────────────────────────273  // Locked-vault create: wait for unlock, then proceed. (bug #3)274  // ─────────────────────────────────────────────────────────────────────275276  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();280281    autoApprove();282    fireCreate(buildCreateOptions(), 101);283284    // 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    );291292    // 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();299300    const done = await waitForCreateComplete(101);301    expect(done.error).toBeUndefined();302    expect(done.responseJson).toBeDefined();303  });304305  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();310311    fireCreate(buildCreateOptions(), 102);312313    // 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();318319    const done = await waitForCreateComplete(102);320    expect(done.error).toMatchObject({ name: "NotAllowedError" });321    expect(done.responseJson).toBeUndefined();322  });323324  // ─────────────────────────────────────────────────────────────────────325  // clientDataJSON.origin — page origin via active tab, not synthesized326  // from rpId. (bug #1)327  // ─────────────────────────────────────────────────────────────────────328329  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");332333    activeTabs = [{ id: 1, url: "https://myaccount.google.com/security/passkeys" }];334335    autoApprove();336    fireCreate(buildCreateOptions({ rpId: "google.com" }), 110);337    const done = await waitForCreateComplete(110);338339    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  });344345  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");348349    activeTabs = [{ id: 1, url: "https://github.com/settings/security" }];350351    autoApprove();352    fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 111);353    const done = await waitForCreateComplete(111);354355    const response = JSON.parse(done.responseJson!);356    const clientData = decodeClientData(response.response.clientDataJSON);357    expect(clientData.origin).toBe("https://github.com");358  });359360  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");363364    // 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" }];368369    autoApprove();370    fireCreate(buildCreateOptions({ rpId: "google.com" }), 112);371    const done = await waitForCreateComplete(112);372373    const response = JSON.parse(done.responseJson!);374    const clientData = decodeClientData(response.response.clientDataJSON);375    expect(clientData.origin).toBe("https://google.com");376  });377378  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");381382    activeTabs = [{ id: 1 }]; // no url383384    autoApprove();385    fireCreate(buildCreateOptions({ rpId: "google.com" }), 113);386    const done = await waitForCreateComplete(113);387388    const response = JSON.parse(done.responseJson!);389    const clientData = decodeClientData(response.response.clientDataJSON);390    expect(clientData.origin).toBe("https://google.com");391  });392393  // ─────────────────────────────────────────────────────────────────────394  // Core create ceremony.395  // ─────────────────────────────────────────────────────────────────────396397  it("create rejects with NotSupportedError when rp.id is missing", async () => {398    const vault = await import("./vault");399    await vault.createVault("correct-pass-12345");400401    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  });405406  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");409410    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  });415416  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");419420    autoDeny();421    fireCreate(buildCreateOptions(), 122);422    const done = await waitForCreateComplete(122);423    expect(done.error).toMatchObject({ name: "NotAllowedError" });424  });425426  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);429430    const vault = await import("./vault");431    await vault.createVault("correct-pass-12345");432433    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  });442443  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");446447    autoApprove();448    fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 124);449    await waitForCreateComplete(124);450451    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!.backupEligible).toBe(true);455    expect(stored!.passkey!.signCount).toBe(0);456    expect(stored!.passkey!.privateKeyJwk).toBeDefined();457458    const createResponse = JSON.parse(completes.create.find((c) => c.requestId === 124)!.responseJson!);459    const authenticatorData = Uint8Array.from(atob(createResponse.response.authenticatorData.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(createResponse.response.authenticatorData.length / 4) * 4, "=")), (char) => char.charCodeAt(0));460    expect(authenticatorData[32]! & 0x08).toBe(0x08);461  });462463  it("create attaches the passkey to an existing login entry for the same domain instead of creating a new one", async () => {464    const vault = await import("./vault");465    await vault.createVault("correct-pass-12345");466    const existing = await vault.addEntry({467      type: "login",468      title: "GitHub",469      url: "https://github.com",470      username: "june",471      password: "hunter2",472    });473    const beforeCount = vault.getEntries().length;474475    autoApprove();476    fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 125);477    await waitForCreateComplete(125);478479    expect(vault.getEntries().length).toBe(beforeCount);480    const updated = vault.getEntries().find((e) => e.id === existing.id);481    expect(updated?.passkey?.rpId).toBe("github.com");482  });483484  // ─────────────────────────────────────────────────────────────────────485  // Get ceremony.486  // ─────────────────────────────────────────────────────────────────────487488  async function seedPasskey(rpId: string, opts?: { signCount?: number }) {489    const vault = await import("./vault");490    const { base64urlEncode, generatePasskeyKeypair } = await import("./passkey-crypto");491    const generated = await generatePasskeyKeypair(-7);492    const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));493    const entry = await vault.addEntry({494      type: "login",495      title: rpId,496      url: `https://${rpId}`,497      username: "june",498      passkey: {499        rpId,500        rpName: rpId,501        credentialId,502        publicKeyCose: base64urlEncode(generated.publicKeyCose),503        privateKeyJwk: generated.privateKeyJwk,504        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),505        userName: "june",506        userDisplayName: "June",507        algorithm: -7,508        signCount: opts?.signCount ?? 0,509        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",510        backupEligible: false,511        backupState: false,512        browser: "chrome",513        transports: ["internal"],514        createdAt: Date.now(),515      },516    });517    return { entry, credentialId };518  }519520  it("get passes through to platform when no stored credential matches the rpId", async () => {521    const vault = await import("./vault");522    await vault.createVault("correct-pass-12345");523524    fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 200);525    const done = await waitForGetComplete(200);526    // Pass-through: no error and no responseJson — Chrome routes to next authenticator.527    expect(done.error).toBeUndefined();528    expect(done.responseJson).toBeUndefined();529  });530531  // PB-26-015: a credential-ID match must still belong to the requesting rpId —532  // a foreign-RP request listing a known credential id must not select it.533  it("get does not match a credential whose stored rpId differs from the request rpId", async () => {534    const vault = await import("./vault");535    await vault.createVault("correct-pass-12345");536    const { credentialId } = await seedPasskey("github.com");537538    fireGet(539      {540        rpId: "evil.example",541        challenge: "Y2g",542        allowCredentials: [{ type: "public-key", id: credentialId }],543      },544      205,545    );546    const done = await waitForGetComplete(205);547    expect(done.error).toBeUndefined();548    expect(done.responseJson).toBeUndefined();549  });550551  it("get encodes the active tab page origin into clientDataJSON (not https://<rpId>)", async () => {552    const vault = await import("./vault");553    await vault.createVault("correct-pass-12345");554    await seedPasskey("google.com");555556    activeTabs = [{ id: 1, url: "https://accounts.google.com/login" }];557558    autoApprove();559    fireGet({ rpId: "google.com", challenge: "Y2g", allowCredentials: [] }, 201);560    const done = await waitForGetComplete(201);561562    const response = JSON.parse(done.responseJson!);563    const clientData = decodeClientData(response.response.clientDataJSON);564    expect(clientData.origin).toBe("https://accounts.google.com");565    expect(clientData.type).toBe("webauthn.get");566  });567568  it("get increments signCount and updates lastUsedAt on success", async () => {569    const vault = await import("./vault");570    await vault.createVault("correct-pass-12345");571    const { entry } = await seedPasskey("github.com", { signCount: 7 });572573    autoApprove();574    fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 202);575    await waitForGetComplete(202);576577    const after = vault.getEntries().find((e) => e.id === entry.id);578    expect(after!.passkey!.signCount).toBe(8);579    expect(after!.passkey!.lastUsedAt).toBeDefined();580  });581582  it("get preserves the BE flag used to register legacy Chrome passkeys", async () => {583    const vault = await import("./vault");584    await vault.createVault("correct-pass-12345");585    const { entry } = await seedPasskey("github.com");586587    expect(entry.passkey!.browser).toBe("chrome");588    expect(entry.passkey!.backupEligible).toBe(false);589590    autoApprove();591    fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 206);592    const done = await waitForGetComplete(206);593    const response = JSON.parse(done.responseJson!);594    const authenticatorData = Uint8Array.from(595      atob(response.response.authenticatorData.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(response.response.authenticatorData.length / 4) * 4, "=")),596      (char) => char.charCodeAt(0),597    );598599    expect(authenticatorData[32]! & 0x08).toBe(0x08);600    expect(vault.getEntries().find((candidate) => candidate.id === entry.id)!.passkey!.backupEligible).toBe(true);601  });602603  it("get passes through to platform when the stored passkey has no private key (Firefox-created)", async () => {604    const vault = await import("./vault");605    await vault.createVault("correct-pass-12345");606    const { base64urlEncode, generatePasskeyKeypair } = await import("./passkey-crypto");607    const generated = await generatePasskeyKeypair(-7);608    await vault.addEntry({609      type: "login",610      title: "Cross-browser passkey",611      passkey: {612        rpId: "webauthn.io",613        rpName: "WebAuthn.io",614        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),615        publicKeyCose: base64urlEncode(generated.publicKeyCose),616        // privateKeyJwk intentionally absent — pre-v2.12 Firefox passkey617        // delegated signing to the platform authenticator.618        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),619        userName: "june",620        userDisplayName: "June",621        algorithm: -7,622        signCount: 0,623        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",624        backupEligible: false,625        backupState: false,626        browser: "firefox",627        transports: ["internal"],628        createdAt: Date.now(),629      },630    });631632    fireGet({ rpId: "webauthn.io", challenge: "Y2g", allowCredentials: [] }, 203);633    const done = await waitForGetComplete(203);634    // Pass-through: no error and no responseJson — Chrome routes to next authenticator.635    expect(done.error).toBeUndefined();636    expect(done.responseJson).toBeUndefined();637  });638639  it("get on a locked vault parks the request and proceeds after unlock", async () => {640    const vault = await import("./vault");641    await vault.createVault("correct-pass-12345");642    await seedPasskey("github.com");643    vault.lock();644645    autoApprove();646    fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 204);647648    await new Promise((r) => setTimeout(r, 50));649    expect(tabsSendMessage).toHaveBeenCalledWith(650      1,651      expect.objectContaining({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" })652    );653654    await vault.unlock("correct-pass-12345");655    const passkey = await import("./passkey");656    passkey.onVaultUnlocked();657658    const done = await waitForGetComplete(204);659    expect(done.error).toBeUndefined();660    expect(done.responseJson).toBeDefined();661  });662663  // ─────────────────────────────────────────────────────────────────────664  // onRequestCanceled — popup-closed / RP-cancelled cleanup.665  // ─────────────────────────────────────────────────────────────────────666667  // ─────────────────────────────────────────────────────────────────────668  // "Invalid requestId" survival — conditional-UI gets cancel fast.669  // ─────────────────────────────────────────────────────────────────────670671  it("pass-through swallows 'Invalid requestId' from a cancelled conditional-UI get without throwing", async () => {672    const vault = await import("./vault");673    await vault.createVault("correct-pass-12345");674675    // Simulate Chrome cancelling the request between handleGetRequest running676    // and the pass-through completeGetRequest call landing.677    const ce = (globalThis as { chrome: typeof chrome }).chrome.webAuthenticationProxy678      .completeGetRequest as unknown as ReturnType<typeof vi.fn>;679    ce.mockImplementationOnce(async () => {680      throw new Error("Invalid requestId");681    });682683    // No matching passkey for this rpId → pass-through completeGetRequest call,684    // which must NOT propagate the Invalid requestId error.685    let resolved = false;686    proxyListeners.get!({687      requestId: 999,688      requestDetailsJson: JSON.stringify({ rpId: "unknown.example", challenge: "x", allowCredentials: [] }),689    });690    // Allow the handler to finish; if it threw, the test would unhandle-reject.691    await new Promise((r) => setTimeout(r, 30));692    resolved = true;693    expect(resolved).toBe(true);694  });695696  it("rejectCreate swallows 'Invalid requestId' from a cancelled create", async () => {697    const vault = await import("./vault");698    await vault.createVault("correct-pass-12345");699700    const ce = (globalThis as { chrome: typeof chrome }).chrome.webAuthenticationProxy701      .completeCreateRequest as unknown as ReturnType<typeof vi.fn>;702    ce.mockImplementationOnce(async () => {703      throw new Error("Invalid requestId");704    });705706    // Force the early rp.id missing path so rejectCreate runs first.707    fireCreate({ rp: {}, user: { id: "x", name: "u" }, challenge: "c", pubKeyCredParams: [{ alg: -7, type: "public-key" }] }, 998);708    await new Promise((r) => setTimeout(r, 30));709    // No assertions on completes — the assertion is that the handler did710    // not throw an unhandled rejection (the test would fail under vitest's711    // unhandled-rejection guard otherwise).712  });713714  it("onRequestCanceled clears a parked unlock waiter so the handler exits promptly", async () => {715    const vault = await import("./vault");716    await vault.createVault("correct-pass-12345");717    vault.lock();718719    fireCreate(buildCreateOptions(), 997);720    // Wait for the request to park.721    await new Promise((r) => setTimeout(r, 30));722723    proxyListeners.cancel!(997);724725    const done = await waitForCreateComplete(997, 500);726    expect(done.error).toMatchObject({ name: "NotAllowedError" });727  });728729  it("onRequestCanceled resolves a still-pending approval as denied", async () => {730    const vault = await import("./vault");731    await vault.createVault("correct-pass-12345");732733    fireCreate(buildCreateOptions(), 301);734735    // Wait for the approval to be registered.736    const passkey = await import("./passkey");737    let pending: Awaited<ReturnType<typeof passkey.getPendingPasskeyRequest>> = null;738    for (let i = 0; i < 50; i += 1) {739      pending = await passkey.getPendingPasskeyRequest();740      if (pending) break;741      await new Promise((r) => setTimeout(r, 10));742    }743    expect(pending).toBeTruthy();744745    proxyListeners.cancel!(301);746747    // After cancel the pending session record should be cleared.748    for (let i = 0; i < 50; i += 1) {749      const after = await passkey.getPendingPasskeyRequest();750      if (!after) return;751      await new Promise((r) => setTimeout(r, 10));752    }753    throw new Error("pending passkey request was not cleared after onRequestCanceled");754  });755});