vault.test.ts

54.9 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 sessionStorage: StorageShape;11  let alarms = { created: [] as Array<{ name: string; delayInMinutes: number }>, cleared: [] as string[] };1213  beforeEach(() => {14    vi.resetModules();15    storage = {};16    sessionStorage = {};17    alarms = { created: [], cleared: [] };1819    const chromeMock = {20      storage: {21        local: {22          get: vi.fn(async (key?: string | string[]) => {23            if (!key) return storage;24            if (typeof key === "string") {25              return { [key]: storage[key] };26            }2728            return key.reduce<Record<string, unknown>>((result, entry) => {29              result[entry] = storage[entry];30              return result;31            }, {});32          }),33          set: vi.fn(async (next: Record<string, unknown>) => {34            Object.assign(storage, next);35          }),36          remove: vi.fn(async (keys: string | string[]) => {37            const list = Array.isArray(keys) ? keys : [keys];38            for (const key of list) {39              delete storage[key];40            }41          }),42        },43        session: {44          get: vi.fn(async (key?: string | string[]) => {45            if (!key) return sessionStorage;46            if (typeof key === "string") return { [key]: sessionStorage[key] };47            return key.reduce<Record<string, unknown>>((result, entry) => {48              result[entry] = sessionStorage[entry];49              return result;50            }, {});51          }),52          set: vi.fn(async (next: Record<string, unknown>) => {53            Object.assign(sessionStorage, next);54          }),55          remove: vi.fn(async (keys: string | string[]) => {56            const list = Array.isArray(keys) ? keys : [keys];57            for (const key of list) delete sessionStorage[key];58          }),59          clear: vi.fn(async () => {60            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];61          }),62        },63      },64      alarms: {65        clear: vi.fn((name: string) => {66          alarms.cleared.push(name);67        }),68        create: vi.fn((name: string, info: { delayInMinutes: number }) => {69          alarms.created.push({ name, delayInMinutes: info.delayInMinutes });70        }),71        onAlarm: { addListener: vi.fn() },72      },73      idle: {74        setDetectionInterval: vi.fn(),75        onStateChanged: { addListener: vi.fn() },76      },77    } as unknown as typeof chrome;7879    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8081    // Prevent fire-and-forget breach-check fetches from leaking into other tests.82    vi.stubGlobal("fetch", vi.fn(async () => {83      throw new Error("network disabled in tests");84    }));85  });8687  it("creates a vault and stores encrypted payload", async () => {88    const vault = await import("./vault");8990    await vault.createVault("correct horse battery staple");9192    expect(storage.peach_vault_a).toBeDefined();93    expect((storage.peach_vault_a as StoredVault).ciphertext).toBeTruthy();94    expect(vault.getEntries()).toEqual([]);95    expect(alarms.created).toContainEqual({96      name: "autolock",97      delayInMinutes: 5,98    });99  });100101  it("writes both slots A and B on vault creation", async () => {102    const vault = await import("./vault");103    await vault.createVault("correct horse battery staple");104105    expect(storage.peach_vault_a).toBeDefined();106    expect(storage.peach_vault_b).toBeDefined();107    expect(storage.peach_duress).toBeUndefined();108    expect(storage.peach_vault).toBeUndefined();109    expect(storage.peach_decoy_vault).toBeUndefined();110  });111112  it("keeps the PIN wrapper in session storage and invalidates it on password change", async () => {113    const vault = await import("./vault");114    const password = "correct horse battery staple";115116    await vault.createVault(password);117    expect(await vault.setupPinUnlock(password, "123")).toBe(false);118    expect(await vault.setupPinUnlock(password, "1234567890123")).toBe(false);119    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);120    expect((sessionStorage.peach_pin_wrap as { ciphertext: string }).ciphertext).toBeTruthy();121    expect(storage.peach_pin_wrap).toBeUndefined();122123    vault.lock();124    expect(await vault.unlockWithPin("000000")).toBe(false);125    expect(await vault.unlockWithPin("1234")).toBe(true);126127    expect(await vault.changePassword(password, "new correct horse battery"))128      .toBe(true);129    vault.lock();130    expect(await vault.unlockWithPin("1234")).toBe(false);131    expect(await vault.unlock("new correct horse battery")).toBe(true);132  });133134  it("requires the master password after three incorrect PIN attempts", async () => {135    const vault = await import("./vault");136    const password = "correct horse battery staple";137138    await vault.createVault(password);139    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);140    vault.lock();141142    expect(await vault.unlockWithPin("0000")).toBe(false);143    expect(await vault.unlockWithPin("0000")).toBe(false);144    expect(await vault.unlockWithPin("0000")).toBe(false);145    expect(await vault.unlockWithPin("1234")).toBe(false);146    expect(await vault.unlock(password)).toBe(true);147148    vault.lock();149    expect(await vault.unlockWithPin("1234")).toBe(true);150  });151152  it("cannot unlock from a copied local-storage snapshot after session storage is cleared", async () => {153    const vault = await import("./vault");154    const password = "correct horse battery staple";155156    await vault.createVault(password);157    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);158    const copiedLocalStorage = { ...storage };159    await (chrome.storage.session as typeof chrome.storage.session).clear();160    vault.lock();161162    expect(copiedLocalStorage.peach_pin_wrap).toBeUndefined();163    await expect(vault.unlockWithPin("1234")).resolves.toBe(false);164    await expect(vault.getPinUnlockStatus()).resolves.toMatchObject({165      configured: true,166      available: false,167      attemptsRemaining: 0,168    });169  });170171  it("slot A and slot B have matching ciphertext size at fresh install", async () => {172    const vault = await import("./vault");173    await vault.createVault("correct horse battery staple");174175    const a = storage.peach_vault_a as StoredVault;176    const b = storage.peach_vault_b as StoredVault;177    expect(a.ciphertext.length).toBe(b.ciphertext.length);178  });179180  it("does not expose a plaintext duress flag after setupDuress", async () => {181    const vault = await import("./vault");182    await vault.createVault("real-password-12345");183    await vault.setupDuress("decoy-password-67890");184185    expect(storage.peach_duress).toBeUndefined();186    expect(storage.peach_decoy_vault).toBeUndefined();187    const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));188    // Slot-neutral keys + optional settings + vault history (snapshot of the189    // pre-setupDuress real-mode blob). No exposed duress flag.190    for (const key of keys) {191      expect(192        [193          "peach_vault_a",194          "peach_vault_b",195          "peach_settings",196          "peach_vault_history",197          "peach_last_modified_at",198        ]199      ).toContain(key);200    }201    expect(keys).toContain("peach_vault_a");202    expect(keys).toContain("peach_vault_b");203  });204205  it("unlocks with real password and loads real entries", async () => {206    const vault = await import("./vault");207    await vault.createVault("real-password-12345");208    await vault.addEntry({209      title: "GitHub",210      username: "june",211      password: "realsecret",212      url: "github.com",213    });214    vault.lock();215216    const ok = await vault.unlock("real-password-12345");217    expect(ok).toBe(true);218    expect(vault.getEntries()).toHaveLength(1);219    expect(vault.getEntries()[0].title).toBe("GitHub");220  });221222  it("unlocks with duress password and loads empty entries", async () => {223    const vault = await import("./vault");224    await vault.createVault("real-password-12345");225    await vault.addEntry({226      title: "GitHub",227      username: "june",228      password: "realsecret",229      url: "github.com",230    });231    await vault.setupDuress("decoy-password-67890");232    vault.lock();233234    const ok = await vault.unlock("decoy-password-67890");235    expect(ok).toBe(true);236    expect(vault.getEntries()).toEqual([]);237  });238239  it("HOLE 5: the duress vault has no sync identity and cannot sync", async () => {240    const vault = await import("./vault");241    await vault.createVault("real-password-12345");242    await vault.addEntry({ type: "login", title: "GitHub", password: "realsecret" });243    await vault.setupDuress("decoy-password-67890");244245    // Baseline: the real vault does have a sync identity.246    expect(vault.getRootKeyHex()).toBeTruthy();247    vault.lock();248249    expect(await vault.unlock("decoy-password-67890")).toBe(true);250    // A second vault_id would be a duress indicator: a coerced user opening the251    // decoy would make sync visibly fail on their other devices. The decoy path252    // never derives a root key, so there is nothing to derive one from.253    expect(vault.getRootKeyHex()).toBeNull();254255    // Every sync adapter operation therefore fails closed rather than syncing,256    // manifesting, or revealing an entry count.257    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");258    const adapter = createSyncVaultAdapter();259    await expect(adapter.computeVaultId()).rejects.toThrow();260    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toThrow();261    vault.lock();262  });263264  it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {265    // The unlock path must derive keys for both slots unconditionally so a266    // real-mode unlock and a decoy-mode unlock both spend roughly the same267    // wall-clock time. Verified here by spying on deriveKey and asserting it268    // is called twice (once per slot) on every unlock attempt.269    const argon2 = await import("../crypto/argon2");270    const deriveSpy = vi.spyOn(argon2, "deriveKey");271272    const vault = await import("./vault");273    await vault.createVault("real-password-12345");274    await vault.setupDuress("decoy-password-67890");275    vault.lock();276277    deriveSpy.mockClear();278    await vault.unlock("real-password-12345");279    expect(deriveSpy).toHaveBeenCalledTimes(2);280    vault.lock();281282    deriveSpy.mockClear();283    await vault.unlock("decoy-password-67890");284    expect(deriveSpy).toHaveBeenCalledTimes(2);285    vault.lock();286287    deriveSpy.mockClear();288    await vault.unlock("wrong-password-xxxxx");289    expect(deriveSpy).toHaveBeenCalledTimes(2);290291    deriveSpy.mockRestore();292  });293294  it("rejects wrong passwords", async () => {295    const vault = await import("./vault");296    await vault.createVault("real-password-12345");297    vault.lock();298299    const ok = await vault.unlock("nope-nope-nope");300    expect(ok).toBe(false);301  });302303  it("persists duressConfigured across lock/unlock in encrypted payload", async () => {304    const vault = await import("./vault");305    await vault.createVault("real-password-12345");306    await vault.setupDuress("decoy-password-67890");307    vault.lock();308309    await vault.unlock("real-password-12345");310    expect(await vault.isDuressEnabled()).toBe(true);311312    await vault.disableDuress();313    expect(await vault.isDuressEnabled()).toBe(false);314    vault.lock();315316    await vault.unlock("real-password-12345");317    expect(await vault.isDuressEnabled()).toBe(false);318  });319320  it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {321    const vault = await import("./vault");322323    await vault.createVault("correct horse battery staple");324    const entry = await vault.addEntry({325      title: "GitHub",326      username: "june",327      password: "secret123",328      url: "github.com",329    });330331    await vault.deleteEntry(entry.id);332333    expect(vault.getEntries()).toEqual([]);334335    const stored = storage.peach_vault_a as StoredVault;336    const salt = Uint8Array.from(337      stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))338    );339    const key = await deriveKey("correct horse battery staple", salt);340    const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");341    const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };342343    expect(parsed.entries).toHaveLength(1);344    expect(parsed.entries[0].id).toBe(entry.id);345    expect(typeof parsed.entries[0].deletedAt).toBe("number");346  });347348  it("accepts imported entries without a username or url", async () => {349    const vault = await import("./vault");350351    await vault.createVault("correct horse battery staple");352    const entry = await vault.addEntry({353      title: "Imported Login",354      username: "",355      password: "secret123",356      url: "",357      notes: "migrated from another manager",358    });359360    expect(entry.username).toBe("");361    expect(entry.url).toBe("");362    expect(vault.getEntries()).toHaveLength(1);363  });364365  it("changePassword happy path — new password unlocks, old is rejected", async () => {366    const vault = await import("./vault");367368    await vault.createVault("old-pass-abc123");369    await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });370    await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });371372    expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);373374    vault.lock();375    expect(await vault.unlock("new-pass-xyz789")).toBe(true);376    expect(vault.getEntries()).toHaveLength(2);377378    vault.lock();379    expect(await vault.unlock("old-pass-abc123")).toBe(false);380  });381382  it("stamps peach_last_modified_at on createVault", async () => {383    const vault = await import("./vault");384    const before = Date.now();385    await new Promise((resolve) => setTimeout(resolve, 1));386387    await vault.createVault("stamp-create-pass");388389    expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);390  });391392  it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {393    const vault = await import("./vault");394    await vault.createVault("stamp-entry-pass");395396    let previous = Number(storage.peach_last_modified_at);397    await new Promise((resolve) => setTimeout(resolve, 1));398    const entry = await vault.addEntry({399      title: "Stamp",400      username: "",401      password: "p",402      url: "",403    });404    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);405406    previous = Number(storage.peach_last_modified_at);407    await new Promise((resolve) => setTimeout(resolve, 1));408    await vault.updateEntry(entry.id, { title: "Stamped" });409    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);410411    previous = Number(storage.peach_last_modified_at);412    await new Promise((resolve) => setTimeout(resolve, 1));413    await vault.deleteEntry(entry.id);414    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);415  });416417  it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {418    const vault = await import("./vault");419    await vault.createVault("stamp-real-pass");420421    let previous = Number(storage.peach_last_modified_at);422    await new Promise((resolve) => setTimeout(resolve, 1));423    await vault.setupDuress("stamp-decoy-pass");424    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);425426    previous = Number(storage.peach_last_modified_at);427    await new Promise((resolve) => setTimeout(resolve, 1));428    expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);429    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);430  });431432  it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {433    const vault = await import("./vault");434435    await vault.createVault("correct-pass-abcde");436    const slotABefore = JSON.stringify(storage.peach_vault_a);437438    expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);439    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);440441    vault.lock();442    expect(await vault.unlock("correct-pass-abcde")).toBe(true);443  });444445  it("changePassword with duress configured leaves slot B byte-identical", async () => {446    const vault = await import("./vault");447448    await vault.createVault("real-pass-abcde");449    await vault.setupDuress("duress-pass-xyzzz");450451    const slotBBefore = JSON.stringify(storage.peach_vault_b);452    expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);453    expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);454455    // Duress path still works with the old decoy password.456    vault.lock();457    expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);458    expect(vault.getEntries()).toEqual([]);459  });460461  it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {462    const vault = await import("./vault");463464    await vault.createVault("pass-abcdef");465    await vault.addEntry({ title: "One", username: "", password: "1", url: "" });466    await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });467    await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });468469    await expect(vault.restoreEntries([], "replace")).rejects.toThrow(470      /refuse to replace with empty entry set/i471    );472    expect(vault.getEntries()).toHaveLength(3);473  });474475  it("changePassword swaps the active key before Slot B is regenerated", async () => {476    const vault = await import("./vault");477478    await vault.createVault("correct-pass-12345");479    const entry = await vault.addEntry({480      title: "GitHub",481      username: "june",482      password: "secret123",483      url: "https://github.com",484    });485486    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;487    const originalSet = setMock.getMockImplementation();488    let injectedDuringSlotBWrite = false;489490    setMock.mockImplementation(async (next: Record<string, unknown>) => {491      Object.assign(storage, next);492493      if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {494        injectedDuringSlotBWrite = true;495        await vault.updateEntry(entry.id, { notes: "updated during password change" });496      }497    });498499    expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);500501    setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {502      Object.assign(storage, next);503    }));504505    vault.lock();506    expect(await vault.unlock("new-pass-67890")).toBe(true);507    expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");508509    vault.lock();510    expect(await vault.unlock("correct-pass-12345")).toBe(false);511  });512513  it("restoreEntries replace mode replaces existing entries", async () => {514    const vault = await import("./vault");515516    await vault.createVault("replace-pass-12345");517    await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });518    await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });519520    const incoming = [521      {522        id: "entry-c",523        type: "login" as const,524        title: "C",525        username: "june",526        password: "three",527        url: "https://c.test",528        createdAt: 10,529        modifiedAt: 10,530      },531      {532        id: "entry-d",533        type: "login" as const,534        title: "D",535        username: "june",536        password: "four",537        url: "https://d.test",538        createdAt: 20,539        modifiedAt: 20,540      },541    ];542543    await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({544      added: 2,545      updated: 0,546      skipped: 0,547    });548549    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);550  });551552  it("restoreEntries merge mode lets newer incoming entries win", async () => {553    const vault = await import("./vault");554555    await vault.createVault("merge-pass-12345");556    const existing = await vault.addEntry({557      title: "GitHub",558      username: "june",559      password: "old-secret",560      url: "https://github.com",561    });562563    await expect(564      vault.restoreEntries(565        [566          {567            ...existing,568            password: "new-secret",569            modifiedAt: existing.modifiedAt + 100,570          },571        ],572        "merge"573      )574    ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });575576    expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");577  });578579  it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {580    const vault = await import("./vault");581582    await vault.createVault("merge-pass-67890");583    const existing = await vault.addEntry({584      title: "GitHub",585      username: "june",586      password: "current-secret",587      url: "https://github.com",588    });589590    await expect(591      vault.restoreEntries(592        [593          {594            ...existing,595            password: "stale-secret",596            modifiedAt: existing.modifiedAt - 100,597          },598        ],599        "merge"600      )601    ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });602603    expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");604  });605606  it("clearVaultEntries without explicit confirmation throws", async () => {607    const vault = await import("./vault");608609    await vault.createVault("clear-pass-12345");610    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });611612    const clearVaultEntries = vault.clearVaultEntries as unknown as (613      confirmation?: unknown614    ) => Promise<void>;615616    await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);617    await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);618    expect(vault.getEntries()).toHaveLength(1);619  });620621  it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {622    const vault = await import("./vault");623624    await vault.createVault("clear-pass-67890");625    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });626    await vault.addEntry({ title: "B", username: "", password: "two", url: "" });627    await vault.addEntry({ title: "C", username: "", password: "three", url: "" });628629    await vault.clearVaultEntries("REPLACE_ALL");630631    expect(vault.getEntries()).toEqual([]);632    expect(vault.getDeletedEntries()).toHaveLength(3);633    expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);634  });635636  it("deduplicateEntries throws in decoy mode", async () => {637    const vault = await import("./vault");638639    await vault.createVault("real-pass-12345");640    await vault.addEntry({641      title: "GitHub",642      username: "june",643      password: "secret123",644      url: "https://github.com",645    });646    await vault.setupDuress("duress-pass-12345");647    vault.lock();648649    expect(await vault.unlock("duress-pass-12345")).toBe(true);650    await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);651  });652653  it("deduplicateEntries removes older duplicates in real mode", async () => {654    const vault = await import("./vault");655656    await vault.createVault("dedupe-pass-12345");657    await vault.addEntry({658      title: "GitHub",659      username: "june",660      password: "old-secret",661      url: "https://github.com",662    });663    await new Promise<void>((resolve) => setTimeout(resolve, 5));664    const newer = await vault.addEntry({665      title: "GitHub",666      username: "june",667      password: "new-secret",668      url: "https://github.com",669    });670671    expect(await vault.deduplicateEntries()).toBe(1);672    expect(vault.getEntries()).toHaveLength(1);673    expect(vault.getEntries()[0].id).toBe(newer.id);674    expect(vault.getDeletedEntries()).toHaveLength(1);675  });676677  it("updateEntry with an empty password on a login entry clears the password", async () => {678    const vault = await import("./vault");679680    await vault.createVault("update-pass-12345");681    const entry = await vault.addEntry({682      title: "GitHub",683      username: "june",684      password: "secret123",685      url: "https://github.com",686    });687688    await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined();689    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();690  });691692  it("updateEntry with an empty password on a note entry is allowed", async () => {693    const vault = await import("./vault");694695    await vault.createVault("note-pass-12345");696    const entry = await vault.addEntry({697      type: "note",698      title: "Secure note",699      notes: "keep me",700    });701702    await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();703    expect(vault.getEntryById(entry.id)).toMatchObject({704      type: "note",705      notes: "updated",706    });707  });708709  it("lock zeroes the derived key and resets unlock state", async () => {710    const aes = await import("./aes");711    const zeroKeySpy = vi.spyOn(aes, "zeroKey");712    const vault = await import("./vault");713714    await vault.createVault("lock-pass-12345");715    expect(vault.isUnlocked()).toBe(true);716717    vault.lock();718719    expect(zeroKeySpy).toHaveBeenCalled();720    expect(vault.isUnlocked()).toBe(false);721    zeroKeySpy.mockRestore();722  });723724  it("lock clears in-memory entries", async () => {725    const vault = await import("./vault");726727    await vault.createVault("lock-pass-67890");728    const entry = await vault.addEntry({729      title: "GitHub",730      username: "june",731      password: "secret123",732      url: "https://github.com",733    });734735    expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");736    vault.lock();737    expect(vault.getEntryById(entry.id)).toBeUndefined();738  });739740  it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {741    const vault = await import("./vault");742743    await vault.createVault("real-pass-67890");744    vault.lock();745746    expect(await vault.unlock("totally-wrong-password")).toBe(false);747    expect(vault.isUnlocked()).toBe(false);748  });749750  it("isUnlocked tracks the full lock lifecycle", async () => {751    const vault = await import("./vault");752753    expect(vault.isUnlocked()).toBe(false);754    await vault.createVault("unlock-pass-12345");755    expect(vault.isUnlocked()).toBe(true);756    vault.lock();757    expect(vault.isUnlocked()).toBe(false);758    expect(await vault.unlock("unlock-pass-12345")).toBe(true);759    expect(vault.isUnlocked()).toBe(true);760    vault.lock();761    expect(vault.isUnlocked()).toBe(false);762  });763764  it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {765    const vault = await import("./vault");766767    await vault.createVault("quota-pass-12345");768769    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;770    setMock.mockImplementation(async (next: Record<string, unknown>) => {771      if ("peach_vault_a" in next || "peach_vault_b" in next) {772        throw new Error("QUOTA_BYTES quota reached");773      }774      Object.assign(storage, next);775    });776777    await expect(778      vault.addEntry({779        title: "GitHub",780        username: "june",781        password: "secret123",782        url: "https://github.com",783      })784    ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");785  });786787  it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {788    const vault = await import("./vault");789790    await vault.createVault("settings-pass-12345");791792    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;793    setMock.mockImplementation(async (next: Record<string, unknown>) => {794      if ("peach_settings" in next) {795        throw new Error("QUOTA_BYTES quota reached");796      }797      Object.assign(storage, next);798    });799800    await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(801      "STORAGE_QUOTA_EXCEEDED"802    );803  });804805  it("card entry round-trips through vault — all fields preserved", async () => {806    const vault = await import("./vault");807808    await vault.createVault("card-test-pass-12345");809    const entry = await vault.addEntry({810      type: "card",811      title: "Personal Visa",812      cardholderName: "Jane Smith",813      cardNumber: "4111111111111111",814      expMonth: "05",815      expYear: "2028",816      cvc: "123",817      cardNickname: "Personal Visa",818      network: "visa",819      last4: "1111",820    });821822    expect(entry.type).toBe("card");823    expect(entry.cardholderName).toBe("Jane Smith");824    expect(entry.cardNumber).toBe("4111111111111111");825    expect(entry.expMonth).toBe("05");826    expect(entry.expYear).toBe("2028");827    expect(entry.cvc).toBe("123");828    expect(entry.network).toBe("visa");829    expect(entry.last4).toBe("1111");830831    vault.lock();832    await vault.unlock("card-test-pass-12345");833    const entries = vault.getEntries();834    expect(entries).toHaveLength(1);835    expect(entries[0].cardNumber).toBe("4111111111111111");836    expect(entries[0].cvc).toBe("123");837  });838839  it("updateEntry — cannot clear card number on card entry", async () => {840    const vault = await import("./vault");841842    await vault.createVault("card-guard-pass-12345");843    const entry = await vault.addEntry({844      type: "card",845      title: "My Card",846      cardholderName: "Jane Smith",847      cardNumber: "4111111111111111",848      expMonth: "05",849      expYear: "2028",850      cvc: "123",851    });852853    await expect(854      vault.updateEntry(entry.id, { cardNumber: "" })855    ).rejects.toThrow("card number is required for card entries");856857    const entries = vault.getEntries();858    expect(entries[0].cardNumber).toBe("4111111111111111");859  });860861  it("declineCardSaveOffer — stores fingerprint in settings", async () => {862    const vault = await import("./vault");863864    await vault.createVault("decline-pass-12345");865    await vault.declineCardSaveOffer("1111", "05", "2028");866867    const settings = vault.getSettings();868    expect(settings.declinedCardFingerprints).toHaveLength(1);869  });870871  it("isCardDeclined — returns true for previously declined card", async () => {872    const vault = await import("./vault");873874    await vault.createVault("declined-check-pass-12345");875    await vault.declineCardSaveOffer("1111", "05", "2028");876877    expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);878  });879880  it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {881    const vault = await import("./vault");882883    await vault.createVault("declined-diff-pass-12345");884    await vault.declineCardSaveOffer("1111", "05", "2028");885886    expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);887  });888889  it("queryDomain matches an entry against its additionalUrls", async () => {890    const vault = await import("./vault");891892    await vault.createVault("multi-domain-pass-12345");893    const entry = await vault.addEntry({894      type: "login",895      title: "Foo",896      username: "june",897      password: "secret",898      url: "https://foo.com",899      additionalUrls: ["https://bar.io", "https://baz.org"],900    });901902    // Vault index keys by eTLD+1 (tldts.getDomain), so additionalUrls only903    // add new buckets when the eTLD+1 is genuinely different from the primary.904    expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);905    expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);906    expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);907    expect(vault.queryDomain("unrelated.com")).toEqual([]);908  });909910  it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {911    const vault = await import("./vault");912913    await vault.createVault("dedupe-additional-pass-12345");914    const entry = await vault.addEntry({915      type: "login",916      title: "Foo",917      username: "june",918      password: "secret",919      url: "https://foo.com",920    });921922    await vault.updateEntry(entry.id, {923      additionalUrls: [924        "  https://login.foo.com  ",925        "https://login.foo.com",926        "",927        "  ",928        "https://accounts.foo.com",929      ],930    });931932    const stored = vault.getEntryById(entry.id);933    expect(stored?.additionalUrls).toEqual([934      "https://login.foo.com",935      "https://accounts.foo.com",936    ]);937938    await vault.updateEntry(entry.id, { additionalUrls: ["", "   "] });939    expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();940  });941942  it("preserves email field through lock/unlock cycle", async () => {943    const vault = await import("./vault");944    await vault.createVault("preserve-email-pass-12345");945    await vault.addEntry({946      type: "login",947      title: "GitHub",948      username: "june",949      email: "[email protected]",950      password: "secret",951      url: "https://github.com",952    });953    vault.lock();954955    const ok = await vault.unlock("preserve-email-pass-12345");956    expect(ok).toBe(true);957    const entry = vault.getEntries().find((e) => e.title === "GitHub");958    expect(entry?.email).toBe("[email protected]");959  });960961  it("preserves additionalUrls through lock/unlock cycle", async () => {962    const vault = await import("./vault");963    await vault.createVault("preserve-urls-pass-12345");964    await vault.addEntry({965      type: "login",966      title: "Example",967      username: "june",968      password: "secret",969      url: "https://example.com",970      additionalUrls: [971        "https://beta.example.com",972        "https://staging.example.com",973      ],974    });975    vault.lock();976977    const ok = await vault.unlock("preserve-urls-pass-12345");978    expect(ok).toBe(true);979    const entry = vault.getEntries().find((e) => e.title === "Example");980    expect(entry?.additionalUrls).toEqual([981      "https://beta.example.com",982      "https://staging.example.com",983    ]);984  });985986  it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => {987    const vault = await import("./vault");988    await vault.createVault("pwchanged-preserve-pass");989    const entry = await vault.addEntry({990      title: "Aged",991      username: "june",992      password: "secret",993      url: "https://aged.test",994    });995    // addEntry stamps passwordChangedAt at creation; capture it and prove it996    // survives the encode/decode round-trip unchanged (was dropped pre-fix).997    const ts = entry.passwordChangedAt;998    expect(typeof ts).toBe("number");999    vault.lock();10001001    expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true);1002    const restored = vault.getEntries().find((e) => e.id === entry.id);1003    expect(restored?.passwordChangedAt).toBe(ts);1004  });10051006  it("normalizeEntry handles entries without passwordChangedAt", async () => {1007    const vault = await import("./vault");1008    await vault.createVault("pwchanged-absent-pass");1009    // A note carries no password and therefore no passwordChangedAt stamp.1010    const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" });1011    vault.lock();10121013    expect(await vault.unlock("pwchanged-absent-pass")).toBe(true);1014    const restored = vault.getEntries().find((e) => e.id === note.id);1015    expect(restored?.passwordChangedAt).toBeUndefined();1016  });10171018  it("applyMergedRemoteEntries does not advance the vault version", async () => {1019    const vault = await import("./vault");1020    await vault.createVault("syncver-pass-aaaaa");1021    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });10221023    const before = await vault.getVaultVersion();1024    await new Promise((r) => setTimeout(r, 2));1025    await vault.applyMergedRemoteEntries(1026      [1027        {1028          id: "remote-1",1029          type: "login",1030          title: "Remote",1031          username: "r",1032          password: "x",1033          url: "https://r.test",1034          createdAt: Date.now(),1035          modifiedAt: Date.now(),1036        },1037      ],1038      {},1039    );10401041    expect(await vault.getVaultVersion()).toBe(before);1042  });10431044  it("addEntry advances the vault version", async () => {1045    const vault = await import("./vault");1046    await vault.createVault("syncver-pass-bbbbb");10471048    const before = await vault.getVaultVersion();1049    await new Promise((r) => setTimeout(r, 2));1050    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });10511052    expect(await vault.getVaultVersion()).toBeGreaterThan(before);1053  });10541055  it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {1056    const vault = await import("./vault");1057    await vault.createVault("monotonic-pass-aaaaa");1058    const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });1059    const before = vault.getEntryById(e1.id)!.modifiedAt;10601061    // Simulate the clock jumping 10s backwards (NTP step / manual change).1062    const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);1063    await vault.updateEntry(e1.id, { password: "p2" });1064    const after = vault.getEntryById(e1.id)!.modifiedAt;1065    expect(after).toBeGreaterThan(before);1066    spy.mockRestore();1067  });10681069  it("deletedAt is strictly greater than the entry's modifiedAt", async () => {1070    const vault = await import("./vault");1071    await vault.createVault("monotonic-pass-bbbbb");1072    const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });1073    const modAt = vault.getEntryById(e.id)!.modifiedAt;1074    await vault.deleteEntry(e.id);1075    const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;1076    expect(tomb.deletedAt!).toBeGreaterThan(modAt);1077  });10781079  it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {1080    const vault = await import("./vault");1081    await vault.createVault("rollback-pass-aaaaa");1082    const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });10831084    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1085    // Fail only the slot-A write (the history snapshot write is swallowed by design).1086    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1087      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1088      Object.assign(storage, next);1089    });10901091    await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();10921093    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1094    // In-memory entry must still hold the original password, not the failed edit.1095    expect(vault.getEntryById(e.id)!.password).toBe("original");1096  });10971098  it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1099    const vault = await import("./vault");1100    await vault.createVault("rootkey-pass-aaaaa");1101    const before = vault.getRootKeyHex();1102    expect(before).toMatch(/^[0-9a-f]{64}$/);11031104    expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1105    expect(vault.getRootKeyHex()).toBe(before);11061107    // …and it survives a lock/unlock with the new password (rehydrated from pw_root_*).1108    vault.lock();1109    expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1110    expect(vault.getRootKeyHex()).toBe(before);1111  });11121113  it("getRootKeyHex returns null when the vault is locked", async () => {1114    const vault = await import("./vault");1115    await vault.createVault("rootkey-pass-ccccc");1116    vault.lock();1117    expect(vault.getRootKeyHex()).toBeNull();1118  });11191120  it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => {1121    const vault = await import("./vault");1122    await vault.createVault("biometric-pass-12345");1123    const rootBefore = vault.getRootKeyHex();1124    const keyHex = vault.getDerivedKeyHex();1125    expect(rootBefore).not.toBeNull();1126    vault.lock();1127    // The biometric path passes the pre-derived AES key; rootKey must rehydrate.1128    const r = await vault.unlockWithKey(keyHex);1129    expect(r.ok).toBe(true);1130    expect(vault.getRootKeyHex()).toBe(rootBefore);1131  });11321133  it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1134    const vault = await import("./vault");1135    await vault.createVault("duress-real-pass-aaaa");1136    await vault.setupDuress("duress-decoy-pass-bbbb");11371138    const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1139    const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;11401141    // Equal immediately after setup…1142    expect(lenA()).toBe(lenB());11431144    // …and STILL equal after real-vault edits (pre-fix, slot A grew per 1 KiB1145    // bucket while the frozen decoy stayed put, leaking that duress exists).1146    await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1147    await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1148    expect(lenA()).toBe(lenB());1149  });11501151  it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1152    const vault = await import("./vault");1153    await vault.createVault("rollback-pass-bbbbb");1154    const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });11551156    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1157    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1158      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1159      Object.assign(storage, next);1160    });11611162    await expect(vault.deleteEntry(e.id)).rejects.toThrow();11631164    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1165    // Entry must still be live (not tombstoned) since the delete never persisted.1166    expect(vault.getEntryById(e.id)).toBeDefined();1167    expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1168  });11691170  describe("password history", () => {1171    it("captures the old password on change, newest first", async () => {1172      const vault = await import("./vault");1173      await vault.createVault("history-pass-12345");1174      const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" });1175      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();11761177      await vault.updateEntry(e.id, { password: "second" });1178      await vault.updateEntry(e.id, { password: "third" });11791180      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1181      expect(hist.map((h) => h.password)).toEqual(["second", "first"]);1182      expect(vault.getEntryById(e.id)?.password).toBe("third");1183    });11841185    it("does not record history on a metadata-only update", async () => {1186      const vault = await import("./vault");1187      await vault.createVault("history-meta-12345");1188      const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" });11891190      await vault.updateEntry(e.id, { title: "Renamed" });1191      await vault.updateEntry(e.id, { username: "newuser" });11921193      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1194    });11951196    it("records each history item with the age the password carried", async () => {1197      const vault = await import("./vault");1198      await vault.createVault("history-stamp-12345");1199      const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" });1200      const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt;1201      expect(firstChangedAt).toBeGreaterThan(0);12021203      await new Promise((resolve) => setTimeout(resolve, 2));1204      await vault.updateEntry(e.id, { password: "v2" });12051206      const hist = vault.getEntryById(e.id)!.passwordHistory!;1207      // The retired "v1" entry carries the timestamp it held while current.1208      expect(hist[0].changedAt).toBe(firstChangedAt);1209    });12101211    it("skips capture when re-saving the identical password", async () => {1212      const vault = await import("./vault");1213      await vault.createVault("history-noop-12345");1214      const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" });12151216      await vault.updateEntry(e.id, { password: "same" });12171218      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1219    });12201221    it("does not capture an empty prior password (clear then set)", async () => {1222      const vault = await import("./vault");1223      await vault.createVault("history-empty-12345");1224      const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" });12251226      await vault.updateEntry(e.id, { password: "now-has-one" });12271228      // Nothing worth keeping when the prior value was empty.1229      expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0);1230    });12311232    it("restoring an old password re-records the swapped-out current one", async () => {1233      const vault = await import("./vault");1234      await vault.createVault("history-restore-12345");1235      const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" });1236      await vault.updateEntry(e.id, { password: "new" });12371238      // Restore "old" — the editor just saves the old value as the current one.1239      await vault.updateEntry(e.id, { password: "old" });12401241      const hist = vault.getEntryById(e.id)!.passwordHistory!;1242      expect(vault.getEntryById(e.id)?.password).toBe("old");1243      expect(hist.map((h) => h.password)).toEqual(["new", "old"]);1244    });12451246    it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => {1247      const vault = await import("./vault");1248      await vault.createVault("history-persist-12345");1249      const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" });1250      await vault.updateEntry(e.id, { password: "p2" });12511252      vault.lock();1253      expect(await vault.unlock("history-persist-12345")).toBe(true);12541255      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1256      expect(hist.map((h) => h.password)).toEqual(["p1"]);1257    });12581259    it("caps history at 50 entries, dropping the oldest", async () => {1260      const vault = await import("./vault");1261      await vault.createVault("history-cap-12345");1262      const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" });12631264      for (let i = 1; i <= 55; i++) {1265        await vault.updateEntry(e.id, { password: `pw-${i}` });1266      }12671268      const hist = vault.getEntryById(e.id)!.passwordHistory!;1269      expect(hist).toHaveLength(50);1270      // Newest retired password is pw-54 (pw-55 is now current); oldest kept is pw-5.1271      expect(hist[0].password).toBe("pw-54");1272      expect(hist[hist.length - 1].password).toBe("pw-5");1273    });1274  });12751276  describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1277    it("drops unknown/attacker keys from a restored entry", async () => {1278      const vault = await import("./vault");1279      await vault.createVault("correct horse battery staple");1280      await vault.restoreEntries(1281        [1282          {1283            id: "e1",1284            type: "login",1285            title: "Bank",1286            username: "june",1287            password: "pw",1288            createdAt: 1,1289            modifiedAt: 2,1290            evil: "payload",1291            isAdmin: true,1292            nested: { junk: 1 },1293          } as unknown as import("../shared/types").VaultEntry,1294        ],1295        "replace",1296      );1297      const restored = vault.getEntries().find((e) => e.id === "e1")!;1298      expect(restored.password).toBe("pw");1299      expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1300      expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1301      expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1302    });13031304    it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1305      const vault = await import("./vault");1306      await vault.createVault("correct horse battery staple");1307      await vault.restoreEntries(1308        [1309          {1310            id: "e2",1311            type: "login",1312            title: "Bank",1313            password: "pw",1314            createdAt: 1,1315            modifiedAt: 2,1316            canary: { webhookUrl: "http://attacker.example.com/x" },1317          } as unknown as import("../shared/types").VaultEntry,1318        ],1319        "replace",1320      );1321      const restored = vault.getEntries().find((e) => e.id === "e2")!;1322      expect(restored).toBeDefined();1323      expect(restored.canary).toBeUndefined();1324    });13251326    it("preserves a well-formed HTTPS canary webhook", async () => {1327      const vault = await import("./vault");1328      await vault.createVault("correct horse battery staple");1329      await vault.restoreEntries(1330        [1331          {1332            id: "e3",1333            type: "login",1334            title: "Bank",1335            password: "pw",1336            createdAt: 1,1337            modifiedAt: 2,1338            canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1339          } as unknown as import("../shared/types").VaultEntry,1340        ],1341        "replace",1342      );1343      const restored = vault.getEntries().find((e) => e.id === "e3")!;1344      expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1345      expect(restored.canary?.label).toBe("watch");1346    });13471348    it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1349      const vault = await import("./vault");1350      await vault.createVault("correct horse battery staple");1351      await vault.restoreEntries(1352        [1353          {1354            id: "e4",1355            type: "login",1356            title: "Has bad passkey",1357            createdAt: 1,1358            modifiedAt: 2,1359            passkey: { rpId: "example.com", publicKeyCose: "x" },1360          } as unknown as import("../shared/types").VaultEntry,1361        ],1362        "replace",1363      );1364      expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1365    });13661367  });13681369  describe("MV-8 rotateSyncIdentity", () => {1370    it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1371      const vault = await import("./vault");1372      await vault.createVault("correct horse battery staple");1373      const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1374      const { upsertPairedDevice, listPairedDevices } = await import(1375        "./paired-devices"1376      );1377      await upsertPairedDevice({1378        deviceId: "d1",1379        deviceName: "Phone",1380        deviceType: "android",1381        longTermPublicKeyHex: "a".repeat(64),1382        signingPublicKeyHex: "b".repeat(64),1383        pairedAt: 1,1384        trustSource: "direct",1385      });13861387      const rootBefore = vault.getRootKeyHex();1388      const res = await vault.rotateSyncIdentity();1389      expect(res.ok).toBe(true);1390      expect(vault.getRootKeyHex()).not.toBe(rootBefore);1391      expect(await listPairedDevices()).toHaveLength(0);1392      // Entries survive — they are sealed under the password key, not the root.1393      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");13941395      vault.lock();1396      expect(await vault.unlock("correct horse battery staple")).toBe(true);1397      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1398    });13991400    it("invalidates the old recovery words and flags recovery-pending", async () => {1401      const vault = await import("./vault");1402      await vault.createVault("correct horse battery staple");1403      const wordsBefore = (await vault.getRecoveryWords()).join(" ");14041405      await vault.rotateSyncIdentity();14061407      const wordsAfter = (await vault.getRecoveryWords()).join(" ");1408      expect(wordsAfter).not.toBe(wordsBefore);1409      const { isRecoveryConfirmPending } = await import("./recovery-pending");1410      expect(await isRecoveryConfirmPending()).toBe(true);1411    });14121413    it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1414      const vault = await import("./vault");1415      await vault.createVault("correct horse battery staple");1416      const { createSyncVaultAdapter } = await import(1417        "./sync-vault-adapter"1418      );1419      const adapter = createSyncVaultAdapter();1420      const vidBefore = await adapter.computeVaultId();14211422      await vault.rotateSyncIdentity();14231424      const vidAfter = await adapter.computeVaultId();1425      expect(vidAfter).not.toBe(vidBefore);1426    });1427  });14281429  describe("LV-1 NFC master password normalization", () => {1430    const CAFE_BASE = "café";1431    const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`;1432    const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`;14331434    it("a vault created with the NFD form unlocks with the NFC form", async () => {1435      const vault = await import("./vault");1436      expect(PW_NFD).not.toBe(PW_NFC);1437      await vault.createVault(PW_NFD);1438      vault.lock();1439      expect(await vault.unlock(PW_NFC)).toBe(true);1440    });14411442  });14431444  describe("LV-7 updateSettings proto-pollution filter", () => {1445    it("strips __proto__/constructor/prototype keys while persisting real settings", async () => {1446      const vault = await import("./vault");1447      await vault.createVault("correct horse battery staple");14481449      await vault.updateSettings({1450        theme: "dark",1451        ["__proto__"]: { polluted: true },1452        constructor: { polluted: true },1453      } as never);14541455      expect(({} as Record<string, unknown>).polluted).toBeUndefined();1456      expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();1457      expect(vault.getSettings().theme).toBe("dark");1458    });1459  });14601461  // PB-26-011: URL scheme allow-list at the storage boundary — imported,1462  // added, or patched URLs with a non-http(s) scheme are dropped. Schemeless1463  // values (e.g. "github.com") are kept: they are inert and pre-date this1464  // guard in existing data.1465  describe("PB-26-011 url scheme allow-list", () => {1466    let vault: typeof import("./vault");14671468    beforeEach(async () => {1469      vault = await import("./vault");1470      await vault.createVault("correct horse battery staple");1471    });14721473    it("drops a javascript: url on addEntry", async () => {1474      const entry = await vault.addEntry({1475        title: "Evil",1476        username: "june",1477        password: "secret1",1478        url: "javascript:alert(1)",1479      });1480      const stored = vault.getEntryById(entry.id);1481      expect(stored?.url).toBe("");1482    });14831484    it("drops non-http(s) schemes from additionalUrls", async () => {1485      const entry = await vault.addEntry({1486        title: "Evil",1487        username: "june",1488        password: "secret1",1489        url: "https://ok.test",1490        additionalUrls: [1491          "javascript:alert(1)",1492          "data:text/html,<b>x</b>",1493          "https://also-ok.test",1494        ],1495      });1496      const stored = vault.getEntryById(entry.id);1497      expect(stored?.additionalUrls).toEqual(["https://also-ok.test"]);1498    });14991500    it("drops a data: url on UPDATE_ENTRY patch", async () => {1501      const entry = await vault.addEntry({1502        title: "Ok",1503        username: "june",1504        password: "secret1",1505        url: "https://ok.test",1506      });1507      await vault.updateEntry(entry.id, { url: "data:text/html,<b>x</b>" });1508      const stored = vault.getEntryById(entry.id);1509      expect(stored?.url).toBe("");1510    });15111512    it("keeps https and schemeless urls", async () => {1513      const entry = await vault.addEntry({1514        title: "Ok",1515        username: "june",1516        password: "secret1",1517        url: "https://github.com",1518        additionalUrls: ["github.com"],1519      });1520      const stored = vault.getEntryById(entry.id);1521      expect(stored?.url).toBe("https://github.com");1522      expect(stored?.additionalUrls).toEqual(["github.com"]);1523    });1524  });1525});