passkey-chrome-ceremony.test.ts

32.9 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  it("leaves Chrome native passkeys available when interception has no saved preference", async () => {160    vi.resetModules();161    storage.peach_settings = { breachMonitoring: false };162    vi.mocked(chrome.webAuthenticationProxy.attach).mockClear();163    const passkey = await import("./passkey");164    await passkey.setupPasskeyProxy();165    expect(chrome.webAuthenticationProxy.attach).not.toHaveBeenCalled();166    await expect(passkey.getPasskeyProviderState()).resolves.toMatchObject({ enabled: false, attached: false });167  });168169  function fireCreate(options: object, requestId = 100): void {170    proxyListeners.create!({171      requestId,172      requestDetailsJson: JSON.stringify(options),173    });174  }175176  function fireGet(options: object, requestId = 200): void {177    proxyListeners.get!({178      requestId,179      requestDetailsJson: JSON.stringify(options),180    });181  }182183  async function waitForCreateComplete(requestId: number, timeoutMs = 2000): Promise<typeof completes.create[number]> {184    const start = Date.now();185    while (Date.now() - start < timeoutMs) {186      const found = completes.create.find((c) => c.requestId === requestId);187      if (found) return found;188      await new Promise((r) => setTimeout(r, 10));189    }190    throw new Error(`completeCreateRequest never called for requestId=${requestId}`);191  }192193  async function waitForGetComplete(requestId: number, timeoutMs = 2000): Promise<typeof completes.get[number]> {194    const start = Date.now();195    while (Date.now() - start < timeoutMs) {196      const found = completes.get.find((c) => c.requestId === requestId);197      if (found) return found;198      await new Promise((r) => setTimeout(r, 10));199    }200    throw new Error(`completeGetRequest never called for requestId=${requestId}`);201  }202203  // Auto-approve any pending Peach popup approval as it appears.204  function autoApprove() {205    void (async () => {206      const passkey = await import("./passkey");207      for (let i = 0; i < 200; i += 1) {208        const pending = await passkey.getPendingPasskeyRequest();209        if (pending) {210          await passkey.resolvePendingPasskeyRequest(pending.requestId, true);211          return;212        }213        await new Promise((r) => setTimeout(r, 10));214      }215    })();216  }217218  function autoDeny() {219    void (async () => {220      const passkey = await import("./passkey");221      for (let i = 0; i < 200; i += 1) {222        const pending = await passkey.getPendingPasskeyRequest();223        if (pending) {224          await passkey.resolvePendingPasskeyRequest(pending.requestId, false);225          return;226        }227        await new Promise((r) => setTimeout(r, 10));228      }229    })();230  }231232  function buildCreateOptions(overrides: Partial<{233    rpId: string;234    rpName: string;235    userName: string;236    userDisplayName: string;237    userIdB64: string;238    challenge: string;239    pubKeyCredParams: Array<{ alg: number; type: string }>;240  }> = {}) {241    return {242      rp: { id: overrides.rpId ?? "google.com", name: overrides.rpName ?? "Google" },243      user: {244        id: overrides.userIdB64 ?? "dXNlci1pZA",245        name: overrides.userName ?? "[email protected]",246        displayName: overrides.userDisplayName ?? "June",247      },248      challenge: overrides.challenge ?? "Y2hhbGxlbmdl",249      pubKeyCredParams: overrides.pubKeyCredParams ?? [250        { alg: -7, type: "public-key" },251        { alg: -257, type: "public-key" },252      ],253    };254  }255256  function decodeClientData(b64url: string): { type: string; challenge: string; origin: string; crossOrigin: boolean } {257    const padded = b64url.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(b64url.length / 4) * 4, "=");258    const json = atob(padded);259    const bytes = new Uint8Array(json.length);260    for (let i = 0; i < json.length; i += 1) bytes[i] = json.charCodeAt(i);261    return JSON.parse(new TextDecoder().decode(bytes));262  }263264  // ─────────────────────────────────────────────────────────────────────265  // isUvpaa — must report true regardless of lock state. (bug #2)266  // ─────────────────────────────────────────────────────────────────────267268  it("isUvpaa returns true when the vault is locked (regression — was 'A passkey can't be created on this device')", async () => {269    proxyListeners.uvpaa!({ requestId: 1 });270    await new Promise((r) => setTimeout(r, 0));271    expect(completes.uvpaa).toEqual([{ requestId: 1, isUvpaa: true }]);272  });273274  it("isUvpaa returns true when the vault is unlocked", async () => {275    const vault = await import("./vault");276    await vault.createVault("correct-pass-12345");277    proxyListeners.uvpaa!({ requestId: 7 });278    await new Promise((r) => setTimeout(r, 0));279    expect(completes.uvpaa).toEqual([{ requestId: 7, isUvpaa: true }]);280  });281282  // ─────────────────────────────────────────────────────────────────────283  // Locked-vault create: wait for unlock, then proceed. (bug #3)284  // ─────────────────────────────────────────────────────────────────────285286  it("handleCreateRequest on a locked vault summons inline unlock and proceeds once unlocked", async () => {287    const vault = await import("./vault");288    await vault.createVault("correct-pass-12345");289    vault.lock();290291    autoApprove();292    fireCreate(buildCreateOptions(), 101);293294    // Wait long enough for the request to park in unlockWaiters and for the295    // SUMMON_AUTOFILL message to fire, then unlock.296    await new Promise((r) => setTimeout(r, 50));297    expect(tabsSendMessage).toHaveBeenCalledWith(298      1,299      expect.objectContaining({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" })300    );301302    // Unlocking fires onVaultUnlocked() via the messages handler. Tests can303    // skip the message dispatcher and call unlock() directly — the messages304    // handler's onVaultUnlocked() call is what production hits, but the305    // module-internal waiter notifier is what we need to fire here.306    await vault.unlock("correct-pass-12345");307    const passkey = await import("./passkey");308    passkey.onVaultUnlocked();309310    const done = await waitForCreateComplete(101);311    expect(done.error).toBeUndefined();312    expect(done.responseJson).toBeDefined();313  });314315  it("handleCreateRequest rejects with NotAllowedError when unlock waits timeout", async () => {316    vi.useFakeTimers();317    const vault = await import("./vault");318    await vault.createVault("correct-pass-12345");319    vault.lock();320321    fireCreate(buildCreateOptions(), 102);322323    // Allow the request to park.324    await vi.advanceTimersByTimeAsync(10);325    // Jump past APPROVAL_TIMEOUT_MS (60s).326    await vi.advanceTimersByTimeAsync(60_500);327    vi.useRealTimers();328329    const done = await waitForCreateComplete(102);330    expect(done.error).toMatchObject({ name: "NotAllowedError" });331    expect(done.responseJson).toBeUndefined();332  });333334  // ─────────────────────────────────────────────────────────────────────335  // clientDataJSON.origin — page origin via active tab, not synthesized336  // from rpId. (bug #1)337  // ─────────────────────────────────────────────────────────────────────338339  it("create encodes the active tab origin into clientDataJSON when hostname is a subdomain of the rpId", async () => {340    const vault = await import("./vault");341    await vault.createVault("correct-pass-12345");342343    activeTabs = [{ id: 1, url: "https://myaccount.google.com/security/passkeys" }];344345    autoApprove();346    fireCreate(buildCreateOptions({ rpId: "google.com" }), 110);347    const done = await waitForCreateComplete(110);348349    const response = JSON.parse(done.responseJson!);350    const clientData = decodeClientData(response.response.clientDataJSON);351    expect(clientData.origin).toBe("https://myaccount.google.com");352    expect(clientData.type).toBe("webauthn.create");353  });354355  it("create encodes the active tab origin when hostname equals the rpId exactly", async () => {356    const vault = await import("./vault");357    await vault.createVault("correct-pass-12345");358359    activeTabs = [{ id: 1, url: "https://github.com/settings/security" }];360361    autoApprove();362    fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 111);363    const done = await waitForCreateComplete(111);364365    const response = JSON.parse(done.responseJson!);366    const clientData = decodeClientData(response.response.clientDataJSON);367    expect(clientData.origin).toBe("https://github.com");368  });369370  it("create falls back to https://<rpId> when the active tab is not a registrable-suffix match (safety)", async () => {371    const vault = await import("./vault");372    await vault.createVault("correct-pass-12345");373374    // User switched tabs to evil.example before we resolved — refuse to375    // sign for that origin and fall back instead of leaking a signature376    // for a hostile origin.377    activeTabs = [{ id: 1, url: "https://evil.example/passkey" }];378379    autoApprove();380    fireCreate(buildCreateOptions({ rpId: "google.com" }), 112);381    const done = await waitForCreateComplete(112);382383    const response = JSON.parse(done.responseJson!);384    const clientData = decodeClientData(response.response.clientDataJSON);385    expect(clientData.origin).toBe("https://google.com");386  });387388  it("create falls back to https://<rpId> when the active tab has no URL", async () => {389    const vault = await import("./vault");390    await vault.createVault("correct-pass-12345");391392    activeTabs = [{ id: 1 }]; // no url393394    autoApprove();395    fireCreate(buildCreateOptions({ rpId: "google.com" }), 113);396    const done = await waitForCreateComplete(113);397398    const response = JSON.parse(done.responseJson!);399    const clientData = decodeClientData(response.response.clientDataJSON);400    expect(clientData.origin).toBe("https://google.com");401  });402403  // ─────────────────────────────────────────────────────────────────────404  // Core create ceremony.405  // ─────────────────────────────────────────────────────────────────────406407  it("create rejects with NotSupportedError when rp.id is missing", async () => {408    const vault = await import("./vault");409    await vault.createVault("correct-pass-12345");410411    fireCreate({ rp: { name: "No id" }, user: { id: "x", name: "u" }, challenge: "c", pubKeyCredParams: [{ alg: -7, type: "public-key" }] }, 120);412    const done = await waitForCreateComplete(120);413    expect(done.error).toMatchObject({ name: "NotSupportedError" });414  });415416  it("create rejects with NotSupportedError when no supported algorithm is offered", async () => {417    const vault = await import("./vault");418    await vault.createVault("correct-pass-12345");419420    autoApprove();421    fireCreate(buildCreateOptions({ pubKeyCredParams: [{ alg: -257, type: "public-key" }] }), 121);422    const done = await waitForCreateComplete(121);423    expect(done.error).toMatchObject({ name: "NotSupportedError" });424  });425426  it("create rejects with NotAllowedError when the user denies the approval", async () => {427    const vault = await import("./vault");428    await vault.createVault("correct-pass-12345");429430    autoDeny();431    fireCreate(buildCreateOptions(), 122);432    const done = await waitForCreateComplete(122);433    expect(done.error).toMatchObject({ name: "NotAllowedError" });434  });435436  it("create rejects with NotAllowedError + broadcasts the pro gate when the user is not Pro", async () => {437    const license = await import("./license");438    (license.isProUser as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);439440    const vault = await import("./vault");441    await vault.createVault("correct-pass-12345");442443    fireCreate(buildCreateOptions(), 123);444    const done = await waitForCreateComplete(123);445    expect(done.error).toMatchObject({ name: "NotAllowedError" });446    // Pro gate broadcasted to every tab via tabs.sendMessage with PASSKEY_PRO_GATE.447    expect(tabsSendMessage).toHaveBeenCalledWith(448      expect.any(Number),449      expect.objectContaining({ type: "PASSKEY_PRO_GATE" })450    );451  });452453  it("create persists the passkey to the vault and tags it browser:'chrome'", async () => {454    const vault = await import("./vault");455    await vault.createVault("correct-pass-12345");456457    autoApprove();458    fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 124);459    await waitForCreateComplete(124);460461    const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");462    expect(stored).toBeDefined();463    expect(stored!.passkey!.browser).toBe("chrome");464    expect(stored!.passkey!.backupEligible).toBe(true);465    expect(stored!.passkey!.signCount).toBe(0);466    expect(stored!.passkey!.privateKeyJwk).toBeDefined();467468    const createResponse = JSON.parse(completes.create.find((c) => c.requestId === 124)!.responseJson!);469    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));470    expect(authenticatorData[32]! & 0x08).toBe(0x08);471  });472473  it("create attaches the passkey to an existing login entry for the same domain instead of creating a new one", async () => {474    const vault = await import("./vault");475    await vault.createVault("correct-pass-12345");476    const existing = await vault.addEntry({477      type: "login",478      title: "GitHub",479      url: "https://github.com",480      username: "june",481      password: "hunter2",482    });483    const beforeCount = vault.getEntries().length;484485    autoApprove();486    fireCreate(buildCreateOptions({ rpId: "github.com", rpName: "GitHub" }), 125);487    await waitForCreateComplete(125);488489    expect(vault.getEntries().length).toBe(beforeCount);490    const updated = vault.getEntries().find((e) => e.id === existing.id);491    expect(updated?.passkey?.rpId).toBe("github.com");492  });493494  // ─────────────────────────────────────────────────────────────────────495  // Get ceremony.496  // ─────────────────────────────────────────────────────────────────────497498  async function seedPasskey(rpId: string, opts?: { signCount?: number }) {499    const vault = await import("./vault");500    const { base64urlEncode, generatePasskeyKeypair } = await import("./passkey-crypto");501    const generated = await generatePasskeyKeypair(-7);502    const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));503    const entry = await vault.addEntry({504      type: "login",505      title: rpId,506      url: `https://${rpId}`,507      username: "june",508      passkey: {509        rpId,510        rpName: rpId,511        credentialId,512        publicKeyCose: base64urlEncode(generated.publicKeyCose),513        privateKeyJwk: generated.privateKeyJwk,514        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),515        userName: "june",516        userDisplayName: "June",517        algorithm: -7,518        signCount: opts?.signCount ?? 0,519        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",520        backupEligible: false,521        backupState: false,522        browser: "chrome",523        transports: ["internal"],524        createdAt: Date.now(),525      },526    });527    return { entry, credentialId };528  }529530  it("get passes through to platform when no stored credential matches the rpId", async () => {531    const vault = await import("./vault");532    await vault.createVault("correct-pass-12345");533534    fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 200);535    const done = await waitForGetComplete(200);536    // Pass-through: no error and no responseJson — Chrome routes to next authenticator.537    expect(done.error).toBeUndefined();538    expect(done.responseJson).toBeUndefined();539  });540541  // PB-26-015: a credential-ID match must still belong to the requesting rpId —542  // a foreign-RP request listing a known credential id must not select it.543  it("get does not match a credential whose stored rpId differs from the request rpId", async () => {544    const vault = await import("./vault");545    await vault.createVault("correct-pass-12345");546    const { credentialId } = await seedPasskey("github.com");547548    fireGet(549      {550        rpId: "evil.example",551        challenge: "Y2g",552        allowCredentials: [{ type: "public-key", id: credentialId }],553      },554      205,555    );556    const done = await waitForGetComplete(205);557    expect(done.error).toBeUndefined();558    expect(done.responseJson).toBeUndefined();559  });560561  it("get encodes the active tab page origin into clientDataJSON (not https://<rpId>)", async () => {562    const vault = await import("./vault");563    await vault.createVault("correct-pass-12345");564    await seedPasskey("google.com");565566    activeTabs = [{ id: 1, url: "https://accounts.google.com/login" }];567568    autoApprove();569    fireGet({ rpId: "google.com", challenge: "Y2g", allowCredentials: [] }, 201);570    const done = await waitForGetComplete(201);571572    const response = JSON.parse(done.responseJson!);573    const clientData = decodeClientData(response.response.clientDataJSON);574    expect(clientData.origin).toBe("https://accounts.google.com");575    expect(clientData.type).toBe("webauthn.get");576  });577578  it("get increments signCount and updates lastUsedAt on success", async () => {579    const vault = await import("./vault");580    await vault.createVault("correct-pass-12345");581    const { entry } = await seedPasskey("github.com", { signCount: 7 });582583    autoApprove();584    fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 202);585    await waitForGetComplete(202);586587    const after = vault.getEntries().find((e) => e.id === entry.id);588    expect(after!.passkey!.signCount).toBe(8);589    expect(after!.passkey!.lastUsedAt).toBeDefined();590  });591592  it("get preserves the BE flag used to register legacy Chrome passkeys", async () => {593    const vault = await import("./vault");594    await vault.createVault("correct-pass-12345");595    const { entry } = await seedPasskey("github.com");596597    expect(entry.passkey!.browser).toBe("chrome");598    expect(entry.passkey!.backupEligible).toBe(false);599600    autoApprove();601    fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 206);602    const done = await waitForGetComplete(206);603    const response = JSON.parse(done.responseJson!);604    const authenticatorData = Uint8Array.from(605      atob(response.response.authenticatorData.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(response.response.authenticatorData.length / 4) * 4, "=")),606      (char) => char.charCodeAt(0),607    );608609    expect(authenticatorData[32]! & 0x08).toBe(0x08);610    expect(vault.getEntries().find((candidate) => candidate.id === entry.id)!.passkey!.backupEligible).toBe(true);611  });612613  it("get passes through to platform when the stored passkey has no private key (Firefox-created)", async () => {614    const vault = await import("./vault");615    await vault.createVault("correct-pass-12345");616    const { base64urlEncode, generatePasskeyKeypair } = await import("./passkey-crypto");617    const generated = await generatePasskeyKeypair(-7);618    await vault.addEntry({619      type: "login",620      title: "Cross-browser passkey",621      passkey: {622        rpId: "webauthn.io",623        rpName: "WebAuthn.io",624        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),625        publicKeyCose: base64urlEncode(generated.publicKeyCose),626        // privateKeyJwk intentionally absent — pre-v2.12 Firefox passkey627        // delegated signing to the platform authenticator.628        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),629        userName: "june",630        userDisplayName: "June",631        algorithm: -7,632        signCount: 0,633        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",634        backupEligible: false,635        backupState: false,636        browser: "firefox",637        transports: ["internal"],638        createdAt: Date.now(),639      },640    });641642    fireGet({ rpId: "webauthn.io", challenge: "Y2g", allowCredentials: [] }, 203);643    const done = await waitForGetComplete(203);644    // Pass-through: no error and no responseJson — Chrome routes to next authenticator.645    expect(done.error).toBeUndefined();646    expect(done.responseJson).toBeUndefined();647  });648649  it("get on a locked vault parks the request and proceeds after unlock", async () => {650    const vault = await import("./vault");651    await vault.createVault("correct-pass-12345");652    await seedPasskey("github.com");653    vault.lock();654655    autoApprove();656    fireGet({ rpId: "github.com", challenge: "Y2g", allowCredentials: [] }, 204);657658    await new Promise((r) => setTimeout(r, 50));659    expect(tabsSendMessage).toHaveBeenCalledWith(660      1,661      expect.objectContaining({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" })662    );663664    await vault.unlock("correct-pass-12345");665    const passkey = await import("./passkey");666    passkey.onVaultUnlocked();667668    const done = await waitForGetComplete(204);669    expect(done.error).toBeUndefined();670    expect(done.responseJson).toBeDefined();671  });672673  // ─────────────────────────────────────────────────────────────────────674  // onRequestCanceled — popup-closed / RP-cancelled cleanup.675  // ─────────────────────────────────────────────────────────────────────676677  // ─────────────────────────────────────────────────────────────────────678  // "Invalid requestId" survival — conditional-UI gets cancel fast.679  // ─────────────────────────────────────────────────────────────────────680681  it("pass-through swallows 'Invalid requestId' from a cancelled conditional-UI get without throwing", async () => {682    const vault = await import("./vault");683    await vault.createVault("correct-pass-12345");684685    // Simulate Chrome cancelling the request between handleGetRequest running686    // and the pass-through completeGetRequest call landing.687    const ce = (globalThis as { chrome: typeof chrome }).chrome.webAuthenticationProxy688      .completeGetRequest as unknown as ReturnType<typeof vi.fn>;689    ce.mockImplementationOnce(async () => {690      throw new Error("Invalid requestId");691    });692693    // No matching passkey for this rpId → pass-through completeGetRequest call,694    // which must NOT propagate the Invalid requestId error.695    let resolved = false;696    proxyListeners.get!({697      requestId: 999,698      requestDetailsJson: JSON.stringify({ rpId: "unknown.example", challenge: "x", allowCredentials: [] }),699    });700    // Allow the handler to finish; if it threw, the test would unhandle-reject.701    await new Promise((r) => setTimeout(r, 30));702    resolved = true;703    expect(resolved).toBe(true);704  });705706  it("rejectCreate swallows 'Invalid requestId' from a cancelled create", async () => {707    const vault = await import("./vault");708    await vault.createVault("correct-pass-12345");709710    const ce = (globalThis as { chrome: typeof chrome }).chrome.webAuthenticationProxy711      .completeCreateRequest as unknown as ReturnType<typeof vi.fn>;712    ce.mockImplementationOnce(async () => {713      throw new Error("Invalid requestId");714    });715716    // Force the early rp.id missing path so rejectCreate runs first.717    fireCreate({ rp: {}, user: { id: "x", name: "u" }, challenge: "c", pubKeyCredParams: [{ alg: -7, type: "public-key" }] }, 998);718    await new Promise((r) => setTimeout(r, 30));719    // No assertions on completes — the assertion is that the handler did720    // not throw an unhandled rejection (the test would fail under vitest's721    // unhandled-rejection guard otherwise).722  });723724  it("onRequestCanceled clears a parked unlock waiter so the handler exits promptly", async () => {725    const vault = await import("./vault");726    await vault.createVault("correct-pass-12345");727    vault.lock();728729    fireCreate(buildCreateOptions(), 997);730    // Wait for the request to park.731    await new Promise((r) => setTimeout(r, 30));732733    proxyListeners.cancel!(997);734735    const done = await waitForCreateComplete(997, 500);736    expect(done.error).toMatchObject({ name: "NotAllowedError" });737  });738739  it("onRequestCanceled resolves a still-pending approval as denied", async () => {740    const vault = await import("./vault");741    await vault.createVault("correct-pass-12345");742743    fireCreate(buildCreateOptions(), 301);744745    // Wait for the approval to be registered.746    const passkey = await import("./passkey");747    let pending: Awaited<ReturnType<typeof passkey.getPendingPasskeyRequest>> = null;748    for (let i = 0; i < 50; i += 1) {749      pending = await passkey.getPendingPasskeyRequest();750      if (pending) break;751      await new Promise((r) => setTimeout(r, 10));752    }753    expect(pending).toBeTruthy();754755    proxyListeners.cancel!(301);756757    // After cancel the pending session record should be cleared.758    for (let i = 0; i < 50; i += 1) {759      const after = await passkey.getPendingPasskeyRequest();760      if (!after) return;761      await new Promise((r) => setTimeout(r, 10));762    }763    throw new Error("pending passkey request was not cleared after onRequestCanceled");764  });765});