vault.consolidate-passkeys.test.ts

8.9 KB · raw

1// T-023: vault.consolidatePasskeyEntries — passkey merge with lastUsed ranking2//3// Why this test exists: `consolidatePasskeyEntries()` (exposed via the4// CONSOLIDATE_PASSKEYS message) calls `mergePasskeyOnlyIntoLogins()`, which:5//   1. Groups all passkey-bearing entries by rpId.6//   2. Within each group, sorts by lastUsedAt (then createdAt, then modifiedAt).7//   3. The "winner" (most-recently-used) passkey is merged into the existing8//      non-passkey login for the same rpId domain (if one exists).9//   4. If no login exists but multiple passkey entries exist for the same rpId,10//      the loser entries are soft-deleted (winner kept).11//12// This function is called on every passkey registration (via REGISTER_PASSKEY)13// and has ZERO direct test coverage. A regression in the sort order would14// incorrectly merge an older passkey, losing a newer one — a silent security15// regression.1617import { beforeEach, describe, expect, it, vi } from "vitest";1819type StorageShape = Record<string, unknown>;2021function makeChromeStorageMock() {22  const storage: StorageShape = {};23  const chrome = {24    storage: {25      local: {26        get: vi.fn(async (key?: string | string[]) => {27          if (!key) return { ...storage };28          if (typeof key === "string") return { [key]: storage[key] };29          return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(30            (acc, k) => { acc[k] = storage[k]; return acc; },31            {},32          );33        }),34        set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),35        remove: vi.fn(async (keys: string | string[]) => {36          const ks = Array.isArray(keys) ? keys : [keys];37          for (const k of ks) delete storage[k];38        }),39      },40      session: {41        get: vi.fn(async () => ({})),42        set: vi.fn(async () => {}),43        remove: vi.fn(async () => {}),44      },45    },46    alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },47    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },48  } as unknown as typeof globalThis.chrome;49  return { storage, chrome };50}5152// Minimal passkey object for test entries53function makePasskey(opts: {54  rpId: string;55  credentialId: string;56  lastUsedAt?: number;57  createdAt?: number;58}) {59  return {60    rpId: opts.rpId,61    rpName: opts.rpId,62    credentialId: opts.credentialId,63    publicKeyCose: "a401020326",64    privateKeyJwk: { kty: "EC", crv: "P-256", d: "test", x: "test", y: "test" },65    userHandle: "uh",66    userName: opts.rpId + "-user",67    userDisplayName: opts.rpId + " User",68    algorithm: -7 as const,69    signCount: 0,70    aaguid: "AAAAAAAAAAAAAAAAAAAAAA",71    backupEligible: false,72    backupState: false,73    createdAt: opts.createdAt ?? 1000,74    lastUsedAt: opts.lastUsedAt,75  };76}7778describe("T-023: vault.consolidatePasskeyEntries", () => {79  let vault: typeof import("./vault");8081  beforeEach(async () => {82    vi.resetModules();83    const mock = makeChromeStorageMock();84    (globalThis as Record<string, unknown>).chrome = mock.chrome;85    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));8687    vault = await import("./vault");88    await vault.createVault("consolidate-test-pass");89  });9091  // T-023a: passkey entry for a domain + login entry for the same domain →92  // consolidatePasskeyEntries() merges the passkey onto the login entry and93  // soft-deletes the passkey-only entry.94  //95  // Bug this catches: if the rpId → URL domain match is broken (e.g., wrong96  // extractDomain call), the passkey would never merge and the user would see97  // duplicate entries.98  it("T-023a: passkey-only entry merges into existing login with matching domain", async () => {99    // Add a login entry for example.com100    const login = await vault.addEntry({101      type: "login",102      title: "Example Login",103      username: "[email protected]",104      password: "pw123",105      url: "https://example.com/login",106    });107108    // Add a passkey-only entry for the same rpId109    const passkeyEntry = await vault.addEntry({110      type: "login",111      title: "Example Passkey",112      username: "[email protected]",113      password: "",114      url: "https://example.com",115      passkey: makePasskey({ rpId: "example.com", credentialId: "cred-001" }),116    });117118    await vault.consolidatePasskeyEntries();119120    const entries = vault.getEntries();121    const loginAfter = entries.find((e) => e.id === login.id);122    const passkeyAfter = vault.getEntryById(passkeyEntry.id);123124    // The login must now have the passkey merged in125    expect(loginAfter?.passkey).toBeDefined();126    expect(loginAfter?.passkey?.credentialId).toBe("cred-001");127128    // The passkey-only entry must be soft-deleted (not in active entries)129    expect(passkeyAfter).toBeUndefined();130  });131132  it("preserves TOTP when the passkey entry is consolidated into a login", async () => {133    const login = await vault.addEntry({134      type: "login",135      title: "Example Login",136      username: "[email protected]",137      password: "pw123",138      url: "https://example.com/login",139    });140    const passkeyEntry = await vault.addEntry({141      type: "login",142      title: "Example Passkey",143      username: "[email protected]",144      password: "",145      url: "https://example.com",146      totp: "JBSWY3DPEHPK3PXP",147      totpAlgorithm: "SHA256",148      totpDigits: 8,149      totpPeriod: 60,150      passkey: makePasskey({ rpId: "example.com", credentialId: "cred-totp" }),151    });152153    await vault.consolidatePasskeyEntries();154155    expect(vault.getEntryById(login.id)).toMatchObject({156      totp: "JBSWY3DPEHPK3PXP",157      totpAlgorithm: "SHA256",158      totpDigits: 8,159      totpPeriod: 60,160    });161    expect(vault.getEntryById(passkeyEntry.id)).toBeUndefined();162  });163164  // T-023b: two passkey entries for the same rpId, no login → winner is kept165  // (by lastUsedAt), loser is soft-deleted.166  //167  // Bug this catches: an incorrect sort that picks the OLDER passkey as the168  // winner would retain a stale credential and soft-delete the current one.169  it("T-023b: two passkeys for same rpId → most recently used is kept, other soft-deleted", async () => {170    const olderPasskey = await vault.addEntry({171      type: "login",172      title: "Old Passkey",173      username: "user",174      password: "",175      url: "https://github.com",176      passkey: makePasskey({177        rpId: "github.com",178        credentialId: "cred-old",179        lastUsedAt: 1000,180        createdAt: 1000,181      }),182    });183184    const newerPasskey = await vault.addEntry({185      type: "login",186      title: "New Passkey",187      username: "user",188      password: "",189      url: "https://github.com",190      passkey: makePasskey({191        rpId: "github.com",192        credentialId: "cred-new",193        lastUsedAt: 9999,194        createdAt: 9999,195      }),196    });197198    await vault.consolidatePasskeyEntries();199200    const entries = vault.getEntries();201202    // The newer passkey must still be in active entries203    const keptEntry = entries.find((e) =>204      e.passkey?.credentialId === "cred-new" && !e.deletedAt,205    );206    expect(keptEntry).toBeDefined();207208    // The older passkey must be soft-deleted209    const deletedEntry = vault.getEntryById(olderPasskey.id);210    expect(deletedEntry).toBeUndefined();211212    // Void reference to suppress unused var warning213    void newerPasskey;214  });215216  // T-023c: passkey entry for a domain with no matching login → it stays217  // (is not merged or deleted). consolidatePasskeyEntries must not touch218  // single-passkey, no-login groups.219  it("T-023c: single passkey with no matching login → entry is not deleted", async () => {220    const passkey = await vault.addEntry({221      type: "login",222      title: "Solo Passkey",223      username: "user",224      password: "",225      url: "https://uniquesite.example.com",226      passkey: makePasskey({ rpId: "uniquesite.example.com", credentialId: "cred-solo" }),227    });228229    await vault.consolidatePasskeyEntries();230231    const after = vault.getEntryById(passkey.id);232    expect(after).toBeDefined();233    expect(after?.passkey?.credentialId).toBe("cred-solo");234  });235236  // T-023d: passkey on a different domain than any login → no merge.237  // The domain match must use exact domain extraction, not substring matching.238  // A passkey for "signin.google.com" must not absorb a login for "google.com".239  it("T-023d: passkey domain mismatch → no merge (exact domain match, not substring)", async () => {240    await vault.addEntry({241      type: "login",242      title: "Google Login",243      username: "user",244      password: "pw",245      url: "https://google.com",246    });247248    const signinPasskey = await vault.addEntry({249      type: "login",250      title: "Google Signin Passkey",251      username: "user",252      password: "",253      url: "https://signin.google.com",254      passkey: makePasskey({ rpId: "signin.google.com", credentialId: "cred-signin" }),255    });256257    await vault.consolidatePasskeyEntries();258259    // The passkey-only entry must remain (no login for "signin.google.com")260    const after = vault.getEntryById(signinPasskey.id);261    expect(after).toBeDefined();262  });263});