vault.test.ts

54.2 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("wraps the derived vault key for PIN unlock and invalidates it on password change", async () => {91    const vault = await import("./vault");92    const password = "correct horse battery staple";9394    await vault.createVault(password);95    expect(await vault.setupPinUnlock(password, "123")).toBe(false);96    expect(await vault.setupPinUnlock(password, "1234567890123")).toBe(false);97    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);98    expect((storage.peach_pin_wrap as { ciphertext: string }).ciphertext).toBeTruthy();99100    vault.lock();101    expect(await vault.unlockWithPin("000000")).toBe(false);102    expect(await vault.unlockWithPin("1234")).toBe(true);103104    expect(await vault.changePassword(password, "new correct horse battery"))105      .toBe(true);106    vault.lock();107    expect(await vault.unlockWithPin("1234")).toBe(false);108    expect(await vault.unlock("new correct horse battery")).toBe(true);109  });110111  it("slot A and slot B have matching ciphertext size at fresh install", async () => {112    const vault = await import("./vault");113    await vault.createVault("correct horse battery staple");114115    const a = storage.peach_vault_a as StoredVault;116    const b = storage.peach_vault_b as StoredVault;117    expect(a.ciphertext.length).toBe(b.ciphertext.length);118  });119120  it("does not expose a plaintext duress flag after setupDuress", async () => {121    const vault = await import("./vault");122    await vault.createVault("real-password-12345");123    await vault.setupDuress("decoy-password-67890");124125    expect(storage.peach_duress).toBeUndefined();126    expect(storage.peach_decoy_vault).toBeUndefined();127    const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));128    // Slot-neutral keys + optional settings + vault history (snapshot of the129    // pre-setupDuress real-mode blob). No exposed duress flag.130    for (const key of keys) {131      expect(132        [133          "peach_vault_a",134          "peach_vault_b",135          "peach_settings",136          "peach_vault_history",137          "peach_last_modified_at",138        ]139      ).toContain(key);140    }141    expect(keys).toContain("peach_vault_a");142    expect(keys).toContain("peach_vault_b");143  });144145  it("unlocks with real password and loads real entries", async () => {146    const vault = await import("./vault");147    await vault.createVault("real-password-12345");148    await vault.addEntry({149      title: "GitHub",150      username: "june",151      password: "realsecret",152      url: "github.com",153    });154    vault.lock();155156    const ok = await vault.unlock("real-password-12345");157    expect(ok).toBe(true);158    expect(vault.getEntries()).toHaveLength(1);159    expect(vault.getEntries()[0].title).toBe("GitHub");160  });161162  it("unlocks with duress password and loads empty entries", async () => {163    const vault = await import("./vault");164    await vault.createVault("real-password-12345");165    await vault.addEntry({166      title: "GitHub",167      username: "june",168      password: "realsecret",169      url: "github.com",170    });171    await vault.setupDuress("decoy-password-67890");172    vault.lock();173174    const ok = await vault.unlock("decoy-password-67890");175    expect(ok).toBe(true);176    expect(vault.getEntries()).toEqual([]);177  });178179  it("HOLE 5: the duress vault has no sync identity and cannot sync", async () => {180    const vault = await import("./vault");181    await vault.createVault("real-password-12345");182    await vault.addEntry({ type: "login", title: "GitHub", password: "realsecret" });183    await vault.setupDuress("decoy-password-67890");184185    // Baseline: the real vault does have a sync identity.186    expect(vault.getRootKeyHex()).toBeTruthy();187    vault.lock();188189    expect(await vault.unlock("decoy-password-67890")).toBe(true);190    // A second vault_id would be a duress indicator: a coerced user opening the191    // decoy would make sync visibly fail on their other devices. The decoy path192    // never derives a root key, so there is nothing to derive one from.193    expect(vault.getRootKeyHex()).toBeNull();194195    // Every sync adapter operation therefore fails closed rather than syncing,196    // manifesting, or revealing an entry count.197    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");198    const adapter = createSyncVaultAdapter();199    await expect(adapter.computeVaultId()).rejects.toThrow();200    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toThrow();201    vault.lock();202  });203204  it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {205    // The unlock path must derive keys for both slots unconditionally so a206    // real-mode unlock and a decoy-mode unlock both spend roughly the same207    // wall-clock time. Verified here by spying on deriveKey and asserting it208    // is called twice (once per slot) on every unlock attempt.209    const argon2 = await import("../crypto/argon2");210    const deriveSpy = vi.spyOn(argon2, "deriveKey");211212    const vault = await import("./vault");213    await vault.createVault("real-password-12345");214    await vault.setupDuress("decoy-password-67890");215    vault.lock();216217    deriveSpy.mockClear();218    await vault.unlock("real-password-12345");219    expect(deriveSpy).toHaveBeenCalledTimes(2);220    vault.lock();221222    deriveSpy.mockClear();223    await vault.unlock("decoy-password-67890");224    expect(deriveSpy).toHaveBeenCalledTimes(2);225    vault.lock();226227    deriveSpy.mockClear();228    await vault.unlock("wrong-password-xxxxx");229    expect(deriveSpy).toHaveBeenCalledTimes(2);230231    deriveSpy.mockRestore();232  });233234  it("rejects wrong passwords", async () => {235    const vault = await import("./vault");236    await vault.createVault("real-password-12345");237    vault.lock();238239    const ok = await vault.unlock("nope-nope-nope");240    expect(ok).toBe(false);241  });242243  it("persists duressConfigured across lock/unlock in encrypted payload", async () => {244    const vault = await import("./vault");245    await vault.createVault("real-password-12345");246    await vault.setupDuress("decoy-password-67890");247    vault.lock();248249    await vault.unlock("real-password-12345");250    expect(await vault.isDuressEnabled()).toBe(true);251252    await vault.disableDuress();253    expect(await vault.isDuressEnabled()).toBe(false);254    vault.lock();255256    await vault.unlock("real-password-12345");257    expect(await vault.isDuressEnabled()).toBe(false);258  });259260  it("migrates legacy peach_vault / peach_decoy_vault / peach_duress schema", async () => {261    // Arrange: set up storage as if from pre-A-2 (v1.4.24 and earlier).262    const firstVault = await import("./vault");263    await firstVault.createVault("legacy-password-abcde");264    const legacySlotA = storage.peach_vault_a as StoredVault;265    storage.peach_vault = legacySlotA;266    delete storage.peach_vault_a;267    delete storage.peach_vault_b;268    storage.peach_duress = { enabled: true };269    storage.peach_decoy_vault = legacySlotA; // reuse shape for test270    vi.resetModules();271272    // Act: fresh module load triggers migration on next vault access.273    const vault = await import("./vault");274    await vault.hasVault();275276    // Assert: legacy keys removed, new keys present.277    expect(storage.peach_vault).toBeUndefined();278    expect(storage.peach_decoy_vault).toBeUndefined();279    expect(storage.peach_duress).toBeUndefined();280    expect(storage.peach_vault_a).toBeDefined();281    expect(storage.peach_vault_b).toBeDefined();282  });283284  it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {285    const vault = await import("./vault");286287    await vault.createVault("correct horse battery staple");288    const entry = await vault.addEntry({289      title: "GitHub",290      username: "june",291      password: "secret123",292      url: "github.com",293    });294295    await vault.deleteEntry(entry.id);296297    expect(vault.getEntries()).toEqual([]);298299    const stored = storage.peach_vault_a as StoredVault;300    const salt = Uint8Array.from(301      stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))302    );303    const key = await deriveKey("correct horse battery staple", salt);304    const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");305    const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };306307    expect(parsed.entries).toHaveLength(1);308    expect(parsed.entries[0].id).toBe(entry.id);309    expect(typeof parsed.entries[0].deletedAt).toBe("number");310  });311312  it("accepts imported entries without a username or url", async () => {313    const vault = await import("./vault");314315    await vault.createVault("correct horse battery staple");316    const entry = await vault.addEntry({317      title: "Imported Login",318      username: "",319      password: "secret123",320      url: "",321      notes: "migrated from another manager",322    });323324    expect(entry.username).toBe("");325    expect(entry.url).toBe("");326    expect(vault.getEntries()).toHaveLength(1);327  });328329  it("changePassword happy path — new password unlocks, old is rejected", async () => {330    const vault = await import("./vault");331332    await vault.createVault("old-pass-abc123");333    await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });334    await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });335336    expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);337338    vault.lock();339    expect(await vault.unlock("new-pass-xyz789")).toBe(true);340    expect(vault.getEntries()).toHaveLength(2);341342    vault.lock();343    expect(await vault.unlock("old-pass-abc123")).toBe(false);344  });345346  it("stamps peach_last_modified_at on createVault", async () => {347    const vault = await import("./vault");348    const before = Date.now();349    await new Promise((resolve) => setTimeout(resolve, 1));350351    await vault.createVault("stamp-create-pass");352353    expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);354  });355356  it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {357    const vault = await import("./vault");358    await vault.createVault("stamp-entry-pass");359360    let previous = Number(storage.peach_last_modified_at);361    await new Promise((resolve) => setTimeout(resolve, 1));362    const entry = await vault.addEntry({363      title: "Stamp",364      username: "",365      password: "p",366      url: "",367    });368    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);369370    previous = Number(storage.peach_last_modified_at);371    await new Promise((resolve) => setTimeout(resolve, 1));372    await vault.updateEntry(entry.id, { title: "Stamped" });373    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);374375    previous = Number(storage.peach_last_modified_at);376    await new Promise((resolve) => setTimeout(resolve, 1));377    await vault.deleteEntry(entry.id);378    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);379  });380381  it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {382    const vault = await import("./vault");383    await vault.createVault("stamp-real-pass");384385    let previous = Number(storage.peach_last_modified_at);386    await new Promise((resolve) => setTimeout(resolve, 1));387    await vault.setupDuress("stamp-decoy-pass");388    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);389390    previous = Number(storage.peach_last_modified_at);391    await new Promise((resolve) => setTimeout(resolve, 1));392    expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);393    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);394  });395396  it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {397    const vault = await import("./vault");398399    await vault.createVault("correct-pass-abcde");400    const slotABefore = JSON.stringify(storage.peach_vault_a);401402    expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);403    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);404405    vault.lock();406    expect(await vault.unlock("correct-pass-abcde")).toBe(true);407  });408409  it("changePassword with duress configured leaves slot B byte-identical", async () => {410    const vault = await import("./vault");411412    await vault.createVault("real-pass-abcde");413    await vault.setupDuress("duress-pass-xyzzz");414415    const slotBBefore = JSON.stringify(storage.peach_vault_b);416    expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);417    expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);418419    // Duress path still works with the old decoy password.420    vault.lock();421    expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);422    expect(vault.getEntries()).toEqual([]);423  });424425  it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {426    const vault = await import("./vault");427428    await vault.createVault("pass-abcdef");429    await vault.addEntry({ title: "One", username: "", password: "1", url: "" });430    await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });431    await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });432433    await expect(vault.restoreEntries([], "replace")).rejects.toThrow(434      /refuse to replace with empty entry set/i435    );436    expect(vault.getEntries()).toHaveLength(3);437  });438439  it("changePassword swaps the active key before Slot B is regenerated", async () => {440    const vault = await import("./vault");441442    await vault.createVault("correct-pass-12345");443    const entry = await vault.addEntry({444      title: "GitHub",445      username: "june",446      password: "secret123",447      url: "https://github.com",448    });449450    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;451    const originalSet = setMock.getMockImplementation();452    let injectedDuringSlotBWrite = false;453454    setMock.mockImplementation(async (next: Record<string, unknown>) => {455      Object.assign(storage, next);456457      if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {458        injectedDuringSlotBWrite = true;459        await vault.updateEntry(entry.id, { notes: "updated during password change" });460      }461    });462463    expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);464465    setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {466      Object.assign(storage, next);467    }));468469    vault.lock();470    expect(await vault.unlock("new-pass-67890")).toBe(true);471    expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");472473    vault.lock();474    expect(await vault.unlock("correct-pass-12345")).toBe(false);475  });476477  it("restoreEntries replace mode replaces existing entries", async () => {478    const vault = await import("./vault");479480    await vault.createVault("replace-pass-12345");481    await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });482    await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });483484    const incoming = [485      {486        id: "entry-c",487        type: "login" as const,488        title: "C",489        username: "june",490        password: "three",491        url: "https://c.test",492        createdAt: 10,493        modifiedAt: 10,494      },495      {496        id: "entry-d",497        type: "login" as const,498        title: "D",499        username: "june",500        password: "four",501        url: "https://d.test",502        createdAt: 20,503        modifiedAt: 20,504      },505    ];506507    await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({508      added: 2,509      updated: 0,510      skipped: 0,511    });512513    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);514  });515516  it("restoreEntries merge mode lets newer incoming entries win", async () => {517    const vault = await import("./vault");518519    await vault.createVault("merge-pass-12345");520    const existing = await vault.addEntry({521      title: "GitHub",522      username: "june",523      password: "old-secret",524      url: "https://github.com",525    });526527    await expect(528      vault.restoreEntries(529        [530          {531            ...existing,532            password: "new-secret",533            modifiedAt: existing.modifiedAt + 100,534          },535        ],536        "merge"537      )538    ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });539540    expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");541  });542543  it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {544    const vault = await import("./vault");545546    await vault.createVault("merge-pass-67890");547    const existing = await vault.addEntry({548      title: "GitHub",549      username: "june",550      password: "current-secret",551      url: "https://github.com",552    });553554    await expect(555      vault.restoreEntries(556        [557          {558            ...existing,559            password: "stale-secret",560            modifiedAt: existing.modifiedAt - 100,561          },562        ],563        "merge"564      )565    ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });566567    expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");568  });569570  it("clearVaultEntries without explicit confirmation throws", async () => {571    const vault = await import("./vault");572573    await vault.createVault("clear-pass-12345");574    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });575576    const clearVaultEntries = vault.clearVaultEntries as unknown as (577      confirmation?: unknown578    ) => Promise<void>;579580    await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);581    await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);582    expect(vault.getEntries()).toHaveLength(1);583  });584585  it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {586    const vault = await import("./vault");587588    await vault.createVault("clear-pass-67890");589    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });590    await vault.addEntry({ title: "B", username: "", password: "two", url: "" });591    await vault.addEntry({ title: "C", username: "", password: "three", url: "" });592593    await vault.clearVaultEntries("REPLACE_ALL");594595    expect(vault.getEntries()).toEqual([]);596    expect(vault.getDeletedEntries()).toHaveLength(3);597    expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);598  });599600  it("deduplicateEntries throws in decoy mode", async () => {601    const vault = await import("./vault");602603    await vault.createVault("real-pass-12345");604    await vault.addEntry({605      title: "GitHub",606      username: "june",607      password: "secret123",608      url: "https://github.com",609    });610    await vault.setupDuress("duress-pass-12345");611    vault.lock();612613    expect(await vault.unlock("duress-pass-12345")).toBe(true);614    await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);615  });616617  it("deduplicateEntries removes older duplicates in real mode", async () => {618    const vault = await import("./vault");619620    await vault.createVault("dedupe-pass-12345");621    await vault.addEntry({622      title: "GitHub",623      username: "june",624      password: "old-secret",625      url: "https://github.com",626    });627    await new Promise<void>((resolve) => setTimeout(resolve, 5));628    const newer = await vault.addEntry({629      title: "GitHub",630      username: "june",631      password: "new-secret",632      url: "https://github.com",633    });634635    expect(await vault.deduplicateEntries()).toBe(1);636    expect(vault.getEntries()).toHaveLength(1);637    expect(vault.getEntries()[0].id).toBe(newer.id);638    expect(vault.getDeletedEntries()).toHaveLength(1);639  });640641  it("updateEntry with an empty password on a login entry clears the password", async () => {642    const vault = await import("./vault");643644    await vault.createVault("update-pass-12345");645    const entry = await vault.addEntry({646      title: "GitHub",647      username: "june",648      password: "secret123",649      url: "https://github.com",650    });651652    await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined();653    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();654  });655656  it("updateEntry with an empty password on a note entry is allowed", async () => {657    const vault = await import("./vault");658659    await vault.createVault("note-pass-12345");660    const entry = await vault.addEntry({661      type: "note",662      title: "Secure note",663      notes: "keep me",664    });665666    await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();667    expect(vault.getEntryById(entry.id)).toMatchObject({668      type: "note",669      notes: "updated",670    });671  });672673  it("lock zeroes the derived key and resets unlock state", async () => {674    const aes = await import("./aes");675    const zeroKeySpy = vi.spyOn(aes, "zeroKey");676    const vault = await import("./vault");677678    await vault.createVault("lock-pass-12345");679    expect(vault.isUnlocked()).toBe(true);680681    vault.lock();682683    expect(zeroKeySpy).toHaveBeenCalled();684    expect(vault.isUnlocked()).toBe(false);685    zeroKeySpy.mockRestore();686  });687688  it("lock clears in-memory entries", async () => {689    const vault = await import("./vault");690691    await vault.createVault("lock-pass-67890");692    const entry = await vault.addEntry({693      title: "GitHub",694      username: "june",695      password: "secret123",696      url: "https://github.com",697    });698699    expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");700    vault.lock();701    expect(vault.getEntryById(entry.id)).toBeUndefined();702  });703704  it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {705    const vault = await import("./vault");706707    await vault.createVault("real-pass-67890");708    vault.lock();709710    expect(await vault.unlock("totally-wrong-password")).toBe(false);711    expect(vault.isUnlocked()).toBe(false);712  });713714  it("isUnlocked tracks the full lock lifecycle", async () => {715    const vault = await import("./vault");716717    expect(vault.isUnlocked()).toBe(false);718    await vault.createVault("unlock-pass-12345");719    expect(vault.isUnlocked()).toBe(true);720    vault.lock();721    expect(vault.isUnlocked()).toBe(false);722    expect(await vault.unlock("unlock-pass-12345")).toBe(true);723    expect(vault.isUnlocked()).toBe(true);724    vault.lock();725    expect(vault.isUnlocked()).toBe(false);726  });727728  it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {729    const vault = await import("./vault");730731    await vault.createVault("quota-pass-12345");732733    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;734    setMock.mockImplementation(async (next: Record<string, unknown>) => {735      if ("peach_vault_a" in next || "peach_vault_b" in next) {736        throw new Error("QUOTA_BYTES quota reached");737      }738      Object.assign(storage, next);739    });740741    await expect(742      vault.addEntry({743        title: "GitHub",744        username: "june",745        password: "secret123",746        url: "https://github.com",747      })748    ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");749  });750751  it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {752    const vault = await import("./vault");753754    await vault.createVault("settings-pass-12345");755756    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;757    setMock.mockImplementation(async (next: Record<string, unknown>) => {758      if ("peach_settings" in next) {759        throw new Error("QUOTA_BYTES quota reached");760      }761      Object.assign(storage, next);762    });763764    await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(765      "STORAGE_QUOTA_EXCEEDED"766    );767  });768769  it("card entry round-trips through vault — all fields preserved", async () => {770    const vault = await import("./vault");771772    await vault.createVault("card-test-pass-12345");773    const entry = await vault.addEntry({774      type: "card",775      title: "Personal Visa",776      cardholderName: "Jane Smith",777      cardNumber: "4111111111111111",778      expMonth: "05",779      expYear: "2028",780      cvc: "123",781      cardNickname: "Personal Visa",782      network: "visa",783      last4: "1111",784    });785786    expect(entry.type).toBe("card");787    expect(entry.cardholderName).toBe("Jane Smith");788    expect(entry.cardNumber).toBe("4111111111111111");789    expect(entry.expMonth).toBe("05");790    expect(entry.expYear).toBe("2028");791    expect(entry.cvc).toBe("123");792    expect(entry.network).toBe("visa");793    expect(entry.last4).toBe("1111");794795    vault.lock();796    await vault.unlock("card-test-pass-12345");797    const entries = vault.getEntries();798    expect(entries).toHaveLength(1);799    expect(entries[0].cardNumber).toBe("4111111111111111");800    expect(entries[0].cvc).toBe("123");801  });802803  it("updateEntry — cannot clear card number on card entry", async () => {804    const vault = await import("./vault");805806    await vault.createVault("card-guard-pass-12345");807    const entry = await vault.addEntry({808      type: "card",809      title: "My Card",810      cardholderName: "Jane Smith",811      cardNumber: "4111111111111111",812      expMonth: "05",813      expYear: "2028",814      cvc: "123",815    });816817    await expect(818      vault.updateEntry(entry.id, { cardNumber: "" })819    ).rejects.toThrow("card number is required for card entries");820821    const entries = vault.getEntries();822    expect(entries[0].cardNumber).toBe("4111111111111111");823  });824825  it("declineCardSaveOffer — stores fingerprint in settings", async () => {826    const vault = await import("./vault");827828    await vault.createVault("decline-pass-12345");829    await vault.declineCardSaveOffer("1111", "05", "2028");830831    const settings = vault.getSettings();832    expect(settings.declinedCardFingerprints).toHaveLength(1);833  });834835  it("isCardDeclined — returns true for previously declined card", async () => {836    const vault = await import("./vault");837838    await vault.createVault("declined-check-pass-12345");839    await vault.declineCardSaveOffer("1111", "05", "2028");840841    expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);842  });843844  it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {845    const vault = await import("./vault");846847    await vault.createVault("declined-diff-pass-12345");848    await vault.declineCardSaveOffer("1111", "05", "2028");849850    expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);851  });852853  it("queryDomain matches an entry against its additionalUrls", async () => {854    const vault = await import("./vault");855856    await vault.createVault("multi-domain-pass-12345");857    const entry = await vault.addEntry({858      type: "login",859      title: "Foo",860      username: "june",861      password: "secret",862      url: "https://foo.com",863      additionalUrls: ["https://bar.io", "https://baz.org"],864    });865866    // Vault index keys by eTLD+1 (tldts.getDomain), so additionalUrls only867    // add new buckets when the eTLD+1 is genuinely different from the primary.868    expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);869    expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);870    expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);871    expect(vault.queryDomain("unrelated.com")).toEqual([]);872  });873874  it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {875    const vault = await import("./vault");876877    await vault.createVault("dedupe-additional-pass-12345");878    const entry = await vault.addEntry({879      type: "login",880      title: "Foo",881      username: "june",882      password: "secret",883      url: "https://foo.com",884    });885886    await vault.updateEntry(entry.id, {887      additionalUrls: [888        "  https://login.foo.com  ",889        "https://login.foo.com",890        "",891        "  ",892        "https://accounts.foo.com",893      ],894    });895896    const stored = vault.getEntryById(entry.id);897    expect(stored?.additionalUrls).toEqual([898      "https://login.foo.com",899      "https://accounts.foo.com",900    ]);901902    await vault.updateEntry(entry.id, { additionalUrls: ["", "   "] });903    expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();904  });905906  it("preserves email field through lock/unlock cycle", async () => {907    const vault = await import("./vault");908    await vault.createVault("preserve-email-pass-12345");909    await vault.addEntry({910      type: "login",911      title: "GitHub",912      username: "june",913      email: "[email protected]",914      password: "secret",915      url: "https://github.com",916    });917    vault.lock();918919    const ok = await vault.unlock("preserve-email-pass-12345");920    expect(ok).toBe(true);921    const entry = vault.getEntries().find((e) => e.title === "GitHub");922    expect(entry?.email).toBe("[email protected]");923  });924925  it("preserves additionalUrls through lock/unlock cycle", async () => {926    const vault = await import("./vault");927    await vault.createVault("preserve-urls-pass-12345");928    await vault.addEntry({929      type: "login",930      title: "Example",931      username: "june",932      password: "secret",933      url: "https://example.com",934      additionalUrls: [935        "https://beta.example.com",936        "https://staging.example.com",937      ],938    });939    vault.lock();940941    const ok = await vault.unlock("preserve-urls-pass-12345");942    expect(ok).toBe(true);943    const entry = vault.getEntries().find((e) => e.title === "Example");944    expect(entry?.additionalUrls).toEqual([945      "https://beta.example.com",946      "https://staging.example.com",947    ]);948  });949950  it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => {951    const vault = await import("./vault");952    await vault.createVault("pwchanged-preserve-pass");953    const entry = await vault.addEntry({954      title: "Aged",955      username: "june",956      password: "secret",957      url: "https://aged.test",958    });959    // addEntry stamps passwordChangedAt at creation; capture it and prove it960    // survives the encode/decode round-trip unchanged (was dropped pre-fix).961    const ts = entry.passwordChangedAt;962    expect(typeof ts).toBe("number");963    vault.lock();964965    expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true);966    const restored = vault.getEntries().find((e) => e.id === entry.id);967    expect(restored?.passwordChangedAt).toBe(ts);968  });969970  it("normalizeEntry handles entries without passwordChangedAt", async () => {971    const vault = await import("./vault");972    await vault.createVault("pwchanged-absent-pass");973    // A note carries no password and therefore no passwordChangedAt stamp.974    const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" });975    vault.lock();976977    expect(await vault.unlock("pwchanged-absent-pass")).toBe(true);978    const restored = vault.getEntries().find((e) => e.id === note.id);979    expect(restored?.passwordChangedAt).toBeUndefined();980  });981982  it("applyMergedRemoteEntries does not advance the vault version", async () => {983    const vault = await import("./vault");984    await vault.createVault("syncver-pass-aaaaa");985    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });986987    const before = await vault.getVaultVersion();988    await new Promise((r) => setTimeout(r, 2));989    await vault.applyMergedRemoteEntries(990      [991        {992          id: "remote-1",993          type: "login",994          title: "Remote",995          username: "r",996          password: "x",997          url: "https://r.test",998          createdAt: Date.now(),999          modifiedAt: Date.now(),1000        },1001      ],1002      {},1003    );10041005    expect(await vault.getVaultVersion()).toBe(before);1006  });10071008  it("addEntry advances the vault version", async () => {1009    const vault = await import("./vault");1010    await vault.createVault("syncver-pass-bbbbb");10111012    const before = await vault.getVaultVersion();1013    await new Promise((r) => setTimeout(r, 2));1014    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });10151016    expect(await vault.getVaultVersion()).toBeGreaterThan(before);1017  });10181019  it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {1020    const vault = await import("./vault");1021    await vault.createVault("monotonic-pass-aaaaa");1022    const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });1023    const before = vault.getEntryById(e1.id)!.modifiedAt;10241025    // Simulate the clock jumping 10s backwards (NTP step / manual change).1026    const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);1027    await vault.updateEntry(e1.id, { password: "p2" });1028    const after = vault.getEntryById(e1.id)!.modifiedAt;1029    expect(after).toBeGreaterThan(before);1030    spy.mockRestore();1031  });10321033  it("deletedAt is strictly greater than the entry's modifiedAt", async () => {1034    const vault = await import("./vault");1035    await vault.createVault("monotonic-pass-bbbbb");1036    const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });1037    const modAt = vault.getEntryById(e.id)!.modifiedAt;1038    await vault.deleteEntry(e.id);1039    const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;1040    expect(tomb.deletedAt!).toBeGreaterThan(modAt);1041  });10421043  it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {1044    const vault = await import("./vault");1045    await vault.createVault("rollback-pass-aaaaa");1046    const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });10471048    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1049    // Fail only the slot-A write (the history snapshot write is swallowed by design).1050    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1051      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1052      Object.assign(storage, next);1053    });10541055    await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();10561057    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1058    // In-memory entry must still hold the original password, not the failed edit.1059    expect(vault.getEntryById(e.id)!.password).toBe("original");1060  });10611062  it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1063    const vault = await import("./vault");1064    await vault.createVault("rootkey-pass-aaaaa");1065    const before = vault.getRootKeyHex();1066    expect(before).toMatch(/^[0-9a-f]{64}$/);10671068    expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1069    expect(vault.getRootKeyHex()).toBe(before);10701071    // …and it survives a lock/unlock with the new password (rehydrated from pw_root_*).1072    vault.lock();1073    expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1074    expect(vault.getRootKeyHex()).toBe(before);1075  });10761077  it("getRootKeyHex returns null when the vault is locked", async () => {1078    const vault = await import("./vault");1079    await vault.createVault("rootkey-pass-ccccc");1080    vault.lock();1081    expect(vault.getRootKeyHex()).toBeNull();1082  });10831084  it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => {1085    const vault = await import("./vault");1086    await vault.createVault("biometric-pass-12345");1087    const rootBefore = vault.getRootKeyHex();1088    const keyHex = vault.getDerivedKeyHex();1089    expect(rootBefore).not.toBeNull();1090    vault.lock();1091    // The biometric path passes the pre-derived AES key; rootKey must rehydrate.1092    const r = await vault.unlockWithKey(keyHex);1093    expect(r.ok).toBe(true);1094    expect(vault.getRootKeyHex()).toBe(rootBefore);1095  });10961097  it("unlock_with_key_succeeds_without_escrow (PCH-01-009)", async () => {1098    const vault = await import("./vault");1099    await vault.createVault("legacy-pass-12345");1100    const keyHex = vault.getDerivedKeyHex();1101    vault.lock();1102    // Strip the recovery escrow to mimic a pre-v2.12 slot — unlock must still1103    // succeed (non-fatal), leaving rootKey null.1104    const slot = storage.peach_vault_a as Record<string, unknown>;1105    delete slot.recovery_salt;1106    delete slot.recovery_wrapped_root_key;1107    delete slot.pw_root_ciphertext;1108    delete slot.pw_root_nonce;1109    delete slot.pw_root_tag;1110    const r = await vault.unlockWithKey(keyHex);1111    expect(r.ok).toBe(true);1112    expect(vault.getRootKeyHex()).toBeNull();1113  });11141115  it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1116    const vault = await import("./vault");1117    await vault.createVault("duress-real-pass-aaaa");1118    await vault.setupDuress("duress-decoy-pass-bbbb");11191120    const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1121    const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;11221123    // Equal immediately after setup…1124    expect(lenA()).toBe(lenB());11251126    // …and STILL equal after real-vault edits (pre-fix, slot A grew per 1 KiB1127    // bucket while the frozen decoy stayed put, leaking that duress exists).1128    await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1129    await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1130    expect(lenA()).toBe(lenB());1131  });11321133  it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1134    const vault = await import("./vault");1135    await vault.createVault("rollback-pass-bbbbb");1136    const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });11371138    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1139    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1140      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1141      Object.assign(storage, next);1142    });11431144    await expect(vault.deleteEntry(e.id)).rejects.toThrow();11451146    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1147    // Entry must still be live (not tombstoned) since the delete never persisted.1148    expect(vault.getEntryById(e.id)).toBeDefined();1149    expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1150  });11511152  describe("password history", () => {1153    it("captures the old password on change, newest first", async () => {1154      const vault = await import("./vault");1155      await vault.createVault("history-pass-12345");1156      const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" });1157      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();11581159      await vault.updateEntry(e.id, { password: "second" });1160      await vault.updateEntry(e.id, { password: "third" });11611162      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1163      expect(hist.map((h) => h.password)).toEqual(["second", "first"]);1164      expect(vault.getEntryById(e.id)?.password).toBe("third");1165    });11661167    it("does not record history on a metadata-only update", async () => {1168      const vault = await import("./vault");1169      await vault.createVault("history-meta-12345");1170      const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" });11711172      await vault.updateEntry(e.id, { title: "Renamed" });1173      await vault.updateEntry(e.id, { username: "newuser" });11741175      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1176    });11771178    it("records each history item with the age the password carried", async () => {1179      const vault = await import("./vault");1180      await vault.createVault("history-stamp-12345");1181      const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" });1182      const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt;1183      expect(firstChangedAt).toBeGreaterThan(0);11841185      await new Promise((resolve) => setTimeout(resolve, 2));1186      await vault.updateEntry(e.id, { password: "v2" });11871188      const hist = vault.getEntryById(e.id)!.passwordHistory!;1189      // The retired "v1" entry carries the timestamp it held while current.1190      expect(hist[0].changedAt).toBe(firstChangedAt);1191    });11921193    it("skips capture when re-saving the identical password", async () => {1194      const vault = await import("./vault");1195      await vault.createVault("history-noop-12345");1196      const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" });11971198      await vault.updateEntry(e.id, { password: "same" });11991200      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1201    });12021203    it("does not capture an empty prior password (clear then set)", async () => {1204      const vault = await import("./vault");1205      await vault.createVault("history-empty-12345");1206      const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" });12071208      await vault.updateEntry(e.id, { password: "now-has-one" });12091210      // Nothing worth keeping when the prior value was empty.1211      expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0);1212    });12131214    it("restoring an old password re-records the swapped-out current one", async () => {1215      const vault = await import("./vault");1216      await vault.createVault("history-restore-12345");1217      const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" });1218      await vault.updateEntry(e.id, { password: "new" });12191220      // Restore "old" — the editor just saves the old value as the current one.1221      await vault.updateEntry(e.id, { password: "old" });12221223      const hist = vault.getEntryById(e.id)!.passwordHistory!;1224      expect(vault.getEntryById(e.id)?.password).toBe("old");1225      expect(hist.map((h) => h.password)).toEqual(["new", "old"]);1226    });12271228    it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => {1229      const vault = await import("./vault");1230      await vault.createVault("history-persist-12345");1231      const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" });1232      await vault.updateEntry(e.id, { password: "p2" });12331234      vault.lock();1235      expect(await vault.unlock("history-persist-12345")).toBe(true);12361237      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1238      expect(hist.map((h) => h.password)).toEqual(["p1"]);1239    });12401241    it("caps history at 50 entries, dropping the oldest", async () => {1242      const vault = await import("./vault");1243      await vault.createVault("history-cap-12345");1244      const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" });12451246      for (let i = 1; i <= 55; i++) {1247        await vault.updateEntry(e.id, { password: `pw-${i}` });1248      }12491250      const hist = vault.getEntryById(e.id)!.passwordHistory!;1251      expect(hist).toHaveLength(50);1252      // Newest retired password is pw-54 (pw-55 is now current); oldest kept is pw-5.1253      expect(hist[0].password).toBe("pw-54");1254      expect(hist[hist.length - 1].password).toBe("pw-5");1255    });1256  });12571258  describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1259    it("drops unknown/attacker keys from a restored entry", async () => {1260      const vault = await import("./vault");1261      await vault.createVault("correct horse battery staple");1262      await vault.restoreEntries(1263        [1264          {1265            id: "e1",1266            type: "login",1267            title: "Bank",1268            username: "june",1269            password: "pw",1270            createdAt: 1,1271            modifiedAt: 2,1272            evil: "payload",1273            isAdmin: true,1274            nested: { junk: 1 },1275          } as unknown as import("../shared/types").VaultEntry,1276        ],1277        "replace",1278      );1279      const restored = vault.getEntries().find((e) => e.id === "e1")!;1280      expect(restored.password).toBe("pw");1281      expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1282      expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1283      expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1284    });12851286    it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1287      const vault = await import("./vault");1288      await vault.createVault("correct horse battery staple");1289      await vault.restoreEntries(1290        [1291          {1292            id: "e2",1293            type: "login",1294            title: "Bank",1295            password: "pw",1296            createdAt: 1,1297            modifiedAt: 2,1298            canary: { webhookUrl: "http://attacker.example.com/x" },1299          } as unknown as import("../shared/types").VaultEntry,1300        ],1301        "replace",1302      );1303      const restored = vault.getEntries().find((e) => e.id === "e2")!;1304      expect(restored).toBeDefined();1305      expect(restored.canary).toBeUndefined();1306    });13071308    it("preserves a well-formed HTTPS canary webhook", async () => {1309      const vault = await import("./vault");1310      await vault.createVault("correct horse battery staple");1311      await vault.restoreEntries(1312        [1313          {1314            id: "e3",1315            type: "login",1316            title: "Bank",1317            password: "pw",1318            createdAt: 1,1319            modifiedAt: 2,1320            canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1321          } as unknown as import("../shared/types").VaultEntry,1322        ],1323        "replace",1324      );1325      const restored = vault.getEntries().find((e) => e.id === "e3")!;1326      expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1327      expect(restored.canary?.label).toBe("watch");1328    });13291330    it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1331      const vault = await import("./vault");1332      await vault.createVault("correct horse battery staple");1333      await vault.restoreEntries(1334        [1335          {1336            id: "e4",1337            type: "login",1338            title: "Has bad passkey",1339            createdAt: 1,1340            modifiedAt: 2,1341            passkey: { rpId: "example.com", publicKeyCose: "x" },1342          } as unknown as import("../shared/types").VaultEntry,1343        ],1344        "replace",1345      );1346      expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1347    });13481349    it("migrates a pre-v2.11 flat-passkey backup entry into the passkey sub-object", async () => {1350      const vault = await import("./vault");1351      await vault.createVault("correct horse battery staple");1352      await vault.restoreEntries(1353        [1354          {1355            id: "e5",1356            type: "passkey",1357            title: "Legacy Passkey",1358            createdAt: 1,1359            modifiedAt: 2,1360            rpId: "example.com",1361            rpName: "Example",1362            credentialId: "cred-123",1363            publicKeyCose: "cose-blob",1364            userHandle: "uh",1365            userName: "june",1366            userDisplayName: "June",1367            algorithm: -7,1368          } as unknown as import("../shared/types").VaultEntry,1369        ],1370        "replace",1371      );1372      const restored = vault.getEntries().find((e) => e.id === "e5")!;1373      expect(restored).toBeDefined();1374      expect(restored.passkey?.credentialId).toBe("cred-123");1375      // Flat legacy fields are not kept as top-level keys.1376      expect((restored as unknown as Record<string, unknown>).credentialId).toBeUndefined();1377    });1378  });13791380  describe("MV-8 rotateSyncIdentity", () => {1381    it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1382      const vault = await import("./vault");1383      await vault.createVault("correct horse battery staple");1384      const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1385      const { upsertPairedDevice, listPairedDevices } = await import(1386        "./paired-devices"1387      );1388      await upsertPairedDevice({1389        deviceId: "d1",1390        deviceName: "Phone",1391        deviceType: "android",1392        longTermPublicKeyHex: "a".repeat(64),1393        signingPublicKeyHex: "b".repeat(64),1394        pairedAt: 1,1395        trustSource: "direct",1396      });13971398      const rootBefore = vault.getRootKeyHex();1399      const res = await vault.rotateSyncIdentity();1400      expect(res.ok).toBe(true);1401      expect(vault.getRootKeyHex()).not.toBe(rootBefore);1402      expect(await listPairedDevices()).toHaveLength(0);1403      // Entries survive — they are sealed under the password key, not the root.1404      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");14051406      vault.lock();1407      expect(await vault.unlock("correct horse battery staple")).toBe(true);1408      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1409    });14101411    it("invalidates the old recovery words and flags recovery-pending", async () => {1412      const vault = await import("./vault");1413      await vault.createVault("correct horse battery staple");1414      const wordsBefore = (await vault.getRecoveryWords()).join(" ");14151416      await vault.rotateSyncIdentity();14171418      const wordsAfter = (await vault.getRecoveryWords()).join(" ");1419      expect(wordsAfter).not.toBe(wordsBefore);1420      const { isRecoveryConfirmPending } = await import("./recovery-pending");1421      expect(await isRecoveryConfirmPending()).toBe(true);1422    });14231424    it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1425      const vault = await import("./vault");1426      await vault.createVault("correct horse battery staple");1427      const { createSyncVaultAdapter } = await import(1428        "./sync-vault-adapter"1429      );1430      const adapter = createSyncVaultAdapter();1431      const vidBefore = await adapter.computeVaultId();14321433      await vault.rotateSyncIdentity();14341435      const vidAfter = await adapter.computeVaultId();1436      expect(vidAfter).not.toBe(vidBefore);1437    });1438  });14391440  describe("LV-1 NFC master password normalization", () => {1441    const CAFE_BASE = "café";1442    const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`;1443    const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`;14441445    it("a vault created with the NFD form unlocks with the NFC form", async () => {1446      const vault = await import("./vault");1447      expect(PW_NFD).not.toBe(PW_NFC);1448      await vault.createVault(PW_NFD);1449      vault.lock();1450      expect(await vault.unlock(PW_NFC)).toBe(true);1451    });14521453    it("a legacy vault sealed with raw non-NFC bytes still unlocks and is healed to NFC", async () => {1454      const vault = await import("./vault");1455      const { deriveKeyRaw, generateSalt } = await import("../crypto/argon2");1456      await vault.createVault("base-password-12345");14571458      // Simulate a pre-LV-1 vault: re-seal slot A under the RAW (un-normalized)1459      // NFD bytes, which the NFC chokepoint would never reproduce.1460      const rawSalt = generateSalt();1461      const rawKey = await deriveKeyRaw(PW_NFD, rawSalt);1462      await vault.changePasswordWithNewKey(rawSalt, rawKey);1463      vault.lock();14641465      // Normal NFC derive can't open the raw seal — the legacy-raw fallback must.1466      expect(await vault.unlock(PW_NFD)).toBe(true);14671468      // Healed: the slot is now NFC-sealed, so a second unlock takes the1469      // normal path (and the NFC form opens it too).1470      vault.lock();1471      expect(await vault.unlock(PW_NFC)).toBe(true);1472    });1473  });14741475  describe("LV-7 updateSettings proto-pollution filter", () => {1476    it("strips __proto__/constructor/prototype keys while persisting real settings", async () => {1477      const vault = await import("./vault");1478      await vault.createVault("correct horse battery staple");14791480      await vault.updateSettings({1481        theme: "dark",1482        ["__proto__"]: { polluted: true },1483        constructor: { polluted: true },1484      } as never);14851486      expect(({} as Record<string, unknown>).polluted).toBeUndefined();1487      expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();1488      expect(vault.getSettings().theme).toBe("dark");1489    });1490  });1491});