vault.card-decline.test.ts

8.5 KB · raw

1// PB-26-108: declined-card metadata must not be offline-enumerable.2//3// `peach_settings` lives unencrypted in chrome.storage.local, and on Firefox a4// content script can read it directly. The declined-card record was a truncated5// SHA-256 over `last4|expMonth|expYear` "salted" with a per-vault random value6// stored right next to it. A stored salt defeats precomputed tables, not an7// attacker holding the file: the candidate space is ~10^4 last-four values x 128// months x a handful of years, so the card's last four and expiry were9// recoverable by brute force in seconds.10//11// The fix keys the digest with material that is never at rest — a value derived12// from the vault root key, available only while unlocked. Storage alone no13// longer contains the inputs needed to recompute a record, so enumeration has14// nothing to enumerate against.1516import { beforeEach, describe, expect, it, vi } from "vitest";1718type StorageShape = Record<string, unknown>;1920function installChromeMock(storage: StorageShape, sessionStorage: StorageShape) {21  const chrome = {22    storage: {23      local: {24        get: vi.fn(async (key?: string | string[]) => {25          if (!key) return { ...storage };26          const keys = Array.isArray(key) ? key : [key];27          return keys.reduce<StorageShape>((acc, k) => {28            acc[k] = storage[k];29            return acc;30          }, {});31        }),32        set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),33        remove: vi.fn(async (keys: string | string[]) => {34          for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];35        }),36      },37      session: {38        get: vi.fn(async () => ({ ...sessionStorage })),39        set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }),40        remove: vi.fn(async () => {}),41      },42    },43    alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },44    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },45    runtime: {46      id: "test-extension-id",47      getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),48    },49  } as unknown as typeof globalThis.chrome;50  (globalThis as Record<string, unknown>).chrome = chrome;51}5253/** The pre-fix record: unsalted, truncated SHA-256 over the card's own fields. */54async function legacyUnsaltedFingerprint(last4: string, month: string, year: string): Promise<string> {55  const buf = await crypto.subtle.digest(56    "SHA-256",57    new TextEncoder().encode(`${last4}|${month}|${year}`),58  );59  return Array.from(new Uint8Array(buf).slice(0, 8))60    .map((b) => b.toString(16).padStart(2, "0"))61    .join("");62}6364describe("PB-26-108: declined-card metadata at rest", () => {65  let storage: StorageShape;66  let sessionStorage: StorageShape;6768  beforeEach(() => {69    vi.resetModules();70    storage = {};71    sessionStorage = {};72    installChromeMock(storage, sessionStorage);73    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));74  });7576  async function freshVault(password: string) {77    const vault = await import("./vault");78    await vault.createVault(password);79    return vault;80  }8182  function persistedSettings(): Record<string, unknown> {83    return storage.peach_settings as Record<string, unknown>;84  }8586  it("persists no plaintext domain ledger and no card-fingerprint salt", async () => {87    const vault = await freshVault("card-meta-pass-12345");88    await vault.declineCardSaveOffer("4242", "12", "2029");8990    const blob = persistedSettings();91    expect(blob.manualSummonDomains).toBeUndefined();92    expect(blob.manualSummonSalt).toBeUndefined();93    expect(blob.cardFingerprintSalt).toBeUndefined();94    expect(blob.declinedCardFingerprints).toHaveLength(1);95  });9697  // The enumeration attack, run with nothing but the contents of storage. The98  // attacker enumerates every last-four value for the card's real expiry and99  // keys the digest with whatever storage offers — the stored salt under the100  // old scheme, nothing at all under the new one.101  //102  // Bug this catches: restoring a stored `cardFingerprintSalt` next to the103  // records makes this loop terminate on "4242", which is exactly the104  // ~1.2M-candidate offline recovery the finding is about.105  it("a declined card cannot be recovered by brute force from storage alone", async () => {106    const vault = await freshVault("card-brute-pass-12345");107    await vault.declineCardSaveOffer("4242", "12", "2029");108109    const blob = persistedSettings();110    const [stored] = blob.declinedCardFingerprints as string[];111    const storedSalt = blob.cardFingerprintSalt as string | undefined;112    expect(stored).toBeDefined();113114    const digest = async (value: string, salt: string) => {115      const buf = await crypto.subtle.digest(116        "SHA-256",117        new TextEncoder().encode(`${salt}:${value}`),118      );119      return Array.from(new Uint8Array(buf).slice(0, 16))120        .map((b) => b.toString(16).padStart(2, "0"))121        .join("");122    };123124    for (let candidate = 0; candidate < 10_000; candidate += 1) {125      const last4 = candidate.toString().padStart(4, "0");126      const fingerprint = await legacyUnsaltedFingerprint(last4, "12", "2029");127      const guess = storedSalt ? await digest(fingerprint, storedSalt) : fingerprint;128      expect(guess).not.toBe(stored);129    }130  });131132  // Same card, different vault (therefore different root key) — the record is133  // a function of secret material, not of the card.134  it("the same card records a different value under a different root key", async () => {135    const first = await freshVault("card-key-a-pass-12345");136    await first.declineCardSaveOffer("4242", "12", "2029");137    const firstRecord = (persistedSettings().declinedCardFingerprints as string[])[0];138139    vi.resetModules();140    storage = {};141    sessionStorage = {};142    installChromeMock(storage, sessionStorage);143    const second = await freshVault("card-key-b-pass-12345");144    await second.declineCardSaveOffer("4242", "12", "2029");145    const secondRecord = (persistedSettings().declinedCardFingerprints as string[])[0];146147    expect(firstRecord).toBeDefined();148    expect(secondRecord).toBeDefined();149    expect(secondRecord).not.toBe(firstRecord);150  });151152  it("a legacy unsalted record is no longer accepted", async () => {153    const vault = await freshVault("card-legacy-pass-12345");154    // Materialise the settings blob with an unrelated decline, then plant a155    // legacy-format record the way an upgraded profile would carry one.156    await vault.declineCardSaveOffer("1111", "01", "2027");157    const legacy = await legacyUnsaltedFingerprint("4242", "12", "2029");158    (persistedSettings().declinedCardFingerprints as string[]).push(legacy);159160    expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(false);161  });162163  it("a legacy unsalted record is pruned on the next decline", async () => {164    const vault = await freshVault("card-prune-pass-12345");165    const legacy = await legacyUnsaltedFingerprint("1111", "01", "2027");166    await vault.declineCardSaveOffer("4242", "12", "2029");167    (persistedSettings().declinedCardFingerprints as string[]).unshift(legacy);168169    await vault.declineCardSaveOffer("9999", "06", "2030");170171    const records = persistedSettings().declinedCardFingerprints as string[];172    expect(records).not.toContain(legacy);173    expect(records).toHaveLength(2);174    // The genuine declines survive the prune.175    expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true);176    expect(await vault.isCardDeclined("9999", "06", "2030")).toBe(true);177  });178179  // Locked means the root key is gone, so no record can be checked. Offers are180  // not shown while locked, so this is not a functional regression.181  it("declines cannot be evaluated while the vault is locked", async () => {182    const vault = await freshVault("card-locked-pass-12345");183    await vault.declineCardSaveOffer("4242", "12", "2029");184    expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true);185186    vault.lock();187188    expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(false);189  });190191  // The feature itself must still work: a declined card stays declined for192  // exactly that card.193  it("still distinguishes cards by last four and expiry", async () => {194    const vault = await freshVault("card-distinct-pass-12345");195    await vault.declineCardSaveOffer("4242", "12", "2029");196197    expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true);198    expect(await vault.isCardDeclined("4242", "11", "2029")).toBe(false);199    expect(await vault.isCardDeclined("1111", "12", "2029")).toBe(false);200  });201});