vault.test.ts

48.8 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import { decrypt } from "./aes";3import { deriveKey } from "../crypto/argon2";4import type { StoredVault } from "../shared/types";56type StorageShape = Record<string, unknown>;78describe("background vault", () => {9  let storage: StorageShape;10  let alarms = { created: [] as Array<{ name: string; delayInMinutes: number }>, cleared: [] as string[] };1112  beforeEach(() => {13    vi.resetModules();14    storage = {};15    alarms = { created: [], cleared: [] };1617    const chromeMock = {18      storage: {19        local: {20          get: vi.fn(async (key?: string | string[]) => {21            if (!key) return storage;22            if (typeof key === "string") {23              return { [key]: storage[key] };24            }2526            return key.reduce<Record<string, unknown>>((result, entry) => {27              result[entry] = storage[entry];28              return result;29            }, {});30          }),31          set: vi.fn(async (next: Record<string, unknown>) => {32            Object.assign(storage, next);33          }),34          remove: vi.fn(async (keys: string | string[]) => {35            const list = Array.isArray(keys) ? keys : [keys];36            for (const key of list) {37              delete storage[key];38            }39          }),40        },41      },42      alarms: {43        clear: vi.fn((name: string) => {44          alarms.cleared.push(name);45        }),46        create: vi.fn((name: string, info: { delayInMinutes: number }) => {47          alarms.created.push({ name, delayInMinutes: info.delayInMinutes });48        }),49        onAlarm: { addListener: vi.fn() },50      },51      idle: {52        setDetectionInterval: vi.fn(),53        onStateChanged: { addListener: vi.fn() },54      },55    } as unknown as typeof chrome;5657    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;5859    // Prevent fire-and-forget breach-check fetches from leaking into other tests.60    vi.stubGlobal("fetch", vi.fn(async () => {61      throw new Error("network disabled in tests");62    }));63  });6465  it("creates a vault and stores encrypted payload", async () => {66    const vault = await import("./vault");6768    await vault.createVault("correct horse battery staple");6970    expect(storage.peach_vault_a).toBeDefined();71    expect((storage.peach_vault_a as StoredVault).ciphertext).toBeTruthy();72    expect(vault.getEntries()).toEqual([]);73    expect(alarms.created).toContainEqual({74      name: "autolock",75      delayInMinutes: 5,76    });77  });7879  it("writes both slots A and B on vault creation", async () => {80    const vault = await import("./vault");81    await vault.createVault("correct horse battery staple");8283    expect(storage.peach_vault_a).toBeDefined();84    expect(storage.peach_vault_b).toBeDefined();85    expect(storage.peach_duress).toBeUndefined();86    expect(storage.peach_vault).toBeUndefined();87    expect(storage.peach_decoy_vault).toBeUndefined();88  });8990  it("slot A and slot B have matching ciphertext size at fresh install", async () => {91    const vault = await import("./vault");92    await vault.createVault("correct horse battery staple");9394    const a = storage.peach_vault_a as StoredVault;95    const b = storage.peach_vault_b as StoredVault;96    expect(a.ciphertext.length).toBe(b.ciphertext.length);97  });9899  it("does not expose a plaintext duress flag after setupDuress", async () => {100    const vault = await import("./vault");101    await vault.createVault("real-password-12345");102    await vault.setupDuress("decoy-password-67890");103104    expect(storage.peach_duress).toBeUndefined();105    expect(storage.peach_decoy_vault).toBeUndefined();106    const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));107    // Slot-neutral keys + optional settings + vault history (snapshot of the108    // pre-setupDuress real-mode blob). No exposed duress flag.109    for (const key of keys) {110      expect(111        [112          "peach_vault_a",113          "peach_vault_b",114          "peach_settings",115          "peach_vault_history",116          "peach_last_modified_at",117        ]118      ).toContain(key);119    }120    expect(keys).toContain("peach_vault_a");121    expect(keys).toContain("peach_vault_b");122  });123124  it("unlocks with real password and loads real entries", async () => {125    const vault = await import("./vault");126    await vault.createVault("real-password-12345");127    await vault.addEntry({128      title: "GitHub",129      username: "june",130      password: "realsecret",131      url: "github.com",132    });133    vault.lock();134135    const ok = await vault.unlock("real-password-12345");136    expect(ok).toBe(true);137    expect(vault.getEntries()).toHaveLength(1);138    expect(vault.getEntries()[0].title).toBe("GitHub");139  });140141  it("unlocks with duress password and loads empty entries", async () => {142    const vault = await import("./vault");143    await vault.createVault("real-password-12345");144    await vault.addEntry({145      title: "GitHub",146      username: "june",147      password: "realsecret",148      url: "github.com",149    });150    await vault.setupDuress("decoy-password-67890");151    vault.lock();152153    const ok = await vault.unlock("decoy-password-67890");154    expect(ok).toBe(true);155    expect(vault.getEntries()).toEqual([]);156  });157158  it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {159    // The unlock path must derive keys for both slots unconditionally so a160    // real-mode unlock and a decoy-mode unlock both spend roughly the same161    // wall-clock time. Verified here by spying on deriveKey and asserting it162    // is called twice (once per slot) on every unlock attempt.163    const argon2 = await import("../crypto/argon2");164    const deriveSpy = vi.spyOn(argon2, "deriveKey");165166    const vault = await import("./vault");167    await vault.createVault("real-password-12345");168    await vault.setupDuress("decoy-password-67890");169    vault.lock();170171    deriveSpy.mockClear();172    await vault.unlock("real-password-12345");173    expect(deriveSpy).toHaveBeenCalledTimes(2);174    vault.lock();175176    deriveSpy.mockClear();177    await vault.unlock("decoy-password-67890");178    expect(deriveSpy).toHaveBeenCalledTimes(2);179    vault.lock();180181    deriveSpy.mockClear();182    await vault.unlock("wrong-password-xxxxx");183    expect(deriveSpy).toHaveBeenCalledTimes(2);184185    deriveSpy.mockRestore();186  });187188  it("rejects wrong passwords", async () => {189    const vault = await import("./vault");190    await vault.createVault("real-password-12345");191    vault.lock();192193    const ok = await vault.unlock("nope-nope-nope");194    expect(ok).toBe(false);195  });196197  it("persists duressConfigured across lock/unlock in encrypted payload", async () => {198    const vault = await import("./vault");199    await vault.createVault("real-password-12345");200    await vault.setupDuress("decoy-password-67890");201    vault.lock();202203    await vault.unlock("real-password-12345");204    expect(await vault.isDuressEnabled()).toBe(true);205206    await vault.disableDuress();207    expect(await vault.isDuressEnabled()).toBe(false);208    vault.lock();209210    await vault.unlock("real-password-12345");211    expect(await vault.isDuressEnabled()).toBe(false);212  });213214  it("migrates legacy peach_vault / peach_decoy_vault / peach_duress schema", async () => {215    // Arrange: set up storage as if from pre-A-2 (v1.4.24 and earlier).216    const firstVault = await import("./vault");217    await firstVault.createVault("legacy-password-abcde");218    const legacySlotA = storage.peach_vault_a as StoredVault;219    storage.peach_vault = legacySlotA;220    delete storage.peach_vault_a;221    delete storage.peach_vault_b;222    storage.peach_duress = { enabled: true };223    storage.peach_decoy_vault = legacySlotA; // reuse shape for test224    vi.resetModules();225226    // Act: fresh module load triggers migration on next vault access.227    const vault = await import("./vault");228    await vault.hasVault();229230    // Assert: legacy keys removed, new keys present.231    expect(storage.peach_vault).toBeUndefined();232    expect(storage.peach_decoy_vault).toBeUndefined();233    expect(storage.peach_duress).toBeUndefined();234    expect(storage.peach_vault_a).toBeDefined();235    expect(storage.peach_vault_b).toBeDefined();236  });237238  it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {239    const vault = await import("./vault");240241    await vault.createVault("correct horse battery staple");242    const entry = await vault.addEntry({243      title: "GitHub",244      username: "june",245      password: "secret123",246      url: "github.com",247    });248249    await vault.deleteEntry(entry.id);250251    expect(vault.getEntries()).toEqual([]);252253    const stored = storage.peach_vault_a as StoredVault;254    const salt = Uint8Array.from(255      stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))256    );257    const key = await deriveKey("correct horse battery staple", salt);258    const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");259    const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };260261    expect(parsed.entries).toHaveLength(1);262    expect(parsed.entries[0].id).toBe(entry.id);263    expect(typeof parsed.entries[0].deletedAt).toBe("number");264  });265266  it("accepts imported entries without a username or url", async () => {267    const vault = await import("./vault");268269    await vault.createVault("correct horse battery staple");270    const entry = await vault.addEntry({271      title: "Imported Login",272      username: "",273      password: "secret123",274      url: "",275      notes: "migrated from another manager",276    });277278    expect(entry.username).toBe("");279    expect(entry.url).toBe("");280    expect(vault.getEntries()).toHaveLength(1);281  });282283  it("changePassword happy path — new password unlocks, old is rejected", async () => {284    const vault = await import("./vault");285286    await vault.createVault("old-pass-abc123");287    await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });288    await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });289290    expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);291292    vault.lock();293    expect(await vault.unlock("new-pass-xyz789")).toBe(true);294    expect(vault.getEntries()).toHaveLength(2);295296    vault.lock();297    expect(await vault.unlock("old-pass-abc123")).toBe(false);298  });299300  it("stamps peach_last_modified_at on createVault", async () => {301    const vault = await import("./vault");302    const before = Date.now();303    await new Promise((resolve) => setTimeout(resolve, 1));304305    await vault.createVault("stamp-create-pass");306307    expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);308  });309310  it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {311    const vault = await import("./vault");312    await vault.createVault("stamp-entry-pass");313314    let previous = Number(storage.peach_last_modified_at);315    await new Promise((resolve) => setTimeout(resolve, 1));316    const entry = await vault.addEntry({317      title: "Stamp",318      username: "",319      password: "p",320      url: "",321    });322    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);323324    previous = Number(storage.peach_last_modified_at);325    await new Promise((resolve) => setTimeout(resolve, 1));326    await vault.updateEntry(entry.id, { title: "Stamped" });327    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);328329    previous = Number(storage.peach_last_modified_at);330    await new Promise((resolve) => setTimeout(resolve, 1));331    await vault.deleteEntry(entry.id);332    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);333  });334335  it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {336    const vault = await import("./vault");337    await vault.createVault("stamp-real-pass");338339    let previous = Number(storage.peach_last_modified_at);340    await new Promise((resolve) => setTimeout(resolve, 1));341    await vault.setupDuress("stamp-decoy-pass");342    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);343344    previous = Number(storage.peach_last_modified_at);345    await new Promise((resolve) => setTimeout(resolve, 1));346    expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);347    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);348  });349350  it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {351    const vault = await import("./vault");352353    await vault.createVault("correct-pass-abcde");354    const slotABefore = JSON.stringify(storage.peach_vault_a);355356    expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);357    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);358359    vault.lock();360    expect(await vault.unlock("correct-pass-abcde")).toBe(true);361  });362363  it("changePassword with duress configured leaves slot B byte-identical", async () => {364    const vault = await import("./vault");365366    await vault.createVault("real-pass-abcde");367    await vault.setupDuress("duress-pass-xyzzz");368369    const slotBBefore = JSON.stringify(storage.peach_vault_b);370    expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);371    expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);372373    // Duress path still works with the old decoy password.374    vault.lock();375    expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);376    expect(vault.getEntries()).toEqual([]);377  });378379  it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {380    const vault = await import("./vault");381382    await vault.createVault("pass-abcdef");383    await vault.addEntry({ title: "One", username: "", password: "1", url: "" });384    await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });385    await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });386387    await expect(vault.restoreEntries([], "replace")).rejects.toThrow(388      /refuse to replace with empty entry set/i389    );390    expect(vault.getEntries()).toHaveLength(3);391  });392393  it("changePassword swaps the active key before Slot B is regenerated", async () => {394    const vault = await import("./vault");395396    await vault.createVault("correct-pass-12345");397    const entry = await vault.addEntry({398      title: "GitHub",399      username: "june",400      password: "secret123",401      url: "https://github.com",402    });403404    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;405    const originalSet = setMock.getMockImplementation();406    let injectedDuringSlotBWrite = false;407408    setMock.mockImplementation(async (next: Record<string, unknown>) => {409      Object.assign(storage, next);410411      if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {412        injectedDuringSlotBWrite = true;413        await vault.updateEntry(entry.id, { notes: "updated during password change" });414      }415    });416417    expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);418419    setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {420      Object.assign(storage, next);421    }));422423    vault.lock();424    expect(await vault.unlock("new-pass-67890")).toBe(true);425    expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");426427    vault.lock();428    expect(await vault.unlock("correct-pass-12345")).toBe(false);429  });430431  it("restoreEntries replace mode replaces existing entries", async () => {432    const vault = await import("./vault");433434    await vault.createVault("replace-pass-12345");435    await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });436    await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });437438    const incoming = [439      {440        id: "entry-c",441        type: "login" as const,442        title: "C",443        username: "june",444        password: "three",445        url: "https://c.test",446        createdAt: 10,447        modifiedAt: 10,448      },449      {450        id: "entry-d",451        type: "login" as const,452        title: "D",453        username: "june",454        password: "four",455        url: "https://d.test",456        createdAt: 20,457        modifiedAt: 20,458      },459    ];460461    await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({462      added: 2,463      updated: 0,464      skipped: 0,465    });466467    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);468  });469470  it("restoreEntries merge mode lets newer incoming entries win", async () => {471    const vault = await import("./vault");472473    await vault.createVault("merge-pass-12345");474    const existing = await vault.addEntry({475      title: "GitHub",476      username: "june",477      password: "old-secret",478      url: "https://github.com",479    });480481    await expect(482      vault.restoreEntries(483        [484          {485            ...existing,486            password: "new-secret",487            modifiedAt: existing.modifiedAt + 100,488          },489        ],490        "merge"491      )492    ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });493494    expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");495  });496497  it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {498    const vault = await import("./vault");499500    await vault.createVault("merge-pass-67890");501    const existing = await vault.addEntry({502      title: "GitHub",503      username: "june",504      password: "current-secret",505      url: "https://github.com",506    });507508    await expect(509      vault.restoreEntries(510        [511          {512            ...existing,513            password: "stale-secret",514            modifiedAt: existing.modifiedAt - 100,515          },516        ],517        "merge"518      )519    ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });520521    expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");522  });523524  it("clearVaultEntries without explicit confirmation throws", async () => {525    const vault = await import("./vault");526527    await vault.createVault("clear-pass-12345");528    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });529530    const clearVaultEntries = vault.clearVaultEntries as unknown as (531      confirmation?: unknown532    ) => Promise<void>;533534    await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);535    await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);536    expect(vault.getEntries()).toHaveLength(1);537  });538539  it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {540    const vault = await import("./vault");541542    await vault.createVault("clear-pass-67890");543    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });544    await vault.addEntry({ title: "B", username: "", password: "two", url: "" });545    await vault.addEntry({ title: "C", username: "", password: "three", url: "" });546547    await vault.clearVaultEntries("REPLACE_ALL");548549    expect(vault.getEntries()).toEqual([]);550    expect(vault.getDeletedEntries()).toHaveLength(3);551    expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);552  });553554  it("deduplicateEntries throws in decoy mode", async () => {555    const vault = await import("./vault");556557    await vault.createVault("real-pass-12345");558    await vault.addEntry({559      title: "GitHub",560      username: "june",561      password: "secret123",562      url: "https://github.com",563    });564    await vault.setupDuress("duress-pass-12345");565    vault.lock();566567    expect(await vault.unlock("duress-pass-12345")).toBe(true);568    await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);569  });570571  it("deduplicateEntries removes older duplicates in real mode", async () => {572    const vault = await import("./vault");573574    await vault.createVault("dedupe-pass-12345");575    await vault.addEntry({576      title: "GitHub",577      username: "june",578      password: "old-secret",579      url: "https://github.com",580    });581    await new Promise<void>((resolve) => setTimeout(resolve, 5));582    const newer = await vault.addEntry({583      title: "GitHub",584      username: "june",585      password: "new-secret",586      url: "https://github.com",587    });588589    expect(await vault.deduplicateEntries()).toBe(1);590    expect(vault.getEntries()).toHaveLength(1);591    expect(vault.getEntries()[0].id).toBe(newer.id);592    expect(vault.getDeletedEntries()).toHaveLength(1);593  });594595  it("updateEntry with an empty password on a login entry clears the password", async () => {596    const vault = await import("./vault");597598    await vault.createVault("update-pass-12345");599    const entry = await vault.addEntry({600      title: "GitHub",601      username: "june",602      password: "secret123",603      url: "https://github.com",604    });605606    await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined();607    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();608  });609610  it("updateEntry with an empty password on a note entry is allowed", async () => {611    const vault = await import("./vault");612613    await vault.createVault("note-pass-12345");614    const entry = await vault.addEntry({615      type: "note",616      title: "Secure note",617      notes: "keep me",618    });619620    await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();621    expect(vault.getEntryById(entry.id)).toMatchObject({622      type: "note",623      notes: "updated",624    });625  });626627  it("lock zeroes the derived key and resets unlock state", async () => {628    const aes = await import("./aes");629    const zeroKeySpy = vi.spyOn(aes, "zeroKey");630    const vault = await import("./vault");631632    await vault.createVault("lock-pass-12345");633    expect(vault.isUnlocked()).toBe(true);634635    vault.lock();636637    expect(zeroKeySpy).toHaveBeenCalled();638    expect(vault.isUnlocked()).toBe(false);639    zeroKeySpy.mockRestore();640  });641642  it("lock clears in-memory entries", async () => {643    const vault = await import("./vault");644645    await vault.createVault("lock-pass-67890");646    const entry = await vault.addEntry({647      title: "GitHub",648      username: "june",649      password: "secret123",650      url: "https://github.com",651    });652653    expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");654    vault.lock();655    expect(vault.getEntryById(entry.id)).toBeUndefined();656  });657658  it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {659    const vault = await import("./vault");660661    await vault.createVault("real-pass-67890");662    vault.lock();663664    expect(await vault.unlock("totally-wrong-password")).toBe(false);665    expect(vault.isUnlocked()).toBe(false);666  });667668  it("isUnlocked tracks the full lock lifecycle", async () => {669    const vault = await import("./vault");670671    expect(vault.isUnlocked()).toBe(false);672    await vault.createVault("unlock-pass-12345");673    expect(vault.isUnlocked()).toBe(true);674    vault.lock();675    expect(vault.isUnlocked()).toBe(false);676    expect(await vault.unlock("unlock-pass-12345")).toBe(true);677    expect(vault.isUnlocked()).toBe(true);678    vault.lock();679    expect(vault.isUnlocked()).toBe(false);680  });681682  it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {683    const vault = await import("./vault");684685    await vault.createVault("quota-pass-12345");686687    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;688    setMock.mockImplementation(async (next: Record<string, unknown>) => {689      if ("peach_vault_a" in next || "peach_vault_b" in next) {690        throw new Error("QUOTA_BYTES quota reached");691      }692      Object.assign(storage, next);693    });694695    await expect(696      vault.addEntry({697        title: "GitHub",698        username: "june",699        password: "secret123",700        url: "https://github.com",701      })702    ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");703  });704705  it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {706    const vault = await import("./vault");707708    await vault.createVault("settings-pass-12345");709710    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;711    setMock.mockImplementation(async (next: Record<string, unknown>) => {712      if ("peach_settings" in next) {713        throw new Error("QUOTA_BYTES quota reached");714      }715      Object.assign(storage, next);716    });717718    await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(719      "STORAGE_QUOTA_EXCEEDED"720    );721  });722723  it("card entry round-trips through vault — all fields preserved", async () => {724    const vault = await import("./vault");725726    await vault.createVault("card-test-pass-12345");727    const entry = await vault.addEntry({728      type: "card",729      title: "Personal Visa",730      cardholderName: "Jane Smith",731      cardNumber: "4111111111111111",732      expMonth: "05",733      expYear: "2028",734      cvc: "123",735      cardNickname: "Personal Visa",736      network: "visa",737      last4: "1111",738    });739740    expect(entry.type).toBe("card");741    expect(entry.cardholderName).toBe("Jane Smith");742    expect(entry.cardNumber).toBe("4111111111111111");743    expect(entry.expMonth).toBe("05");744    expect(entry.expYear).toBe("2028");745    expect(entry.cvc).toBe("123");746    expect(entry.network).toBe("visa");747    expect(entry.last4).toBe("1111");748749    vault.lock();750    await vault.unlock("card-test-pass-12345");751    const entries = vault.getEntries();752    expect(entries).toHaveLength(1);753    expect(entries[0].cardNumber).toBe("4111111111111111");754    expect(entries[0].cvc).toBe("123");755  });756757  it("updateEntry — cannot clear card number on card entry", async () => {758    const vault = await import("./vault");759760    await vault.createVault("card-guard-pass-12345");761    const entry = await vault.addEntry({762      type: "card",763      title: "My Card",764      cardholderName: "Jane Smith",765      cardNumber: "4111111111111111",766      expMonth: "05",767      expYear: "2028",768      cvc: "123",769    });770771    await expect(772      vault.updateEntry(entry.id, { cardNumber: "" })773    ).rejects.toThrow("card number is required for card entries");774775    const entries = vault.getEntries();776    expect(entries[0].cardNumber).toBe("4111111111111111");777  });778779  it("declineCardSaveOffer — stores fingerprint in settings", async () => {780    const vault = await import("./vault");781782    await vault.createVault("decline-pass-12345");783    await vault.declineCardSaveOffer("1111", "05", "2028");784785    const settings = vault.getSettings();786    expect(settings.declinedCardFingerprints).toHaveLength(1);787  });788789  it("isCardDeclined — returns true for previously declined card", async () => {790    const vault = await import("./vault");791792    await vault.createVault("declined-check-pass-12345");793    await vault.declineCardSaveOffer("1111", "05", "2028");794795    expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);796  });797798  it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {799    const vault = await import("./vault");800801    await vault.createVault("declined-diff-pass-12345");802    await vault.declineCardSaveOffer("1111", "05", "2028");803804    expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);805  });806807  it("queryDomain matches an entry against its additionalUrls", async () => {808    const vault = await import("./vault");809810    await vault.createVault("multi-domain-pass-12345");811    const entry = await vault.addEntry({812      type: "login",813      title: "Foo",814      username: "june",815      password: "secret",816      url: "https://foo.com",817      additionalUrls: ["https://bar.io", "https://baz.org"],818    });819820    // Vault index keys by eTLD+1 (tldts.getDomain), so additionalUrls only821    // add new buckets when the eTLD+1 is genuinely different from the primary.822    expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);823    expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);824    expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);825    expect(vault.queryDomain("unrelated.com")).toEqual([]);826  });827828  it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {829    const vault = await import("./vault");830831    await vault.createVault("dedupe-additional-pass-12345");832    const entry = await vault.addEntry({833      type: "login",834      title: "Foo",835      username: "june",836      password: "secret",837      url: "https://foo.com",838    });839840    await vault.updateEntry(entry.id, {841      additionalUrls: [842        "  https://login.foo.com  ",843        "https://login.foo.com",844        "",845        "  ",846        "https://accounts.foo.com",847      ],848    });849850    const stored = vault.getEntryById(entry.id);851    expect(stored?.additionalUrls).toEqual([852      "https://login.foo.com",853      "https://accounts.foo.com",854    ]);855856    await vault.updateEntry(entry.id, { additionalUrls: ["", "   "] });857    expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();858  });859860  it("preserves email field through lock/unlock cycle", async () => {861    const vault = await import("./vault");862    await vault.createVault("preserve-email-pass-12345");863    await vault.addEntry({864      type: "login",865      title: "GitHub",866      username: "june",867      email: "[email protected]",868      password: "secret",869      url: "https://github.com",870    });871    vault.lock();872873    const ok = await vault.unlock("preserve-email-pass-12345");874    expect(ok).toBe(true);875    const entry = vault.getEntries().find((e) => e.title === "GitHub");876    expect(entry?.email).toBe("[email protected]");877  });878879  it("preserves additionalUrls through lock/unlock cycle", async () => {880    const vault = await import("./vault");881    await vault.createVault("preserve-urls-pass-12345");882    await vault.addEntry({883      type: "login",884      title: "Example",885      username: "june",886      password: "secret",887      url: "https://example.com",888      additionalUrls: [889        "https://beta.example.com",890        "https://staging.example.com",891      ],892    });893    vault.lock();894895    const ok = await vault.unlock("preserve-urls-pass-12345");896    expect(ok).toBe(true);897    const entry = vault.getEntries().find((e) => e.title === "Example");898    expect(entry?.additionalUrls).toEqual([899      "https://beta.example.com",900      "https://staging.example.com",901    ]);902  });903904  it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => {905    const vault = await import("./vault");906    await vault.createVault("pwchanged-preserve-pass");907    const entry = await vault.addEntry({908      title: "Aged",909      username: "june",910      password: "secret",911      url: "https://aged.test",912    });913    // addEntry stamps passwordChangedAt at creation; capture it and prove it914    // survives the encode/decode round-trip unchanged (was dropped pre-fix).915    const ts = entry.passwordChangedAt;916    expect(typeof ts).toBe("number");917    vault.lock();918919    expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true);920    const restored = vault.getEntries().find((e) => e.id === entry.id);921    expect(restored?.passwordChangedAt).toBe(ts);922  });923924  it("normalizeEntry handles entries without passwordChangedAt", async () => {925    const vault = await import("./vault");926    await vault.createVault("pwchanged-absent-pass");927    // A note carries no password and therefore no passwordChangedAt stamp.928    const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" });929    vault.lock();930931    expect(await vault.unlock("pwchanged-absent-pass")).toBe(true);932    const restored = vault.getEntries().find((e) => e.id === note.id);933    expect(restored?.passwordChangedAt).toBeUndefined();934  });935936  it("applyMergedRemoteEntries does not advance the vault version", async () => {937    const vault = await import("./vault");938    await vault.createVault("syncver-pass-aaaaa");939    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });940941    const before = await vault.getVaultVersion();942    await new Promise((r) => setTimeout(r, 2));943    await vault.applyMergedRemoteEntries(944      [945        {946          id: "remote-1",947          type: "login",948          title: "Remote",949          username: "r",950          password: "x",951          url: "https://r.test",952          createdAt: Date.now(),953          modifiedAt: Date.now(),954        },955      ],956      {},957    );958959    expect(await vault.getVaultVersion()).toBe(before);960  });961962  it("addEntry advances the vault version", async () => {963    const vault = await import("./vault");964    await vault.createVault("syncver-pass-bbbbb");965966    const before = await vault.getVaultVersion();967    await new Promise((r) => setTimeout(r, 2));968    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });969970    expect(await vault.getVaultVersion()).toBeGreaterThan(before);971  });972973  it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {974    const vault = await import("./vault");975    await vault.createVault("monotonic-pass-aaaaa");976    const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });977    const before = vault.getEntryById(e1.id)!.modifiedAt;978979    // Simulate the clock jumping 10s backwards (NTP step / manual change).980    const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);981    await vault.updateEntry(e1.id, { password: "p2" });982    const after = vault.getEntryById(e1.id)!.modifiedAt;983    expect(after).toBeGreaterThan(before);984    spy.mockRestore();985  });986987  it("deletedAt is strictly greater than the entry's modifiedAt", async () => {988    const vault = await import("./vault");989    await vault.createVault("monotonic-pass-bbbbb");990    const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });991    const modAt = vault.getEntryById(e.id)!.modifiedAt;992    await vault.deleteEntry(e.id);993    const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;994    expect(tomb.deletedAt!).toBeGreaterThan(modAt);995  });996997  it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {998    const vault = await import("./vault");999    await vault.createVault("rollback-pass-aaaaa");1000    const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });10011002    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1003    // Fail only the slot-A write (the history snapshot write is swallowed by design).1004    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1005      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1006      Object.assign(storage, next);1007    });10081009    await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();10101011    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1012    // In-memory entry must still hold the original password, not the failed edit.1013    expect(vault.getEntryById(e.id)!.password).toBe("original");1014  });10151016  it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1017    const vault = await import("./vault");1018    await vault.createVault("rootkey-pass-aaaaa");1019    const before = vault.getRootKeyHex();1020    expect(before).toMatch(/^[0-9a-f]{64}$/);10211022    expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1023    expect(vault.getRootKeyHex()).toBe(before);10241025    // …and it survives a lock/unlock with the new password (rehydrated from pw_root_*).1026    vault.lock();1027    expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1028    expect(vault.getRootKeyHex()).toBe(before);1029  });10301031  it("getRootKeyHex returns null when the vault is locked", async () => {1032    const vault = await import("./vault");1033    await vault.createVault("rootkey-pass-ccccc");1034    vault.lock();1035    expect(vault.getRootKeyHex()).toBeNull();1036  });10371038  it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1039    const vault = await import("./vault");1040    await vault.createVault("duress-real-pass-aaaa");1041    await vault.setupDuress("duress-decoy-pass-bbbb");10421043    const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1044    const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;10451046    // Equal immediately after setup…1047    expect(lenA()).toBe(lenB());10481049    // …and STILL equal after real-vault edits (pre-fix, slot A grew per 1 KiB1050    // bucket while the frozen decoy stayed put, leaking that duress exists).1051    await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1052    await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1053    expect(lenA()).toBe(lenB());1054  });10551056  it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1057    const vault = await import("./vault");1058    await vault.createVault("rollback-pass-bbbbb");1059    const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });10601061    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1062    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1063      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1064      Object.assign(storage, next);1065    });10661067    await expect(vault.deleteEntry(e.id)).rejects.toThrow();10681069    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1070    // Entry must still be live (not tombstoned) since the delete never persisted.1071    expect(vault.getEntryById(e.id)).toBeDefined();1072    expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1073  });10741075  describe("password history", () => {1076    it("captures the old password on change, newest first", async () => {1077      const vault = await import("./vault");1078      await vault.createVault("history-pass-12345");1079      const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" });1080      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();10811082      await vault.updateEntry(e.id, { password: "second" });1083      await vault.updateEntry(e.id, { password: "third" });10841085      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1086      expect(hist.map((h) => h.password)).toEqual(["second", "first"]);1087      expect(vault.getEntryById(e.id)?.password).toBe("third");1088    });10891090    it("does not record history on a metadata-only update", async () => {1091      const vault = await import("./vault");1092      await vault.createVault("history-meta-12345");1093      const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" });10941095      await vault.updateEntry(e.id, { title: "Renamed" });1096      await vault.updateEntry(e.id, { username: "newuser" });10971098      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1099    });11001101    it("records each history item with the age the password carried", async () => {1102      const vault = await import("./vault");1103      await vault.createVault("history-stamp-12345");1104      const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" });1105      const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt;1106      expect(firstChangedAt).toBeGreaterThan(0);11071108      await new Promise((resolve) => setTimeout(resolve, 2));1109      await vault.updateEntry(e.id, { password: "v2" });11101111      const hist = vault.getEntryById(e.id)!.passwordHistory!;1112      // The retired "v1" entry carries the timestamp it held while current.1113      expect(hist[0].changedAt).toBe(firstChangedAt);1114    });11151116    it("skips capture when re-saving the identical password", async () => {1117      const vault = await import("./vault");1118      await vault.createVault("history-noop-12345");1119      const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" });11201121      await vault.updateEntry(e.id, { password: "same" });11221123      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1124    });11251126    it("does not capture an empty prior password (clear then set)", async () => {1127      const vault = await import("./vault");1128      await vault.createVault("history-empty-12345");1129      const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" });11301131      await vault.updateEntry(e.id, { password: "now-has-one" });11321133      // Nothing worth keeping when the prior value was empty.1134      expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0);1135    });11361137    it("restoring an old password re-records the swapped-out current one", async () => {1138      const vault = await import("./vault");1139      await vault.createVault("history-restore-12345");1140      const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" });1141      await vault.updateEntry(e.id, { password: "new" });11421143      // Restore "old" — the editor just saves the old value as the current one.1144      await vault.updateEntry(e.id, { password: "old" });11451146      const hist = vault.getEntryById(e.id)!.passwordHistory!;1147      expect(vault.getEntryById(e.id)?.password).toBe("old");1148      expect(hist.map((h) => h.password)).toEqual(["new", "old"]);1149    });11501151    it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => {1152      const vault = await import("./vault");1153      await vault.createVault("history-persist-12345");1154      const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" });1155      await vault.updateEntry(e.id, { password: "p2" });11561157      vault.lock();1158      expect(await vault.unlock("history-persist-12345")).toBe(true);11591160      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1161      expect(hist.map((h) => h.password)).toEqual(["p1"]);1162    });11631164    it("caps history at 50 entries, dropping the oldest", async () => {1165      const vault = await import("./vault");1166      await vault.createVault("history-cap-12345");1167      const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" });11681169      for (let i = 1; i <= 55; i++) {1170        await vault.updateEntry(e.id, { password: `pw-${i}` });1171      }11721173      const hist = vault.getEntryById(e.id)!.passwordHistory!;1174      expect(hist).toHaveLength(50);1175      // Newest retired password is pw-54 (pw-55 is now current); oldest kept is pw-5.1176      expect(hist[0].password).toBe("pw-54");1177      expect(hist[hist.length - 1].password).toBe("pw-5");1178    });1179  });11801181  describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1182    it("drops unknown/attacker keys from a restored entry", async () => {1183      const vault = await import("./vault");1184      await vault.createVault("correct horse battery staple");1185      await vault.restoreEntries(1186        [1187          {1188            id: "e1",1189            type: "login",1190            title: "Bank",1191            username: "june",1192            password: "pw",1193            createdAt: 1,1194            modifiedAt: 2,1195            evil: "payload",1196            isAdmin: true,1197            nested: { junk: 1 },1198          } as unknown as import("../shared/types").VaultEntry,1199        ],1200        "replace",1201      );1202      const restored = vault.getEntries().find((e) => e.id === "e1")!;1203      expect(restored.password).toBe("pw");1204      expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1205      expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1206      expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1207    });12081209    it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1210      const vault = await import("./vault");1211      await vault.createVault("correct horse battery staple");1212      await vault.restoreEntries(1213        [1214          {1215            id: "e2",1216            type: "login",1217            title: "Bank",1218            password: "pw",1219            createdAt: 1,1220            modifiedAt: 2,1221            canary: { webhookUrl: "http://attacker.example.com/x" },1222          } as unknown as import("../shared/types").VaultEntry,1223        ],1224        "replace",1225      );1226      const restored = vault.getEntries().find((e) => e.id === "e2")!;1227      expect(restored).toBeDefined();1228      expect(restored.canary).toBeUndefined();1229    });12301231    it("preserves a well-formed HTTPS canary webhook", async () => {1232      const vault = await import("./vault");1233      await vault.createVault("correct horse battery staple");1234      await vault.restoreEntries(1235        [1236          {1237            id: "e3",1238            type: "login",1239            title: "Bank",1240            password: "pw",1241            createdAt: 1,1242            modifiedAt: 2,1243            canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1244          } as unknown as import("../shared/types").VaultEntry,1245        ],1246        "replace",1247      );1248      const restored = vault.getEntries().find((e) => e.id === "e3")!;1249      expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1250      expect(restored.canary?.label).toBe("watch");1251    });12521253    it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1254      const vault = await import("./vault");1255      await vault.createVault("correct horse battery staple");1256      await vault.restoreEntries(1257        [1258          {1259            id: "e4",1260            type: "login",1261            title: "Has bad passkey",1262            createdAt: 1,1263            modifiedAt: 2,1264            passkey: { rpId: "example.com", publicKeyCose: "x" },1265          } as unknown as import("../shared/types").VaultEntry,1266        ],1267        "replace",1268      );1269      expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1270    });12711272    it("migrates a pre-v2.11 flat-passkey backup entry into the passkey sub-object", async () => {1273      const vault = await import("./vault");1274      await vault.createVault("correct horse battery staple");1275      await vault.restoreEntries(1276        [1277          {1278            id: "e5",1279            type: "passkey",1280            title: "Legacy Passkey",1281            createdAt: 1,1282            modifiedAt: 2,1283            rpId: "example.com",1284            rpName: "Example",1285            credentialId: "cred-123",1286            publicKeyCose: "cose-blob",1287            userHandle: "uh",1288            userName: "june",1289            userDisplayName: "June",1290            algorithm: -7,1291          } as unknown as import("../shared/types").VaultEntry,1292        ],1293        "replace",1294      );1295      const restored = vault.getEntries().find((e) => e.id === "e5")!;1296      expect(restored).toBeDefined();1297      expect(restored.passkey?.credentialId).toBe("cred-123");1298      // Flat legacy fields are not kept as top-level keys.1299      expect((restored as unknown as Record<string, unknown>).credentialId).toBeUndefined();1300    });1301  });13021303  describe("MV-8 rotateSyncIdentity", () => {1304    it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1305      const vault = await import("./vault");1306      await vault.createVault("correct horse battery staple");1307      const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1308      const { upsertPairedDevice, listPairedDevices } = await import(1309        "./paired-devices"1310      );1311      await upsertPairedDevice({1312        deviceId: "d1",1313        deviceName: "Phone",1314        deviceType: "android",1315        longTermPublicKeyHex: "a".repeat(64),1316        signingPublicKeyHex: "b".repeat(64),1317        pairedAt: 1,1318        trustSource: "direct",1319      });13201321      const rootBefore = vault.getRootKeyHex();1322      const res = await vault.rotateSyncIdentity();1323      expect(res.ok).toBe(true);1324      expect(vault.getRootKeyHex()).not.toBe(rootBefore);1325      expect(await listPairedDevices()).toHaveLength(0);1326      // Entries survive — they are sealed under the password key, not the root.1327      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");13281329      vault.lock();1330      expect(await vault.unlock("correct horse battery staple")).toBe(true);1331      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1332    });13331334    it("invalidates the old recovery words and flags recovery-pending", async () => {1335      const vault = await import("./vault");1336      await vault.createVault("correct horse battery staple");1337      const wordsBefore = (await vault.getRecoveryWords()).join(" ");13381339      await vault.rotateSyncIdentity();13401341      const wordsAfter = (await vault.getRecoveryWords()).join(" ");1342      expect(wordsAfter).not.toBe(wordsBefore);1343      const { isRecoveryConfirmPending } = await import("./recovery-pending");1344      expect(await isRecoveryConfirmPending()).toBe(true);1345    });13461347    it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1348      const vault = await import("./vault");1349      await vault.createVault("correct horse battery staple");1350      const { createChirpSyncVaultAdapter } = await import(1351        "./chirp-sync-vault-adapter"1352      );1353      const adapter = createChirpSyncVaultAdapter();1354      const vidBefore = await adapter.computeVaultId();13551356      await vault.rotateSyncIdentity();13571358      const vidAfter = await adapter.computeVaultId();1359      expect(vidAfter).not.toBe(vidBefore);1360    });1361  });1362});